From a13ea9c4eabcf921fdd97a36da33444c07043b41 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 1 Jun 2026 12:51:47 +0200 Subject: [PATCH] chore: normalize line endings to LF via .gitattributes The repo had no .gitattributes and core.autocrlf was unset, so line endings drifted to a CRLF/LF mix. Add '* text=auto eol=lf' (Windows scripts stay CRLF, binaries untouched) and renormalize all tracked text files to LF. Mechanical only, no content changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .editorconfig | 112 +- .gitattributes | 19 + .github/workflows/03-publish-prerelease.yml | 264 +- .github/workflows/04-publish-stable.yml | 382 +-- .github/workflows/05-deploy-docs.yml | 120 +- .gitignore | 788 +++--- AGENTS.md | 842 +++--- CHANGELOG.md | 1260 ++++----- GitVersion.yml | 62 +- LICENSE | 402 +-- NOTICE | 32 +- SECURITY.md | 24 +- TRADEMARKS.md | 46 +- .../Cocoar.Configuration.Abstractions.csproj | 22 +- .../Core/IConfigurationAccessor.cs | 160 +- .../ISecret.cs | 34 +- .../Reactive/IReactiveConfig.cs | 40 +- .../SecretLease.cs | 72 +- .../AnalyzerReleases.Shipped.md | 28 +- .../AnalyzerReleases.Unshipped.md | 26 +- .../DuplicateUnconditionalRulesAnalyzer.cs | 342 +-- .../RequiredRuleValidationAnalyzer.cs | 316 +-- .../Analyzers/SecretPathConflictAnalyzer.cs | 448 +-- .../CompilerServicesPolyfill.cs | 84 +- .../DiagnosticDescriptors.cs | 170 +- .../Flags/CocoarFlagsGenerator.cs | 1440 +++++----- .../Flags/FlagsGeneratorDiagnostics.cs | 90 +- src/Cocoar.Configuration.Analyzers/README.md | 238 +- .../Cocoar.Configuration.AspNetCore.csproj | 34 +- ...CocoarConfigurationAspNetCoreExtensions.cs | 98 +- .../FlagEvaluationExtensions.cs | 386 +-- .../Health/CocoarConfigurationHealthCheck.cs | 66 +- .../Health/CocoarHealthCheckExtensions.cs | 40 +- .../Capabilities/DiCapabilities.cs | 30 +- .../Cocoar.Configuration.DI.csproj | 38 +- .../CocoarConfigurationExtensions.cs | 106 +- .../ExposedTypeSetup.cs | 62 +- .../Extensions/ConcreteTypeSetupExtensions.cs | 78 +- .../Extensions/ExposedTypeSetupExtensions.cs | 76 +- .../ServiceRegistrationInfo.cs | 34 +- .../ServiceRegistrationPlanner.cs | 332 +-- ...coar.Configuration.MicrosoftAdapter.csproj | 34 +- .../MicrosoftConfigurationSourceProvider.cs | 360 +-- ...osoftConfigurationSourceProviderOptions.cs | 44 +- ...ConfigurationSourceProviderQueryOptions.cs | 20 +- ...MicrosoftConfigurationSourceRuleOptions.cs | 60 +- .../Cocoar.Configuration.Secrets.Cli.csproj | 56 +- .../Commands/CertInfoCommand.cs | 506 ++-- .../Commands/ConvertCertCommand.cs | 434 +-- .../Commands/DecryptCommand.cs | 538 ++-- .../Commands/EncryptCommand.cs | 434 +-- .../Commands/GenerateCertCommand.cs | 362 +-- .../Program.cs | 42 +- .../README.md | 398 +-- ...Cocoar.Configuration.X509Encryption.csproj | 30 +- .../HybridSecretEnvelope.cs | 82 +- .../JsonSecretsEditor.cs | 700 ++--- .../X509CertificateGenerator.cs | 632 ++--- .../X509HybridCrypto.cs | 296 +- src/Cocoar.Configuration.slnx | 90 +- .../Configure/ConcreteTypeSetup.cs | 98 +- .../Configure/ConfigureSpec.cs | 30 +- .../Configure/IDeferredConfiguration.cs | 26 +- .../Configure/InterfaceTypeSetup.cs | 106 +- .../Configure/SetupBuilder.cs | 86 +- .../Core/ConfigJsonRepository.cs | 264 +- .../Core/ConfigManager.cs | 1306 ++++----- .../Core/ConfigManagerBuilder.cs | 502 ++-- .../Core/ConfigManagerCapabilityScope.cs | 36 +- .../Core/ConfigSnapshot.cs | 204 +- .../Core/ConfigSnapshotBuilder.cs | 392 +-- .../Core/ConfigurationAccessor.cs | 596 ++-- .../Core/ConfigurationEngine.cs | 1162 ++++---- .../Core/ConfigurationState.cs | 368 +-- .../Core/MasterBackplane.cs | 338 +-- .../Core/RecomputeScheduler.cs | 180 +- .../Diagnostics/CocoarMetrics.cs | 44 +- .../ISerializerSetupCapability.cs | 30 +- .../ISerializerSetupContributor.cs | 22 +- .../Flags/ConfigManagerFlagsExtensions.cs | 270 +- .../Descriptors/EntitlementClassDescriptor.cs | 18 +- .../EntitlementDefinitionDescriptor.cs | 16 +- .../Descriptors/FeatureFlagClassDescriptor.cs | 28 +- .../Descriptors/FlagDefinitionDescriptor.cs | 16 +- src/Cocoar.Configuration/Flags/Entitlement.cs | 28 +- .../Flags/EntitlementsDescriptors.cs | 30 +- src/Cocoar.Configuration/Flags/FeatureFlag.cs | 28 +- .../Flags/FeatureFlagsDescriptors.cs | 38 +- .../Flags/IContextResolver.cs | 78 +- .../Flags/IEntitlementEvaluator.cs | 128 +- .../Flags/IEntitlementsDescriptors.cs | 40 +- .../Flags/IFeatureFlagEvaluator.cs | 128 +- .../Flags/IFeatureFlagsDescriptors.cs | 54 +- .../Flags/Internal/DescriptorLookup.cs | 108 +- .../Flags/Internal/EntitlementEvaluator.cs | 166 +- .../Flags/Internal/FeatureFlagEvaluator.cs | 166 +- .../Internal/FeatureFlagsHealthSource.cs | 24 +- .../Flags/Internal/FlagEvaluationEntry.cs | 208 +- .../Fluent/IConfigRuleBuilder.cs | 16 +- .../Fluent/ProviderRuleBuilder.cs | 120 +- .../Fluent/RuleBuilderBase.cs | 304 +- .../Fluent/RulesBuilder.cs | 80 +- .../Fluent/TypedRuleBuilder.cs | 24 +- src/Cocoar.Configuration/GlobalUsings.cs | 2 +- .../Health/ConfigurationHealthModels.cs | 18 +- .../Health/IFlagsHealthSource.cs | 26 +- src/Cocoar.Configuration/Helper/JsonHelper.cs | 444 +-- .../Helper/JsonTransform.cs | 458 +-- .../ChangeSubscriptionManager.cs | 110 +- .../Infrastructure/ExposureRegistry.cs | 244 +- .../Infrastructure/ProviderRegistry.cs | 374 +-- .../Infrastructure/RecomputeCoalescer.cs | 430 +-- .../Properties/AssemblyInfo.cs | 32 +- .../ConfigurationProvider.Generic.cs | 84 +- .../Abstractions/ConfigurationProvider.cs | 38 +- .../Abstractions/IProviderConfiguration.cs | 42 +- .../Providers/Abstractions/IProviderQuery.cs | 10 +- .../CommandLineArgumentProvider.cs | 276 +- .../CommandLineArgumentRulesExtensions.cs | 76 +- .../CommandLineProvider/CommandLineOptions.cs | 36 +- .../Providers/CommandLineProvider/README.md | 544 ++-- .../EnvironmentVariableOptions.cs | 26 +- .../EnvironmentVariableProvider.cs | 320 +-- .../EnvironmentVariableRulesExtensions.cs | 68 +- .../FileSourceProvider/FileSourceProvider.cs | 456 +-- .../FileSourceProviderOptions.cs | 30 +- .../FileSourceProviderQueryOptions.cs | 16 +- .../FileSourceRuleOptions.cs | 102 +- .../FileSourceRulesExtensions.cs | 88 +- .../Observable/FileSystemChange.cs | 12 +- .../Observable/FileSystemChangeType.cs | 18 +- .../ObservableProvider/ObservableProvider.cs | 190 +- .../StaticJsonProvider/StaticJsonProvider.cs | 52 +- .../StaticJsonProviderOptions.cs | 24 +- .../StaticRulesExtensions.cs | 114 +- .../Reactive/Internal/DisposableHelpers.cs | 38 +- .../Reactive/Internal/ObservableExtensions.cs | 254 +- .../Reactive/Internal/ObservableHelpers.cs | 68 +- .../Internal/SimpleBehaviorSubject.cs | 190 +- .../Reactive/Internal/SimpleSubject.cs | 138 +- .../Reactive/ReactiveConfigManager.cs | 212 +- .../Reactive/ReactiveConfigurationFactory.cs | 580 ++-- .../Reactive/ReactiveTupleConfig.cs | 626 ++-- .../Rules/ChangeSubscription.cs | 196 +- .../Rules/ConfigRuleOptions.cs | 58 +- src/Cocoar.Configuration/Rules/RuleManager.cs | 644 ++--- .../Rules/RuleProviderLease.cs | 168 +- .../Rules/TransformCache.cs | 408 +-- .../Converters/Base64UrlByteArrayConverter.cs | 74 +- .../PlaintextSecretJsonConverter.cs | 90 +- .../PlaintextSecretJsonConverterFactory.cs | 70 +- .../Secrets/Converters/SecretJsonConverter.cs | 292 +- .../Converters/SecretJsonConverterFactory.cs | 80 +- .../SecretsSerializerSetupCapability.cs | 46 +- .../Core/ISecretProtectorCapability.cs | 10 +- .../Secrets/Core/ISecretsSetupContributor.cs | 18 +- .../Secrets/Core/ProtectorInterfaces.cs | 58 +- .../Secrets/Core/SecretEnvelope.cs | 200 +- .../Secrets/Core/SecretProtectorAdapters.cs | 130 +- .../Secrets/Core/SecretsDecryptorResolver.cs | 272 +- .../Secrets/Core/SecretsPolicy.cs | 44 +- .../Core/SecretsSetupDeferredConfiguration.cs | 50 +- .../Exceptions/SecretDecryptionException.cs | 232 +- .../Secrets/Helpers/CertificateHelper.cs | 418 +-- .../Protectors/Hybrid/CertificateContext.cs | 56 +- .../Protectors/Hybrid/CertificateInventory.cs | 1058 +++---- .../Hybrid/EncryptionResultSerializer.cs | 46 +- .../Protectors/Hybrid/HybridEnvelope.cs | 46 +- .../Hybrid/HybridProtectorCapabilities.cs | 134 +- .../Hybrid/HybridProtectorRegistrar.cs | 460 +-- .../Hybrid/HybridProtectorSetupContributor.cs | 38 +- .../Hybrid/SecretsHybridExtensions.cs | 274 +- .../Hybrid/X509HybridFolderSecretProtector.cs | 232 +- .../Hybrid/X509SelfSignedOptions.cs | 34 +- .../ByteArraySecretDeserializer.cs | 240 +- .../Secrets/SecretTypes/Secret.cs | 414 +-- .../Secrets/SecretsBuilderExtensions.cs | 144 +- .../Secrets/SecretsSetup.cs | 152 +- .../Testing/TestSecretSerialization.cs | 46 +- .../X509Encryption/HybridSecretEnvelope.cs | 82 +- .../X509Encryption/JsonSecretsEditor.cs | 700 ++--- .../X509CertificateGenerator.cs | 672 ++--- .../X509Encryption/X509HybridCrypto.cs | 310 +- .../Testing/CocoarTestConfiguration.cs | 732 ++--- .../Utilities/ConfigurationDeserializer.cs | 150 +- .../Utilities/InterfaceConverter.cs | 88 +- src/Cocoar.Configuration/Utilities/Safety.cs | 106 +- .../Utilities/SecureBytes.cs | 310 +- .../Utilities/StringToPrimitiveConverter.cs | 206 +- src/Directory.Build.props | 146 +- src/Directory.Packages.props | 92 +- .../AspNetCoreExample.csproj | 24 +- src/Examples/AspNetCoreExample/Program.cs | 146 +- .../Properties/launchSettings.json | 22 +- src/Examples/BasicUsage/BasicUsage.csproj | 24 +- src/Examples/BasicUsage/Program.cs | 100 +- .../BasicUsage/Properties/launchSettings.json | 22 +- .../CommandLineExample.csproj | 28 +- src/Examples/CommandLineExample/Program.cs | 428 +-- .../CommandLineExample/test-commandline.ps1 | 48 +- .../ConditionalRulesExample.csproj | 30 +- .../ConditionalRulesExample/Program.cs | 120 +- src/Examples/Directory.Build.props | 24 +- .../DynamicDependencies.csproj | 28 +- src/Examples/DynamicDependencies/Program.cs | 156 +- .../ExposeExample/ExposeExample.csproj | 40 +- src/Examples/ExposeExample/Program.cs | 302 +- src/Examples/ExposeExample/README.md | 30 +- .../ExposeExample/config/database.json | 8 +- .../ExposeExample/config/features.json | 12 +- .../ExposeExample/config/payment.json | 12 +- src/Examples/FileLayering/FileLayering.csproj | 28 +- src/Examples/FileLayering/Program.cs | 68 +- .../GenericProviderAPI.csproj | 28 +- src/Examples/GenericProviderAPI/Program.cs | 64 +- .../HttpPollingExample.csproj | 30 +- src/Examples/HttpPollingExample/Program.cs | 100 +- .../MicrosoftAdapterExample.csproj | 30 +- src/Examples/SecretsBasicExample/Program.cs | 272 +- src/Examples/SecretsBasicExample/README.md | 182 +- .../SecretsBasicExample.csproj | 40 +- .../SecretsBasicExample/appsettings.json | 20 +- .../SecretsCertificateExample/Program.cs | 322 +-- .../SecretsCertificateExample/README.md | 292 +- .../SecretsCertificateExample.csproj | 46 +- .../appsettings.encrypted.json | 70 +- .../appsettings.json | 22 +- src/Examples/SimplifiedCoreExample/Program.cs | 270 +- src/Examples/SimplifiedCoreExample/README.md | 144 +- .../SimplifiedCoreExample.csproj | 50 +- .../SimplifiedCoreExample/config/app.json | 10 +- .../config/database.json | 8 +- .../config/features.json | 10 +- src/Examples/StaticProviderExample/Program.cs | 184 +- .../StaticProviderExample.csproj | 30 +- .../TestingOverridesExample/Program.cs | 80 +- .../Properties/launchSettings.json | 22 +- .../TestingOverridesExample/README.md | 534 ++-- .../TestingOverridesExample.csproj | 72 +- .../Tests/DirectConfigManagerTests.cs | 170 +- .../Tests/FixtureBasedTests.cs | 468 +-- .../Tests/IntegrationTests.cs | 224 +- .../TestingOverridesExample/config.json | 20 +- src/Examples/TupleReactiveExample/Program.cs | 232 +- .../Properties/launchSettings.json | 22 +- .../TupleReactiveExample.csproj | 30 +- ...ocoar.Configuration.Analyzers.Tests.csproj | 58 +- ...coar.Configuration.AspNetCore.Tests.csproj | 62 +- .../Cocoar.Configuration.Core.Tests.csproj | 58 +- .../Core/ConfigManagerBuilderTests.cs | 442 +-- .../Core/ConfigManagerOrchestrationTests.cs | 192 +- .../Core/SecretsFluentApiTests.cs | 304 +- .../GlobalUsings.cs | 20 +- .../Health/LeanHealthIntegrationTests.cs | 230 +- .../Helpers/ByteTestHelpers.cs | 80 +- .../Helpers/JsonTransformStreamingTests.cs | 178 +- .../Helpers/TestRules.cs | 104 +- .../Integration/ConfigMergingDebugTest.cs | 154 +- .../InterfaceDeserializationTests.cs | 658 ++--- .../MultiProviderIsolationTests.cs | 972 +++---- .../Integration/MultiProviderMergingTests.cs | 430 +-- .../MultiProviderPerformanceTests.cs | 1430 +++++----- .../Integration/MultiProviderReactiveTests.cs | 630 ++--- .../Integration/MultiProviderTestModels.cs | 406 +-- .../ConfigManagerDeserializationTests.cs | 442 +-- .../ConfigManagerErrorHandlingTests.cs | 510 ++-- .../Managers/ConfigManagerIsolationTests.cs | 1408 ++++----- .../ConfigManagerJsonCorruptionTests.cs | 414 +-- .../ConfigManagerRuntimeErrorTests.cs | 310 +- .../ObservableProviderIsolationTests.cs | 2508 ++++++++--------- .../StaticJsonProviderIsolationTests.cs | 1190 ++++---- .../TestUtilities/ActiveWaitHelpers.cs | 614 ++-- .../TestUtilities/FailableProvider.cs | 294 +- .../TestUtilities/ObservableTestHelpers.cs | 562 ++-- .../TestUtilities/TestLogger.cs | 122 +- .../Testing/CocoarTestConfigurationTests.cs | 1524 +++++----- .../WhiteBox/AdvancedCancellationTests.cs | 412 +-- .../DifferentialCorrectnessFuzzTests.cs | 568 ++-- .../WhiteBox/InterfaceReactiveConfigTests.cs | 548 ++-- .../WhiteBox/RecomputeStressTests.cs | 786 +++--- .../WhiteBox/TupleReactiveConfigGuardTests.cs | 98 +- .../WhiteBox/TupleReactiveConfigTests.cs | 230 +- .../AutomaticRegistrationTests.cs | 454 +-- .../BuilderPatternApiTests.cs | 98 +- .../Cocoar.Configuration.DI.Tests.csproj | 52 +- .../ConfigureLifetimeAndKeyTests.cs | 60 +- .../ExposedTypeRegistrationTests.cs | 160 +- .../ReactiveConfigAbsenceTests.cs | 62 +- .../ServiceLifetimeCapabilityTests.cs | 360 +-- .../Cocoar.Configuration.Flags.Tests.csproj | 68 +- .../ReactiveIntegrationTests.cs | 554 ++-- .../RegistryTests.cs | 192 +- ...ocoar.Configuration.Providers.Tests.csproj | 56 +- .../CommandLineArgumentProviderUnitTests.cs | 788 +++--- .../CommandLineParserDebugTests.cs | 46 +- .../EnvironmentProviderUnitTests.cs | 396 +-- .../File/ConfigurablePollingIntervalTests.cs | 396 +-- .../File/FileProviderDirectoryTests.cs | 324 +-- .../File/FileProviderEdgeCaseTests.cs | 562 ++-- .../File/FileProviderMultiQueryTests.cs | 442 +-- .../File/FileProviderSecurityTests.cs | 442 +-- .../File/FileProviderStressTests.cs | 620 ++-- .../File/FileProviderUnitTests.cs | 280 +- .../File/ResilientFileSourceProviderTests.cs | 450 +-- .../Helpers/ByteTestHelpers.cs | 96 +- .../TestUtilities/ActiveWaitHelpers.cs | 70 +- .../TestUtilities/EnvScope.cs | 86 +- .../TestUtilities/TempDirectoryHelper.cs | 120 +- .../TestUtilities/TempFileHelper.cs | 228 +- .../AllowPlaintextTests.cs | 1356 ++++----- .../CertificateExpirationTests.cs | 178 +- .../CertificateFolderTests.cs | 594 ++-- .../CertificateOrderingTests.cs | 400 +-- .../Cocoar.Configuration.Secrets.Tests.csproj | 68 +- .../ComplexTypesTests.cs | 236 +- .../EdgeCasesTests.cs | 556 ++-- .../ErrorMessageTests.cs | 386 +-- .../ISecretInterfaceTests.cs | 484 ++-- .../MixedTypesTests.cs | 358 +-- .../MultiProviderTests.cs | 304 +- .../PrimitivesTests.cs | 318 +-- .../ReplaceSecretsSetupTests.cs | 404 +-- src/tests/Directory.Build.props | 46 +- website/.vitepress/config.ts | 430 +-- website/adr/ADR-001-capabilities-system.md | 430 +-- .../adr/ADR-002-atomic-reactive-updates.md | 1436 +++++----- ...-003-provider-consistency-empty-objects.md | 774 ++--- website/guide/configuration/config-aware.md | 354 +-- website/guide/getting-started.md | 282 +- website/guide/migration/v3-to-v4.md | 54 +- website/guide/roadmap.md | 6 +- website/guide/why-cocoar.md | 130 +- website/index.md | 96 +- website/roadmap/cloud-providers.md | 126 +- website/roadmap/confighub.md | 156 +- website/roadmap/database-provider.md | 138 +- website/roadmap/overview.md | 62 +- 337 files changed, 41024 insertions(+), 41005 deletions(-) create mode 100644 .gitattributes diff --git a/.editorconfig b/.editorconfig index ff14b3f..842a99f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,56 +1,56 @@ -# top-most EditorConfig file -root = true - -[*] -charset = utf-8 -end_of_line = crlf -insert_final_newline = true -indent_style = space -indent_size = 4 -trim_trailing_whitespace = true - -# C# code style -[*.cs] -dotnet_sort_system_directives_first = true -csharp_style_namespace_declarations = file_scoped:suggestion -csharp_prefer_primary_constructors = true:suggestion - -csharp_style_var_for_built_in_types = true:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = true:silent - -csharp_new_line_before_open_brace = all -csharp_new_line_between_query_expression_clauses = true - -csharp_style_expression_bodied_methods = when_possible:suggestion -csharp_style_expression_bodied_properties = when_possible:suggestion -csharp_style_expression_bodied_accessors = when_possible:suggestion - -csharp_style_prefer_switch_expression = true:suggestion -csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion -csharp_style_prefer_readonly_struct = true:suggestion -csharp_style_prefer_static_local_function = true:suggestion -csharp_prefer_simple_default_expression = true:suggestion - -# Dotnet code style -[*.{cs,vb}] -dotnet_style_qualification_for_field = false:suggestion -dotnet_style_qualification_for_property = false:suggestion -dotnet_style_qualification_for_method = false:suggestion -dotnet_style_qualification_for_event = false:suggestion - -dotnet_style_null_propagation = true:suggestion -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion - -# Naming conventions -dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields -dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case - -# symbol group -dotnet_naming_symbols.private_fields.applicable_kinds = field -dotnet_naming_symbols.private_fields.applicable_accessibilities = private - -# style -dotnet_naming_style.camel_case.capitalization = camel_case - +# top-most EditorConfig file +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +# C# code style +[*.cs] +dotnet_sort_system_directives_first = true +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_prefer_primary_constructors = true:suggestion + +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:silent + +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +csharp_style_expression_bodied_methods = when_possible:suggestion +csharp_style_expression_bodied_properties = when_possible:suggestion +csharp_style_expression_bodied_accessors = when_possible:suggestion + +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_static_local_function = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion + +# Dotnet code style +[*.{cs,vb}] +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion + +# Naming conventions +dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case + +# symbol group +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +# style +dotnet_naming_style.camel_case.capitalization = camel_case + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bf045ee --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Normalize all text files to LF in the repository (cross-platform .NET repo). +# Without this, line endings drifted (mixed CRLF/LF) because core.autocrlf was unset. +* text=auto eol=lf + +# Windows-only scripts must stay CRLF +*.cmd text eol=crlf +*.bat text eol=crlf + +# Binary files — never touch line endings +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.svg binary +*.pfx binary +*.snk binary +*.zip binary +*.gz binary diff --git a/.github/workflows/03-publish-prerelease.yml b/.github/workflows/03-publish-prerelease.yml index bb313ed..98968cb 100644 --- a/.github/workflows/03-publish-prerelease.yml +++ b/.github/workflows/03-publish-prerelease.yml @@ -1,132 +1,132 @@ -name: NuGet Prerelease - -on: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test-multiplatform: - name: Test on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - permissions: - contents: read - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} # branch selected in the UI - fetch-depth: 0 - fetch-tags: true - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '9.0.x' - - - name: Restore dependencies - run: dotnet restore - working-directory: ./src - - - name: Build solution - run: dotnet build -c Release --no-restore - working-directory: ./src - - - name: Run tests - run: dotnet test -c Release --no-build --no-restore --verbosity normal --filter "Type!=Performance" - working-directory: ./src - - - publish-prerelease: - runs-on: ubuntu-latest - needs: test-multiplatform - permissions: - contents: read - env: - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - defaults: - run: - working-directory: src - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} # same branch as tests - fetch-depth: 0 - fetch-tags: true - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '9.0.x' - - - name: Install GitVersion - uses: gittools/actions/gitversion/setup@v3 - with: - versionSpec: '6.x' - - - name: Calculate version - id: gv - uses: gittools/actions/gitversion/execute@v3 - - - name: Build prerelease version - id: prerelease - run: | - BASE="${{ steps.gv.outputs.MajorMinorPatch }}-${{ steps.gv.outputs.PreReleaseLabel }}" - N="${{ steps.gv.outputs.CommitsSinceVersionSource }}" - VERSION="$BASE.$N" - - echo "PACKAGE_VERSION=$VERSION" >> $GITHUB_ENV - echo "PACKAGE_VERSION=$VERSION" >> $GITHUB_OUTPUT - - echo "Calculated prerelease version: $VERSION" - - - name: Log calculated version - run: | - echo "GitVersion SemVer (original): ${{ steps.gv.outputs.SemVer }}" - echo "Using PACKAGE_VERSION: $PACKAGE_VERSION" - - - name: Restore dependencies - run: dotnet restore - working-directory: ./src - - - name: Build solution - run: dotnet build -c Release --no-restore -p:Version=$PACKAGE_VERSION -p:AssemblyVersion=${{ steps.gv.outputs.AssemblySemVer }} -p:FileVersion=${{ steps.gv.outputs.AssemblySemFileVer }} - working-directory: ./src - - - name: Pack NuGet packages (incl. symbols) - run: dotnet pack -c Release --no-restore /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ../artifacts -p:Version=$PACKAGE_VERSION - working-directory: ./src - - - name: Ensure packages exist - run: | - shopt -s nullglob - files=("$GITHUB_WORKSPACE"/artifacts/*.nupkg "$GITHUB_WORKSPACE"/artifacts/*.snupkg) - (( ${#files[@]} )) || { echo "::error ::No packages found in artifacts/"; exit 1; } - - - name: Upload artifacts (audit) - uses: actions/upload-artifact@v4 - with: - name: prerelease-packages-${{ env.PACKAGE_VERSION }} - path: ${{ github.workspace }}/artifacts - retention-days: 30 - - - name: Push prerelease to NuGet.org - env: - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} - run: | - shopt -s nullglob - for p in "$GITHUB_WORKSPACE"/artifacts/*.nupkg "$GITHUB_WORKSPACE"/artifacts/*.snupkg; do - echo "Pushing prerelease package: $p" - dotnet nuget push "$p" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate - done +name: NuGet Prerelease + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-multiplatform: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} # branch selected in the UI + fetch-depth: 0 + fetch-tags: true + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore dependencies + run: dotnet restore + working-directory: ./src + + - name: Build solution + run: dotnet build -c Release --no-restore + working-directory: ./src + + - name: Run tests + run: dotnet test -c Release --no-build --no-restore --verbosity normal --filter "Type!=Performance" + working-directory: ./src + + + publish-prerelease: + runs-on: ubuntu-latest + needs: test-multiplatform + permissions: + contents: read + env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + defaults: + run: + working-directory: src + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} # same branch as tests + fetch-depth: 0 + fetch-tags: true + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v3 + with: + versionSpec: '6.x' + + - name: Calculate version + id: gv + uses: gittools/actions/gitversion/execute@v3 + + - name: Build prerelease version + id: prerelease + run: | + BASE="${{ steps.gv.outputs.MajorMinorPatch }}-${{ steps.gv.outputs.PreReleaseLabel }}" + N="${{ steps.gv.outputs.CommitsSinceVersionSource }}" + VERSION="$BASE.$N" + + echo "PACKAGE_VERSION=$VERSION" >> $GITHUB_ENV + echo "PACKAGE_VERSION=$VERSION" >> $GITHUB_OUTPUT + + echo "Calculated prerelease version: $VERSION" + + - name: Log calculated version + run: | + echo "GitVersion SemVer (original): ${{ steps.gv.outputs.SemVer }}" + echo "Using PACKAGE_VERSION: $PACKAGE_VERSION" + + - name: Restore dependencies + run: dotnet restore + working-directory: ./src + + - name: Build solution + run: dotnet build -c Release --no-restore -p:Version=$PACKAGE_VERSION -p:AssemblyVersion=${{ steps.gv.outputs.AssemblySemVer }} -p:FileVersion=${{ steps.gv.outputs.AssemblySemFileVer }} + working-directory: ./src + + - name: Pack NuGet packages (incl. symbols) + run: dotnet pack -c Release --no-restore /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ../artifacts -p:Version=$PACKAGE_VERSION + working-directory: ./src + + - name: Ensure packages exist + run: | + shopt -s nullglob + files=("$GITHUB_WORKSPACE"/artifacts/*.nupkg "$GITHUB_WORKSPACE"/artifacts/*.snupkg) + (( ${#files[@]} )) || { echo "::error ::No packages found in artifacts/"; exit 1; } + + - name: Upload artifacts (audit) + uses: actions/upload-artifact@v4 + with: + name: prerelease-packages-${{ env.PACKAGE_VERSION }} + path: ${{ github.workspace }}/artifacts + retention-days: 30 + + - name: Push prerelease to NuGet.org + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + shopt -s nullglob + for p in "$GITHUB_WORKSPACE"/artifacts/*.nupkg "$GITHUB_WORKSPACE"/artifacts/*.snupkg; do + echo "Pushing prerelease package: $p" + dotnet nuget push "$p" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate + done diff --git a/.github/workflows/04-publish-stable.yml b/.github/workflows/04-publish-stable.yml index b845ac1..4d8a871 100644 --- a/.github/workflows/04-publish-stable.yml +++ b/.github/workflows/04-publish-stable.yml @@ -1,191 +1,191 @@ -name: Release - Stable - -on: - release: - types: [published] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.release.id }} - cancel-in-progress: true - -jobs: - validate-version: - name: Validate & Extract Version - if: github.event.release.target_commitish == 'develop' - runs-on: ubuntu-latest - outputs: - version: ${{ steps.version.outputs.VERSION }} - steps: - - name: Extract and validate release version - id: version - run: | - TAG="${{ github.event.release.tag_name }}" - echo "Release tag: $TAG" - - if [[ "$TAG" =~ ^v[0-9] ]]; then - VERSION="${TAG#v}" - else - echo "::error ::Tag must start with 'v' followed by version number (e.g., v1.2.3)" - exit 1 - fi - - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error ::Invalid release version format. Expected X.Y.Z (e.g., 1.2.3), got: $VERSION" - exit 1 - fi - - echo "Using release version: $VERSION" - echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - - test-multiplatform: - name: Test on ${{ matrix.os }} - needs: validate-version - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - permissions: - contents: read - - steps: - - name: Checkout tag - uses: actions/checkout@v6 - with: - ref: ${{ github.event.release.tag_name }} - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 8.0.x - 9.0.x - - - name: Restore dependencies - run: dotnet restore - working-directory: ./src - - - name: Build solution - run: dotnet build -c Release --no-restore - working-directory: ./src - - - name: Run tests - run: dotnet test -c Release --no-build --no-restore --verbosity normal --filter "Type!=Performance" - working-directory: ./src - - publish: - name: Pack & Publish to NuGet - needs: [validate-version, test-multiplatform] - runs-on: ubuntu-latest - permissions: - contents: read - env: - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - - steps: - - name: Checkout tag - uses: actions/checkout@v6 - with: - ref: ${{ github.event.release.tag_name }} - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 8.0.x - 9.0.x - - - name: Restore dependencies - run: dotnet restore - working-directory: ./src - - - name: Build solution (stable) - run: dotnet build -c Release --no-restore -p:Version=${{ needs.validate-version.outputs.version }} - working-directory: ./src - - - name: Pack NuGet packages (stable) - run: dotnet pack -c Release --no-restore -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg -o ../artifacts -p:Version=${{ needs.validate-version.outputs.version }} - working-directory: ./src - - - name: Ensure packages exist - run: | - shopt -s nullglob - files=(artifacts/*.nupkg artifacts/*.snupkg) - (( ${#files[@]} )) || { echo "::error ::No packages found in artifacts/"; exit 1; } - - - name: Upload artifacts (audit) - uses: actions/upload-artifact@v4 - with: - name: packages-${{ needs.validate-version.outputs.version }} - path: artifacts - retention-days: 7 - - - name: Push to NuGet.org - env: - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} - run: | - shopt -s nullglob - for p in artifacts/*.nupkg artifacts/*.snupkg; do - dotnet nuget push "$p" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate - done - - deploy-docs: - name: Deploy Documentation - needs: [validate-version, publish] - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout tag - uses: actions/checkout@v6 - with: - ref: ${{ github.event.release.tag_name }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Install dependencies - working-directory: website - run: npm ci - - - name: Build VitePress - working-directory: website - run: npx vitepress build - - - name: Extract docs version - id: docs-version - env: - VERSION: ${{ needs.validate-version.outputs.version }} - run: | - MAJOR=$(echo "$VERSION" | cut -d. -f1) - MINOR=$(echo "$VERSION" | cut -d. -f2) - echo "value=v${MAJOR}.${MINOR}" >> $GITHUB_OUTPUT - - - name: Package - run: cd website/.vitepress/dist && zip -r $GITHUB_WORKSPACE/docs.zip . - - - name: Upload to Shelf - env: - KEY: ${{ secrets.SHELF_API_KEY }} - URL: ${{ secrets.SHELF_BASE_URL }} - VER: ${{ steps.docs-version.outputs.value }} - run: | - echo "Deploying configuration docs $VER to $URL" - curl -f -X POST \ - -H "Authorization: Bearer $KEY" \ - -H "Content-Type: application/zip" \ - --data-binary @$GITHUB_WORKSPACE/docs.zip \ - "${URL}/_api/products/configuration/versions/${VER}" - - - name: Docs deployment summary - run: | - echo "### Documentation Deployed" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Product:** configuration" >> $GITHUB_STEP_SUMMARY - echo "**Version:** ${{ steps.docs-version.outputs.value }}" >> $GITHUB_STEP_SUMMARY - echo "**URL:** ${{ secrets.SHELF_BASE_URL }}/configuration/" >> $GITHUB_STEP_SUMMARY +name: Release - Stable + +on: + release: + types: [published] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.release.id }} + cancel-in-progress: true + +jobs: + validate-version: + name: Validate & Extract Version + if: github.event.release.target_commitish == 'develop' + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.VERSION }} + steps: + - name: Extract and validate release version + id: version + run: | + TAG="${{ github.event.release.tag_name }}" + echo "Release tag: $TAG" + + if [[ "$TAG" =~ ^v[0-9] ]]; then + VERSION="${TAG#v}" + else + echo "::error ::Tag must start with 'v' followed by version number (e.g., v1.2.3)" + exit 1 + fi + + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error ::Invalid release version format. Expected X.Y.Z (e.g., 1.2.3), got: $VERSION" + exit 1 + fi + + echo "Using release version: $VERSION" + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + test-multiplatform: + name: Test on ${{ matrix.os }} + needs: validate-version + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + permissions: + contents: read + + steps: + - name: Checkout tag + uses: actions/checkout@v6 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Restore dependencies + run: dotnet restore + working-directory: ./src + + - name: Build solution + run: dotnet build -c Release --no-restore + working-directory: ./src + + - name: Run tests + run: dotnet test -c Release --no-build --no-restore --verbosity normal --filter "Type!=Performance" + working-directory: ./src + + publish: + name: Pack & Publish to NuGet + needs: [validate-version, test-multiplatform] + runs-on: ubuntu-latest + permissions: + contents: read + env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + + steps: + - name: Checkout tag + uses: actions/checkout@v6 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Restore dependencies + run: dotnet restore + working-directory: ./src + + - name: Build solution (stable) + run: dotnet build -c Release --no-restore -p:Version=${{ needs.validate-version.outputs.version }} + working-directory: ./src + + - name: Pack NuGet packages (stable) + run: dotnet pack -c Release --no-restore -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg -o ../artifacts -p:Version=${{ needs.validate-version.outputs.version }} + working-directory: ./src + + - name: Ensure packages exist + run: | + shopt -s nullglob + files=(artifacts/*.nupkg artifacts/*.snupkg) + (( ${#files[@]} )) || { echo "::error ::No packages found in artifacts/"; exit 1; } + + - name: Upload artifacts (audit) + uses: actions/upload-artifact@v4 + with: + name: packages-${{ needs.validate-version.outputs.version }} + path: artifacts + retention-days: 7 + + - name: Push to NuGet.org + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + shopt -s nullglob + for p in artifacts/*.nupkg artifacts/*.snupkg; do + dotnet nuget push "$p" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate + done + + deploy-docs: + name: Deploy Documentation + needs: [validate-version, publish] + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout tag + uses: actions/checkout@v6 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + working-directory: website + run: npm ci + + - name: Build VitePress + working-directory: website + run: npx vitepress build + + - name: Extract docs version + id: docs-version + env: + VERSION: ${{ needs.validate-version.outputs.version }} + run: | + MAJOR=$(echo "$VERSION" | cut -d. -f1) + MINOR=$(echo "$VERSION" | cut -d. -f2) + echo "value=v${MAJOR}.${MINOR}" >> $GITHUB_OUTPUT + + - name: Package + run: cd website/.vitepress/dist && zip -r $GITHUB_WORKSPACE/docs.zip . + + - name: Upload to Shelf + env: + KEY: ${{ secrets.SHELF_API_KEY }} + URL: ${{ secrets.SHELF_BASE_URL }} + VER: ${{ steps.docs-version.outputs.value }} + run: | + echo "Deploying configuration docs $VER to $URL" + curl -f -X POST \ + -H "Authorization: Bearer $KEY" \ + -H "Content-Type: application/zip" \ + --data-binary @$GITHUB_WORKSPACE/docs.zip \ + "${URL}/_api/products/configuration/versions/${VER}" + + - name: Docs deployment summary + run: | + echo "### Documentation Deployed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Product:** configuration" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ steps.docs-version.outputs.value }}" >> $GITHUB_STEP_SUMMARY + echo "**URL:** ${{ secrets.SHELF_BASE_URL }}/configuration/" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/05-deploy-docs.yml b/.github/workflows/05-deploy-docs.yml index 17c80ac..1717a67 100644 --- a/.github/workflows/05-deploy-docs.yml +++ b/.github/workflows/05-deploy-docs.yml @@ -1,60 +1,60 @@ -name: Deploy Docs - -on: - workflow_dispatch: - inputs: - version: - description: 'Version (e.g. v5.0, v5.1)' - required: true - -concurrency: - group: deploy-docs - cancel-in-progress: false - -jobs: - deploy: - name: Build & Deploy Docs - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Install dependencies - working-directory: website - run: npm ci - - - name: Build VitePress - working-directory: website - run: npx vitepress build - - - name: Package - run: cd website/.vitepress/dist && zip -r $GITHUB_WORKSPACE/docs.zip . - - - name: Upload to Shelf - env: - KEY: ${{ secrets.SHELF_API_KEY }} - URL: ${{ secrets.SHELF_BASE_URL }} - VER: ${{ inputs.version }} - run: | - echo "Deploying configuration docs $VER to $URL" - curl -f -X POST \ - -H "Authorization: Bearer $KEY" \ - -H "Content-Type: application/zip" \ - --data-binary @$GITHUB_WORKSPACE/docs.zip \ - "${URL}/_api/products/configuration/versions/${VER}" - - - name: Deployment summary - run: | - echo "### Docs Deployment Complete" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Product:** configuration" >> $GITHUB_STEP_SUMMARY - echo "**Version:** ${{ inputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "**URL:** ${{ secrets.SHELF_BASE_URL }}/configuration/" >> $GITHUB_STEP_SUMMARY +name: Deploy Docs + +on: + workflow_dispatch: + inputs: + version: + description: 'Version (e.g. v5.0, v5.1)' + required: true + +concurrency: + group: deploy-docs + cancel-in-progress: false + +jobs: + deploy: + name: Build & Deploy Docs + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + working-directory: website + run: npm ci + + - name: Build VitePress + working-directory: website + run: npx vitepress build + + - name: Package + run: cd website/.vitepress/dist && zip -r $GITHUB_WORKSPACE/docs.zip . + + - name: Upload to Shelf + env: + KEY: ${{ secrets.SHELF_API_KEY }} + URL: ${{ secrets.SHELF_BASE_URL }} + VER: ${{ inputs.version }} + run: | + echo "Deploying configuration docs $VER to $URL" + curl -f -X POST \ + -H "Authorization: Bearer $KEY" \ + -H "Content-Type: application/zip" \ + --data-binary @$GITHUB_WORKSPACE/docs.zip \ + "${URL}/_api/products/configuration/versions/${VER}" + + - name: Deployment summary + run: | + echo "### Docs Deployment Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Product:** configuration" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ inputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**URL:** ${{ secrets.SHELF_BASE_URL }}/configuration/" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index b398985..764647e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,394 +1,394 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Oo]ut/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -#Rider -.idea - -dist/ - -.mono/ -**/.mono/ - -/output -.DS_Store - -local/ -.local/ - -nul - -# VitePress -website/.vitepress/cache/ -website/.vitepress/dist/ -website/dist/ - -# Build artifacts -artifacts/ -**/nupkgs/ -**/temp-packages/ -testlog.* -build.log - -# Claude Code local settings -.claude/settings.local.json +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +#Rider +.idea + +dist/ + +.mono/ +**/.mono/ + +/output +.DS_Store + +local/ +.local/ + +nul + +# VitePress +website/.vitepress/cache/ +website/.vitepress/dist/ +website/dist/ + +# Build artifacts +artifacts/ +**/nupkgs/ +**/temp-packages/ +testlog.* +build.log + +# Claude Code local settings +.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md index 8bfa147..9e91d22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,421 +1,421 @@ -# AGENTS.md — AI Assistant Guidance for .NET Repositories - -> **System prompt for GitHub Copilot and other AI assistants:** -> Act as a context-aware assistant for this repository. -> Your responsibilities: ensure high-quality C#/.NET code, maintain consistency, and prepare the repository for release. -> Always prioritize clarity, intent, and maintainability over verbosity. -> Follow the principles below and extend them as this repository evolves. - ---- - -## 🎯 Purpose - -This file defines how AI assistants (e.g., GitHub Copilot) work with this repository. -It establishes the philosophy, quality standards, and release-readiness criteria that define a polished .NET project. - ---- - -## 🧭 Core Principles - -* **Explain Why, Not What** — Comments describe *intent and reasoning*, not code behavior. -* **Consistency Over Novelty** — Prefer predictable patterns that align with the repository's style. -* **Simplicity and Intent** — Code should be self-explanatory and purposeful. -* **Incremental Improvement** — Every change should leave the codebase cleaner and more consistent. -* **Automation Friendly** — Prefer patterns that integrate easily with CI/CD and documentation tooling. - ---- - -## 💬 Comment Policy: Why, Not What - -Code comments should explain *why* decisions were made, not *what* the code does. Self-documenting code (clear naming, structure) is always preferable to comments. - -### ✅ Keep These Comments - -* **Rationale for non-obvious choices** — Why a specific algorithm, library, or approach was chosen -* **Performance trade-offs** — Justification for optimizations or deliberate inefficiencies -* **Security decisions** — Why certain patterns are used for security-sensitive operations -* **Business logic context** — Domain knowledge that isn't obvious from code alone -* **Workarounds** — Why unusual patterns exist to handle external limitations -* **Future considerations** — TODOs with clear context (what, why, when) - -**Examples:** -```csharp -// Using SHA256 instead of MD5 because we're hashing sensitive data and MD5 is cryptographically broken -var hasher = SHA256.Create(); - -// ConfigureAwait(false) to avoid capturing SynchronizationContext - this is a library, not an app -await LoadConfigAsync().ConfigureAwait(false); - -// Caching for 5 minutes because the upstream API rate-limits us to 100 requests/hour -_cache.Set(key, value, TimeSpan.FromMinutes(5)); -``` - -### ❌ Remove These Comments - -* **Restating the code** — If the code is clear, don't repeat it in prose -* **Obvious descriptions** — Explaining what a well-named method does -* **Commented-out code** — Use version control, don't leave dead code -* **Debug/temporary comments** — "test", "TODO: fix this", etc. without context -* **Obsolete explanations** — Comments that no longer match the code -* **Auto-generated noise** — Boilerplate like "Constructor for X" - -**Examples to remove:** -```csharp -// Create a hasher -var hasher = SHA256.Create(); - -// Loop through items -foreach (var item in items) { ... } - -// This method gets the configuration -public IConfiguration GetConfiguration() { ... } - -// TODO: fix -// var x = 5; -``` - -### 🔄 Prefer Refactoring Over Comments - -When you're tempted to add a comment explaining complex code: -1. **Extract method** — Pull complexity into a well-named method -2. **Rename variables** — Make intent clear through naming -3. **Simplify logic** — Reduce cognitive load -4. Only add a comment if the *why* still isn't obvious - ---- - -## 🧠 Context & Awareness - -When assisting, AI assistants should consider: - -* **Repository Stage** — Early prototype, maturing library, or release-ready. -* **User Intent** — Exploration, implementation, debugging, or release prep. -* **Scope of Change** — Suggest proportionate solutions; avoid overengineering. -* **Existing Patterns** — Align with established conventions before suggesting new ones. - ---- - -## 🧱 Code Quality - -* Clear, intentional naming and structure. -* Only meaningful comments remain (focus on *why* decisions were made). -* No unused variables, dead code, or debug leftovers. -* Logging is clean, consistent, and safe — no sensitive data. -* Public APIs are coherent and documented. -* Breaking changes are intentional and clearly communicated. - ---- - -## ⚠️ Error Handling & Diagnostics - -* Exceptions must be meaningful and actionable. -* Use specific exception types instead of generic ones. -* Fail early with clear validation messages. -* Logs and errors should guide users toward resolution. - ---- - -## ⚡ Performance & Efficiency - -* Avoid premature optimization, but eliminate obvious inefficiencies. -* Justify allocations in hot paths and document trade-offs. -* Use async patterns correctly (`Task`, `ValueTask`, no `async void`). -* Record major performance-related choices in ADRs or docs. - ---- - -## 🎨 API Design (.NET) - -* Follow .NET naming conventions (PascalCase, Async suffix, etc.). -* Cancellation tokens as the last parameter. -* Prefer interfaces/abstractions for inputs, concrete types for outputs. -* Avoid `out` parameters; prefer tuples or return objects. -* Enable nullable reference types and ensure annotations are correct. - -### ✅ Good API Design - -```csharp -// Async suffix, CancellationToken last, nullable annotations -public async Task LoadConfigAsync(string path, CancellationToken cancellationToken = default) -{ - // ... -} - -// Interface input, concrete output, no out params -public ValidationResult Validate(IConfiguration config) -{ - return new ValidationResult(IsValid: true, Errors: Array.Empty()); -} -``` - -### ❌ Poor API Design - -```csharp -// Missing Async suffix, CancellationToken not last -public async Task LoadConfig(CancellationToken cancellationToken, string path) -{ - // ... -} - -// Concrete input, out parameter instead of return value -public bool TryValidate(Configuration config, out List errors) -{ - // ... -} -``` - ---- - -## 🧾 Documentation - -* **README.md** provides concise overview and links deeper docs if needed. -* **CHANGELOG.md** lists meaningful changes since the last tag. -* **/docs/** contains advanced usage, architecture, and ADRs. -* All docs accurately reflect the current codebase. - ---- - -## 📚 Dependencies - -* Minimize external dependencies; every one adds risk. -* Prefer stable, widely used libraries over experimental ones. -* Document the reason for major dependencies. -* Keep dependencies updated but test thoroughly before upgrading. - ---- - -## 📂 Local-only Working Files (`.local/`) - -A repository-scoped **`.local/`** folder may exist and is **git-ignored**. - -### ✅ Appropriate Uses - -* Release preparation checklists and scratch notes -* Generated diff analyses or API inventories -* Draft ADRs or design explorations not yet ready for review -* Meeting notes or discussion artifacts -* Personal TODO lists or investigation notes -* Temporary test data or sample files - -### ❌ Inappropriate Uses - -* **Secrets or credentials** — Use OS keychain/secret manager instead -* **Build artifacts** — Use `bin/`, `obj/`, or dedicated build output directories -* **Shared documentation** — Belongs in `/docs/` under version control -* **Configuration templates** — Belongs in repo with `.example` suffix -* **Production data** — Never store real user data, even temporarily - -### 🔒 Rules - -* **Not authoritative**: Never reference `.local/` from README, docs, code, or CI. Do not assume it exists. -* **No release artifacts**: Nothing from `.local/` should ship in packages, images, or releases. -* **Security**: Avoid storing secrets in plaintext even here; prefer local secret stores/encrypted files. Never promote `.local/` content into the repo without review. -* **AI assistant behavior**: Treat `.local/` as ephemeral context only. Do not inline, quote, or depend on it when generating public docs or code comments. - ---- - -## 🧪 Testing & Behavior - -* Tests represent real-world behavior and critical paths. -* No broken, outdated, or redundant tests. -* Regression tests exist for fixed bugs. -* Test coverage trends should not regress significantly. - ---- - -## 🔒 Security & Safety - -* Never commit secrets or credentials. -* Sensitive data must not appear in logs or dumps. -* Dependencies checked for known vulnerabilities. -* External licenses compatible with this project’s license. -* Secrets handled securely and cleared when possible. - ---- - -## 📦 Release Readiness - -A release is **ready** when the following are true. These criteria are explicit so AI assistants base results on **real code changes** (not just the last commit message): - -### A) Compare to the last tag (**actual code**, not commit subjects) - -- [ ] Determine latest tag via `git describe --tags --abbrev=0` -- [ ] Analyze **code diff** from tag to HEAD (not just commit messages) -- [ ] Summarize meaningful changes in behavior, API, performance -- [ ] Identify and document breaking changes with impact assessment - -### B) Changelog & Versioning (SemVer) - -- [ ] Update **CHANGELOG.md** with entries **derived from actual code changes** since the previous tag (avoid relying on commit messages alone) -- [ ] Apply **Semantic Versioning** consistently: - * **MAJOR** – breaking changes - * **MINOR** – new features, backwards compatible - * **PATCH** – fixes and small safe improvements -- [ ] Ensure the project/package version matches the intended release number - -### C) Documentation in Sync - -- [ ] **README.md** reflects current features, quickstart, and key examples - * If README grows too large, recommend restructuring: keep pitch/install/quickstart in README; move advanced topics to **/docs/** -- [ ] Update or add docs for: - * New/changed **public APIs** - * **Configuration / environment variables** - * **Migration notes** for breaking changes -- [ ] Ensure example code and snippets correspond to the current API - -### D) Repository Hygiene - -- [ ] Presence & freshness of: - * `LICENSE`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` - * `.github/` issue/PR templates and any workflow docs - * Badges/links are correct and not broken -- [ ] Package metadata is accurate (description, license, repository URL, tags) - -### E) Code Quality (static + structural) - -- [ ] Remove unused code: variables, parameters, imports, dead files -- [ ] Public API is coherent and discoverable; obsolete members are marked and documented with migration guidance -- [ ] Logging: no secrets; appropriate log levels; no stray debug prints in release paths -- [ ] Error handling provides useful context and fails early for invalid inputs -- [ ] Hot paths avoid unnecessary allocations/copies; performance trade-offs are documented where relevant -- [ ] Comments adhere to the **why-not-what** policy - -### F) CI/CD & Packaging - -- [ ] All checks pass (build, analyzers/formatting, tests, security scans) -- [ ] Packaging configuration is correct (symbols/SourceLink if applicable) and produces reproducible artifacts -- [ ] Release process targets the intended platforms (including Windows ARM64 when applicable) - ---- - -## 💬 AI Assistant Behavior - -* Respect the **why-not-what** rule for comments. - -* Prefer clearer naming/structure over extra docs. - -* **When asked "Is this ready for release?"**: systematically verify all Release Readiness items **based on the diff to the last tag**, not on the latest commit or the current Unreleased notes. - -* When suggesting refactoring, explain benefits, trade-offs, and potential risks. - -* When generating tests, focus on meaningful behavior and edge cases over raw coverage. - -* When reviewing PRs, highlight deviations from these principles and explain *why* they matter. - ---- - -### 🔍 Release Verification Deep-Dive Methodology - -When verifying release readiness, **systematically analyze the diff** rather than relying on memory or commit messages: - -#### 1. Enumerate ALL new/changed public APIs - -```powershell -# Find all public API changes since last tag -$lastTag = git describe --tags --abbrev=0 -git diff $lastTag..HEAD -- '*.cs' | Select-String '^\+.*public' -``` - -- [ ] List each method/class/property explicitly -- [ ] Don't summarize or group—enumerate individually -- [ ] Note parameter types, return types, and access modifiers - -#### 2. Cross-reference API names in documentation - -- [ ] For each API in code, search docs for exact name match -- [ ] Flag discrepancies (e.g., `UseCertificatesFromFolder` in code vs `UseCertificateFromFolder` in docs) -- [ ] Verify parameter counts and types match examples -- [ ] Check that all parameters are documented - -#### 3. Analyze each API for design consistency - -- [ ] Async methods end with `Async` suffix -- [ ] CancellationToken is last parameter (if present) -- [ ] Nullable reference type annotations are accurate -- [ ] Naming follows .NET conventions (PascalCase, descriptive) -- [ ] XML documentation comments are present and accurate -- [ ] Return types appropriate (interface for input, concrete for output) - -#### 4. Assess breaking change impact precisely - -Don't assume—analyze each change systematically: - -- [ ] **Identify** exact type/member that changed -- [ ] **Determine** who is affected: - - All users (public API change) - - Advanced scenarios (extensibility point) - - Internal only (implementation detail) -- [ ] **Estimate** surface area: - - **High**: Core API used in quickstart/common scenarios - - **Medium**: Feature-specific API used in advanced scenarios - - **Low**: Edge case or rarely-used functionality -- [ ] **Document** migration path for each breaking change in docs - -#### 5. Validate all documentation examples - -- [ ] Extract code snippets from README.md and /docs/ -- [ ] Verify each snippet would compile against current code -- [ ] Check namespaces are correct and imported -- [ ] Verify method signatures match (names, parameter order, types) -- [ ] Confirm return types and patterns are accurate - -#### 6. Confirm test coverage for new functionality - -- [ ] Each new public API has corresponding tests -- [ ] Tests cover happy path scenarios -- [ ] Tests cover edge cases and boundary conditions -- [ ] Tests cover error conditions and exceptions -- [ ] Test names clearly describe what they verify - ---- - -## ⚠️ Common Pitfalls to Avoid - -* **Documentation lag** — Examples reference old API signatures or removed features -* **Inconsistent naming** — Plural vs singular (`UseCertificate` vs `UseCertificates`), inconsistent prefixes -* **Async suffix inconsistency** — Some async methods missing `Async` suffix -* **Breaking changes unmarked** — Changed signatures without major version bump or migration guide -* **Orphaned tests** — Tests for removed features still in test suite -* **Dead configuration** — Unused config keys or environment variables still documented -* **Stale badges** — Build status or version badges pointing to wrong branch/package -* **Hardcoded examples** — Code snippets with specific versions, paths, or outdated imports - ---- - -## 🤔 When in Doubt - -If uncertain about a decision, AI assistants should: - -1. **Favor existing patterns** — Check how similar problems are solved elsewhere in the codebase before introducing new approaches -2. **Prefer standard library** — Use BCL types/patterns over custom implementations unless there's a documented reason -3. **Ask, don't assume** — Flag ambiguity and present options rather than guessing user intent -4. **Suggest, don't dictate** — Present alternatives with clear trade-offs (performance, maintainability, complexity) -5. **Cite this document** — Reference specific AGENTS.md sections when explaining recommendations - ---- - -## 🌿 Version Control - -* Commit messages describe *why*, not just *what*. -* Feature branches stay focused and short-lived. -* No force-pushes to main. -* Tags follow SemVer (`v1.2.3`). -* Breaking changes require major version and migration notes. - ---- - -## ✅ Definition of Done - -A change or release is complete when: - -* Code and docs align with these principles. -* Changelog and version accurately describe reality. -* No unused or redundant elements remain. -* Intent is clear through naming, structure, or concise *why* comments. - ---- - -**Version:** 1.0.0 - -> This file defines the authoritative AI guidance for this repository. -> Copilot, Claude, ChatGPT, and other assistants should treat this as the primary behavioral contract. +# AGENTS.md — AI Assistant Guidance for .NET Repositories + +> **System prompt for GitHub Copilot and other AI assistants:** +> Act as a context-aware assistant for this repository. +> Your responsibilities: ensure high-quality C#/.NET code, maintain consistency, and prepare the repository for release. +> Always prioritize clarity, intent, and maintainability over verbosity. +> Follow the principles below and extend them as this repository evolves. + +--- + +## 🎯 Purpose + +This file defines how AI assistants (e.g., GitHub Copilot) work with this repository. +It establishes the philosophy, quality standards, and release-readiness criteria that define a polished .NET project. + +--- + +## 🧭 Core Principles + +* **Explain Why, Not What** — Comments describe *intent and reasoning*, not code behavior. +* **Consistency Over Novelty** — Prefer predictable patterns that align with the repository's style. +* **Simplicity and Intent** — Code should be self-explanatory and purposeful. +* **Incremental Improvement** — Every change should leave the codebase cleaner and more consistent. +* **Automation Friendly** — Prefer patterns that integrate easily with CI/CD and documentation tooling. + +--- + +## 💬 Comment Policy: Why, Not What + +Code comments should explain *why* decisions were made, not *what* the code does. Self-documenting code (clear naming, structure) is always preferable to comments. + +### ✅ Keep These Comments + +* **Rationale for non-obvious choices** — Why a specific algorithm, library, or approach was chosen +* **Performance trade-offs** — Justification for optimizations or deliberate inefficiencies +* **Security decisions** — Why certain patterns are used for security-sensitive operations +* **Business logic context** — Domain knowledge that isn't obvious from code alone +* **Workarounds** — Why unusual patterns exist to handle external limitations +* **Future considerations** — TODOs with clear context (what, why, when) + +**Examples:** +```csharp +// Using SHA256 instead of MD5 because we're hashing sensitive data and MD5 is cryptographically broken +var hasher = SHA256.Create(); + +// ConfigureAwait(false) to avoid capturing SynchronizationContext - this is a library, not an app +await LoadConfigAsync().ConfigureAwait(false); + +// Caching for 5 minutes because the upstream API rate-limits us to 100 requests/hour +_cache.Set(key, value, TimeSpan.FromMinutes(5)); +``` + +### ❌ Remove These Comments + +* **Restating the code** — If the code is clear, don't repeat it in prose +* **Obvious descriptions** — Explaining what a well-named method does +* **Commented-out code** — Use version control, don't leave dead code +* **Debug/temporary comments** — "test", "TODO: fix this", etc. without context +* **Obsolete explanations** — Comments that no longer match the code +* **Auto-generated noise** — Boilerplate like "Constructor for X" + +**Examples to remove:** +```csharp +// Create a hasher +var hasher = SHA256.Create(); + +// Loop through items +foreach (var item in items) { ... } + +// This method gets the configuration +public IConfiguration GetConfiguration() { ... } + +// TODO: fix +// var x = 5; +``` + +### 🔄 Prefer Refactoring Over Comments + +When you're tempted to add a comment explaining complex code: +1. **Extract method** — Pull complexity into a well-named method +2. **Rename variables** — Make intent clear through naming +3. **Simplify logic** — Reduce cognitive load +4. Only add a comment if the *why* still isn't obvious + +--- + +## 🧠 Context & Awareness + +When assisting, AI assistants should consider: + +* **Repository Stage** — Early prototype, maturing library, or release-ready. +* **User Intent** — Exploration, implementation, debugging, or release prep. +* **Scope of Change** — Suggest proportionate solutions; avoid overengineering. +* **Existing Patterns** — Align with established conventions before suggesting new ones. + +--- + +## 🧱 Code Quality + +* Clear, intentional naming and structure. +* Only meaningful comments remain (focus on *why* decisions were made). +* No unused variables, dead code, or debug leftovers. +* Logging is clean, consistent, and safe — no sensitive data. +* Public APIs are coherent and documented. +* Breaking changes are intentional and clearly communicated. + +--- + +## ⚠️ Error Handling & Diagnostics + +* Exceptions must be meaningful and actionable. +* Use specific exception types instead of generic ones. +* Fail early with clear validation messages. +* Logs and errors should guide users toward resolution. + +--- + +## ⚡ Performance & Efficiency + +* Avoid premature optimization, but eliminate obvious inefficiencies. +* Justify allocations in hot paths and document trade-offs. +* Use async patterns correctly (`Task`, `ValueTask`, no `async void`). +* Record major performance-related choices in ADRs or docs. + +--- + +## 🎨 API Design (.NET) + +* Follow .NET naming conventions (PascalCase, Async suffix, etc.). +* Cancellation tokens as the last parameter. +* Prefer interfaces/abstractions for inputs, concrete types for outputs. +* Avoid `out` parameters; prefer tuples or return objects. +* Enable nullable reference types and ensure annotations are correct. + +### ✅ Good API Design + +```csharp +// Async suffix, CancellationToken last, nullable annotations +public async Task LoadConfigAsync(string path, CancellationToken cancellationToken = default) +{ + // ... +} + +// Interface input, concrete output, no out params +public ValidationResult Validate(IConfiguration config) +{ + return new ValidationResult(IsValid: true, Errors: Array.Empty()); +} +``` + +### ❌ Poor API Design + +```csharp +// Missing Async suffix, CancellationToken not last +public async Task LoadConfig(CancellationToken cancellationToken, string path) +{ + // ... +} + +// Concrete input, out parameter instead of return value +public bool TryValidate(Configuration config, out List errors) +{ + // ... +} +``` + +--- + +## 🧾 Documentation + +* **README.md** provides concise overview and links deeper docs if needed. +* **CHANGELOG.md** lists meaningful changes since the last tag. +* **/docs/** contains advanced usage, architecture, and ADRs. +* All docs accurately reflect the current codebase. + +--- + +## 📚 Dependencies + +* Minimize external dependencies; every one adds risk. +* Prefer stable, widely used libraries over experimental ones. +* Document the reason for major dependencies. +* Keep dependencies updated but test thoroughly before upgrading. + +--- + +## 📂 Local-only Working Files (`.local/`) + +A repository-scoped **`.local/`** folder may exist and is **git-ignored**. + +### ✅ Appropriate Uses + +* Release preparation checklists and scratch notes +* Generated diff analyses or API inventories +* Draft ADRs or design explorations not yet ready for review +* Meeting notes or discussion artifacts +* Personal TODO lists or investigation notes +* Temporary test data or sample files + +### ❌ Inappropriate Uses + +* **Secrets or credentials** — Use OS keychain/secret manager instead +* **Build artifacts** — Use `bin/`, `obj/`, or dedicated build output directories +* **Shared documentation** — Belongs in `/docs/` under version control +* **Configuration templates** — Belongs in repo with `.example` suffix +* **Production data** — Never store real user data, even temporarily + +### 🔒 Rules + +* **Not authoritative**: Never reference `.local/` from README, docs, code, or CI. Do not assume it exists. +* **No release artifacts**: Nothing from `.local/` should ship in packages, images, or releases. +* **Security**: Avoid storing secrets in plaintext even here; prefer local secret stores/encrypted files. Never promote `.local/` content into the repo without review. +* **AI assistant behavior**: Treat `.local/` as ephemeral context only. Do not inline, quote, or depend on it when generating public docs or code comments. + +--- + +## 🧪 Testing & Behavior + +* Tests represent real-world behavior and critical paths. +* No broken, outdated, or redundant tests. +* Regression tests exist for fixed bugs. +* Test coverage trends should not regress significantly. + +--- + +## 🔒 Security & Safety + +* Never commit secrets or credentials. +* Sensitive data must not appear in logs or dumps. +* Dependencies checked for known vulnerabilities. +* External licenses compatible with this project’s license. +* Secrets handled securely and cleared when possible. + +--- + +## 📦 Release Readiness + +A release is **ready** when the following are true. These criteria are explicit so AI assistants base results on **real code changes** (not just the last commit message): + +### A) Compare to the last tag (**actual code**, not commit subjects) + +- [ ] Determine latest tag via `git describe --tags --abbrev=0` +- [ ] Analyze **code diff** from tag to HEAD (not just commit messages) +- [ ] Summarize meaningful changes in behavior, API, performance +- [ ] Identify and document breaking changes with impact assessment + +### B) Changelog & Versioning (SemVer) + +- [ ] Update **CHANGELOG.md** with entries **derived from actual code changes** since the previous tag (avoid relying on commit messages alone) +- [ ] Apply **Semantic Versioning** consistently: + * **MAJOR** – breaking changes + * **MINOR** – new features, backwards compatible + * **PATCH** – fixes and small safe improvements +- [ ] Ensure the project/package version matches the intended release number + +### C) Documentation in Sync + +- [ ] **README.md** reflects current features, quickstart, and key examples + * If README grows too large, recommend restructuring: keep pitch/install/quickstart in README; move advanced topics to **/docs/** +- [ ] Update or add docs for: + * New/changed **public APIs** + * **Configuration / environment variables** + * **Migration notes** for breaking changes +- [ ] Ensure example code and snippets correspond to the current API + +### D) Repository Hygiene + +- [ ] Presence & freshness of: + * `LICENSE`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` + * `.github/` issue/PR templates and any workflow docs + * Badges/links are correct and not broken +- [ ] Package metadata is accurate (description, license, repository URL, tags) + +### E) Code Quality (static + structural) + +- [ ] Remove unused code: variables, parameters, imports, dead files +- [ ] Public API is coherent and discoverable; obsolete members are marked and documented with migration guidance +- [ ] Logging: no secrets; appropriate log levels; no stray debug prints in release paths +- [ ] Error handling provides useful context and fails early for invalid inputs +- [ ] Hot paths avoid unnecessary allocations/copies; performance trade-offs are documented where relevant +- [ ] Comments adhere to the **why-not-what** policy + +### F) CI/CD & Packaging + +- [ ] All checks pass (build, analyzers/formatting, tests, security scans) +- [ ] Packaging configuration is correct (symbols/SourceLink if applicable) and produces reproducible artifacts +- [ ] Release process targets the intended platforms (including Windows ARM64 when applicable) + +--- + +## 💬 AI Assistant Behavior + +* Respect the **why-not-what** rule for comments. + +* Prefer clearer naming/structure over extra docs. + +* **When asked "Is this ready for release?"**: systematically verify all Release Readiness items **based on the diff to the last tag**, not on the latest commit or the current Unreleased notes. + +* When suggesting refactoring, explain benefits, trade-offs, and potential risks. + +* When generating tests, focus on meaningful behavior and edge cases over raw coverage. + +* When reviewing PRs, highlight deviations from these principles and explain *why* they matter. + +--- + +### 🔍 Release Verification Deep-Dive Methodology + +When verifying release readiness, **systematically analyze the diff** rather than relying on memory or commit messages: + +#### 1. Enumerate ALL new/changed public APIs + +```powershell +# Find all public API changes since last tag +$lastTag = git describe --tags --abbrev=0 +git diff $lastTag..HEAD -- '*.cs' | Select-String '^\+.*public' +``` + +- [ ] List each method/class/property explicitly +- [ ] Don't summarize or group—enumerate individually +- [ ] Note parameter types, return types, and access modifiers + +#### 2. Cross-reference API names in documentation + +- [ ] For each API in code, search docs for exact name match +- [ ] Flag discrepancies (e.g., `UseCertificatesFromFolder` in code vs `UseCertificateFromFolder` in docs) +- [ ] Verify parameter counts and types match examples +- [ ] Check that all parameters are documented + +#### 3. Analyze each API for design consistency + +- [ ] Async methods end with `Async` suffix +- [ ] CancellationToken is last parameter (if present) +- [ ] Nullable reference type annotations are accurate +- [ ] Naming follows .NET conventions (PascalCase, descriptive) +- [ ] XML documentation comments are present and accurate +- [ ] Return types appropriate (interface for input, concrete for output) + +#### 4. Assess breaking change impact precisely + +Don't assume—analyze each change systematically: + +- [ ] **Identify** exact type/member that changed +- [ ] **Determine** who is affected: + - All users (public API change) + - Advanced scenarios (extensibility point) + - Internal only (implementation detail) +- [ ] **Estimate** surface area: + - **High**: Core API used in quickstart/common scenarios + - **Medium**: Feature-specific API used in advanced scenarios + - **Low**: Edge case or rarely-used functionality +- [ ] **Document** migration path for each breaking change in docs + +#### 5. Validate all documentation examples + +- [ ] Extract code snippets from README.md and /docs/ +- [ ] Verify each snippet would compile against current code +- [ ] Check namespaces are correct and imported +- [ ] Verify method signatures match (names, parameter order, types) +- [ ] Confirm return types and patterns are accurate + +#### 6. Confirm test coverage for new functionality + +- [ ] Each new public API has corresponding tests +- [ ] Tests cover happy path scenarios +- [ ] Tests cover edge cases and boundary conditions +- [ ] Tests cover error conditions and exceptions +- [ ] Test names clearly describe what they verify + +--- + +## ⚠️ Common Pitfalls to Avoid + +* **Documentation lag** — Examples reference old API signatures or removed features +* **Inconsistent naming** — Plural vs singular (`UseCertificate` vs `UseCertificates`), inconsistent prefixes +* **Async suffix inconsistency** — Some async methods missing `Async` suffix +* **Breaking changes unmarked** — Changed signatures without major version bump or migration guide +* **Orphaned tests** — Tests for removed features still in test suite +* **Dead configuration** — Unused config keys or environment variables still documented +* **Stale badges** — Build status or version badges pointing to wrong branch/package +* **Hardcoded examples** — Code snippets with specific versions, paths, or outdated imports + +--- + +## 🤔 When in Doubt + +If uncertain about a decision, AI assistants should: + +1. **Favor existing patterns** — Check how similar problems are solved elsewhere in the codebase before introducing new approaches +2. **Prefer standard library** — Use BCL types/patterns over custom implementations unless there's a documented reason +3. **Ask, don't assume** — Flag ambiguity and present options rather than guessing user intent +4. **Suggest, don't dictate** — Present alternatives with clear trade-offs (performance, maintainability, complexity) +5. **Cite this document** — Reference specific AGENTS.md sections when explaining recommendations + +--- + +## 🌿 Version Control + +* Commit messages describe *why*, not just *what*. +* Feature branches stay focused and short-lived. +* No force-pushes to main. +* Tags follow SemVer (`v1.2.3`). +* Breaking changes require major version and migration notes. + +--- + +## ✅ Definition of Done + +A change or release is complete when: + +* Code and docs align with these principles. +* Changelog and version accurately describe reality. +* No unused or redundant elements remain. +* Intent is clear through naming, structure, or concise *why* comments. + +--- + +**Version:** 1.0.0 + +> This file defines the authoritative AI guidance for this repository. +> Copilot, Claude, ChatGPT, and other assistants should treat this as the primary behavioral contract. diff --git a/CHANGELOG.md b/CHANGELOG.md index e3bf68a..d38d7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,630 +1,630 @@ -# Changelog - -## [5.1.0] - 2026-05-31 - -### Added - -- **WritableStore provider** — a writable, application-controlled override layer for *overridable defaults*: the normal sources (files, environment, …) supply defaults, and the application overrides individual values at runtime. - - `IWritableStore` (type-safe facade) and `IWritableStoreOverlay` (raw key-path surface) in `Cocoar.Configuration.Abstractions` - - **Sparse writes** — `SetAsync(x => x.Smtp.Port, value)` persists only the touched leaf; unset keys keep inheriting from the lower layers - - `ResetAsync(...)` removes an override (value falls back to the inherited default); an explicit `null` override is distinct from reset - - `DescribeAsync()` returns per-key provenance (`StoreEntry`: base value, effective value, `IsSet`) for management UIs - - `.FromStore()` rule extension; file-based backend by default with a pluggable `IStoreBackend` - - `IWritableStore` / `IWritableStoreOverlay` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging - - WritableStoreExample example project -- `IProviderServiceRegistration` now supports resolve-time factory registrations (`ProviderServiceRegistration.Singleton(type, factory)`) in addition to eager instances -- **Multi-Tenancy** — the same configuration type resolves to different values per tenant, layered on a shared global base (ADR-005) - - `ITenantConfigurationAccessor` lifecycle on `ConfigManager`: `InitializeTenantAsync` / `EnsureTenantInitializedAsync` / `IsTenantInitialized` / `RemoveTenantAsync` - - `.TenantScoped()` rule marker + `Tenant` on `IConfigurationAccessor` (default-interface member, non-breaking) — author one flat rule list, no second surface - - Per-tenant access: `GetConfigForTenant` / `GetReactiveConfigForTenant` / `GetFeatureFlagsForTenant` / `GetEntitlementsForTenant` / `GetWritableStoreForTenant` - - Tenant-only types are excluded from the global DI plan (avoids the captive-dependency bug); per-tenant flags/entitlements need no source-generator change - - Tenant config consumption (DI, no ASP.NET dependency): scoped `ITenantReactiveConfig` + `ITenantContext`; `AddCocoarTenantResolver(s => s.TenantId)` resolves the current tenant from any DI service (HTTP via `IHttpContextAccessor`) — no hand-written adapter - - ASP.NET Core: `MapTenantFeatureFlagEndpoints()` / `MapTenantEntitlementEndpoints()` -- **Service-Backed (DI-aware) configuration** — a two-layer model so config providers can use DI-managed services (ADR-006) - - `UseServiceBackedConfiguration(...)` (DI package) — Layer-2 rules whose provider factories receive the application `IServiceProvider` - - `FromStore((sp, a) => IStoreBackend)`, `FromHttp((sp, a) => HttpClient)`, and `FromService(s => config)` overloads - - providers can use `IHttpClientFactory` / Marten / EF without giving up the no-DI core; activated on host start via `IHostedLifecycleService` (a recompute, never a rebuild) - - public `ServiceBackedProviderBuilder` seam so third-party provider packages can author their own `(sp, a)` overloads - - ServiceBackedConfig example project -- **Secrets encryption-key publishing** — publish the public half of the configured secrets encryption key so a browser/CLI producer can build `cocoar.secret` envelopes - - `ISecretEncryptionKeyProvider` (`GetCurrentKey()` / `GetCurrentKeyForTenant(tenantId)`) returns exactly one current public key — the newest cert (per the configured comparer); older certs stay decrypt-only for rotation - - ASP.NET Core `MapSecretEncryptionKey()` (single-tenant) and `MapTenantSecretEncryptionKey()` (per-tenant; tenant from `ITenantContext`) at `/.well-known/cocoar/encryption-key` — one key per request, never a list, no cross-tenant exposure - - `SecretEnvelope` for typed secret-overlay writes; WritableStore `SetSecretAsync` / `SetSecretEnvelopeAsync` accept pre-encrypted envelopes -- Public `ProviderObservable` / `ProviderDisposable` helpers (in `Cocoar.Configuration.Providers.Abstractions`) for authoring a custom provider's change stream without referencing System.Reactive -- `FromFile(a => …)` config-aware file-path overload (resolves the path from the accessor per recompute) — the natural shape for per-tenant file rules - -### Changed - -- Secret payloads (the decrypted value of `Secret`) now (de)serialize with lenient options: **enums as names** (round-trip-safe if the enum is later reordered) and **case-insensitive** property matching. Reading still accepts numeric enums and any casing, so **existing encrypted secrets remain fully readable** — no migration. Only the in-memory form of newly serialized typed secret values changes (enum name instead of ordinal); encrypted envelopes at rest are unaffected. -- Reading a tenant-only type (every rule `.TenantScoped()`) from the global pipeline now throws a **targeted** error pointing at `GetConfigForTenant(id)` / `GetReactiveConfigForTenant(id)` — for both `GetConfig()` and `GetReactiveConfig()` — instead of the misleading generic "no configuration rule is registered" message (a rule does exist; it is just tenant-scoped). Matches the existing mixed-scope-tuple guard. - -### Notes - -- Secret-typed members (`Secret` / `ISecret`) cannot be overridden via WritableStore — the typed facade throws `NotSupportedException` (manage secrets via the Secrets CLI/provider). -- Overlay values serialize with vanilla options (enums as strings) and overlay keys are aligned to the lower layers' casing, so an override **replaces** the base key rather than creating a casing-variant sibling. - -## [5.0.0] - 2026-03-24 - -### Added - -- .NET 8 LTS support — all library packages multi-target net8.0 and net9.0 -- Feature Flags & Entitlements framework (`FeatureFlag`, `Entitlement`, `IFeatureFlags`, `IEntitlements`) -- Source generator for feature flag/entitlement classes (produces constructor and `Config` property) -- `IFeatureFlagEvaluator` / `IEntitlementEvaluator` for contextual evaluation (Scoped) -- `IContextResolver` for bridging HTTP requests to domain context -- REST evaluation endpoints: `MapFeatureFlagEndpoints()`, `MapEntitlementEndpoints()` -- Flag expiry tracking and health degradation (`ExpiresAt`, `IFeatureFlagsDescriptors`) -- `IFlagsHealthSource` for flags contributing to health status -- SSE (Server-Sent Events) support in HTTP provider (`serverSentEvents: true`) -- One-time HTTP fetch mode (no polling, no SSE) -- `FromIConfiguration(IConfiguration)` — simplified Microsoft adapter API -- `FromHttp()` — simplified HTTP provider API with simple overload -- Resolver registration via `[]` collection expressions and `ResolverBuilder` -- Resolver lifetime customization (`.AsSingleton()`, `.AsScoped()`, `.AsTransient()`) via Capabilities -- OpenTelemetry metrics: `cocoar.config.health.status`, `cocoar.config.recompute.count`, `cocoar.config.recompute.duration`, `cocoar.config.provider.errors`, `cocoar.config.flags.evaluations` -- Activity source `Cocoar.Configuration` for distributed tracing -- ASP.NET Core health check integration (`AddCocoarConfigurationHealthCheck()`) -- `where T : class` constraint on `TypedRuleBuilder` — configuration types must be reference types -- Roslyn analyzer diagnostics: COCFLAG001 (non-static ExpiresAt), COCFLAG002 (abstract type registered), COCFLAG003 (missing description) -- File provider security: symlink/reparse point rejection, improved path traversal defense -- VitePress documentation site with complete guide, reference, and roadmap sections -- `llms.txt` and `llms-full.txt` export for LLM consumption -- How-To guide: Migrating from IOptions -- Certificate management guide -- `ConfigManager.Create()` static factory with fluent builder pattern -- `ConfigManager.CreateAsync()` async factory with `CancellationToken` support -- `UseSecretsSetup()` builder extension for secrets configuration -- Testing API: `ReplaceConfiguration()`, `AppendConfiguration()`, `ReplaceSecretsSetup()` with fluent `TestOverrideBuilder` -- Aggregate Rules: `FromFiles(params string[])` for concise file layering, `.Aggregate(r => [...])` for general-purpose rule grouping -- `AggregateRuleManager` — isolated execution boundary for grouped rules (inner Required stays within aggregate) -- `TypedProviderBuilder` base class for provider extension methods (prevents recursive nesting in aggregates) -- `IRuleManager` interface extracted from `RuleManager` for uniform engine handling -- `SubManagers` property on `IRuleManager` for ConfigHub drill-down into aggregate structure -- ADR-004: Aggregate Rules with Isolated Execution Boundary -- AggregateRules example project - -### Changed - -- `Flag` renamed to `FeatureFlag` (and `Flag` to `FeatureFlag`) -- Package `Cocoar.Configuration.HttpPolling` renamed to `Cocoar.Configuration.Http` -- `FromHttpPolling()` renamed to `FromHttp()` -- `FromMicrosoftSource()` deprecated in favor of `FromIConfiguration()` -- `HttpPollingRuleOptions` replaced by `HttpRuleOptions` -- Default resolver lifetime changed from Transient to Scoped -- File path resolution now uses `AppContext.BaseDirectory` (was `Assembly.GetEntryAssembly().Location`) -- Health API simplified: `ConfigManager.HealthStatus` and `ConfigManager.IsHealthy` properties (was `GetHealthService()`) -- Resolver registration moved from Core to DI package -- `UseFeatureFlags()` / `UseEntitlements()` now use `[]` collection expression pattern -- DI package no longer depends on ASP.NET Core FrameworkReference -- ConfigManager constructors and `Initialize()` are now `internal` — use `ConfigManager.Create()` instead -- `AddCocoarConfiguration()` now uses the builder API: `c => c.UseConfiguration(rule => [...], setup => [...])` -- Secrets setup moved from `setup` lambda to dedicated `.UseSecretsSetup()` builder method -- Runtime recomputes are now fully async (no sync-over-async in the recompute pipeline) -- Package consolidation: 10+ packages reduced to 7 (Secrets, Flags, X509Encryption merged into core; Secrets.Abstractions merged into Abstractions; Flags.Generator merged into Analyzers) -- Testing API: `ReplaceAllRules()` renamed to `ReplaceConfiguration()`, `AppendTestRules()` renamed to `AppendConfiguration()` - -### Removed - -- `Cocoar.Configuration.Secrets` package (merged into `Cocoar.Configuration`) -- `Cocoar.Configuration.Secrets.Abstractions` package (merged into `Cocoar.Configuration.Abstractions`) -- `Cocoar.Configuration.HttpPolling` package (renamed to `Cocoar.Configuration.Http`) -- System.Reactive dependency (replaced with lightweight internal reactive primitives) -- COCFG004 analyzer diagnostic (enforced by `where T : class` constraint instead) -- `IConfigurationHealthService` interface (replaced with `ConfigManager.HealthStatus` property) -- `FeatureFlagsSetupBuilder`, `FlagClassRegistrationBuilder`, `EntitlementClassRegistrationBuilder` (replaced by `FlagsBuilder`, `EntitlementsBuilder`, `ResolverBuilder`) -- `RegisterGlobalContextResolver()`, `WithContextResolver()` (replaced by `resolvers.Global()`, `resolvers.For()`) -- `WithSetup()` testing method (was broken and redundant in v5) - -### Fixed - -- CLI exit codes now consistent across all commands (0=success, 1=argument, 2=IO, 3=crypto, 4=general) -- File provider symlink escape vulnerability -- Provider consistency for optional rules (always returns `{}`, never null) -- `Secret.Open()` now deserializes directly from UTF-8 bytes instead of creating an intermediate string -- `SecretJsonConverter` uses `JsonElement.Deserialize()` instead of `GetRawText()` to avoid plaintext string intermediates -- `X509HybridCrypto.Encrypt()` zeros the heap copy of the Data Encryption Key in the `finally` block -- `ConfigSnapshotBuilder.CreateJsonPreview()` shows only property names (not values) to prevent secret leakage - -### Migration from v4.x - -```csharp -// v4.x -var manager = new ConfigManager( - rule => [rule.For().FromFile("config.json")], - logger: myLogger -).Initialize(); - -// v5.0 -var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromFile("config.json") - ]) - .UseLogger(myLogger)); -``` - -See [Migration Guide v4→v5](website/guide/migration/v4-to-v5.md) for all patterns. - -## [4.2.1] - 2026-02-03 - -### Fixed -- **Interface reactive configs**: `IReactiveConfig` now works for interfaces exposed via `setup.ConcreteType().ExposeAs()`. Previously, requesting a reactive config for an interface type threw an error. Tuples containing interface types (e.g., `IReactiveConfig<(IAppSettings, IDatabaseSettings)>`) also now work correctly. - -## [4.2.0] - 2026-02-03 - -### Added - -**NEW: Abstractions Packages** -- `Cocoar.Configuration.Abstractions` - Lightweight package containing core interfaces for decoupled architecture - - `IConfigurationAccessor` - Interface for accessing configuration values - - `IReactiveConfig` - Interface for reactive configuration (supports tuples for atomic multi-config updates) - - Enables libraries to depend on abstractions without taking a dependency on the full configuration implementation -- `Cocoar.Configuration.Secrets.Abstractions` - Lightweight package containing secrets interfaces - - `ISecret` - Interface representing a secret value that can be opened - - `SecretLease` - Struct providing controlled access to secret values with automatic cleanup - - Enables code to work with secrets through interfaces for better testability and decoupling - - `ISecret` properties in configuration classes automatically deserialize to `Secret` instances - -**NEW: AllowPlaintext() for Secrets** -- New `AllowPlaintext()` fluent API to conditionally allow plaintext JSON values to be deserialized into `Secret` properties -- Useful for development and testing scenarios where encrypted envelopes are not available -- **SECURITY WARNING**: Only enable in development/test environments; production should always use encrypted envelopes -- Example: `.UseSecretsSetup(secrets => secrets.AllowPlaintext(builder.Environment.IsDevelopment()))` (v5.0+ syntax) - -**NEW: Testing Setup Overrides** -- Extended `CocoarTestConfiguration` to support setup overrides in addition to rule overrides - - New `WithSetup()` method for setup-only overrides (keeps original rules) - - Optional `setup` parameter added to `ReplaceAllRules()`, `AppendTestRules()`, and `Apply()` - - `TestConfigurationContext.Replace()` and `Append()` factory methods now accept optional setup parameter - - Enables test-time setup options like `setup.Secrets().AllowPlaintext()` without modifying application code - - Setup overrides are always merged (appended) to configured setup, using last-write-wins for capabilities - - See [Testing Overrides Documentation](website/guide/testing/overrides.md) for usage patterns - -### Fixed -- **Deserialization failure logging**: `GetConfig()` now logs an error (EventId 5100) when deserialization fails due to missing `required` properties or type mismatches, instead of silently returning `null`. This helps diagnose configuration issues while maintaining backward-compatible behavior (still returns `null`, `GetRequiredConfig()` still throws). -- **DI registration ordering**: Service descriptors are now emitted in deterministic order (sorted by type full name). Previously, dictionary iteration order could vary between runs, affecting test assertions and diagnostic logging. -- **Provider rebuild callback ordering**: Fixed callback timing in `RuleProviderLease` to invoke the subscription reset callback *before* rebuilding the provider, matching original `RuleManager` behavior. This prevents spurious change notifications that could occur if a provider's `Dispose()` triggers events while the subscription is still active. - -### Changed -- **Type Relocation with Forwarding**: Moved core interfaces to abstractions packages with type forwarding for full backward compatibility - - `IConfigurationAccessor` moved to `Cocoar.Configuration.Abstractions` - - `IReactiveConfig` moved to `Cocoar.Configuration.Abstractions` - - `SecretLease` moved to `Cocoar.Configuration.Secrets.Abstractions` - - Existing code continues to work unchanged via `[TypeForwardedTo]` attributes - -## [4.1.0] - 2026-01-11 - -### Fixed -- **Provider consistency bug**: Optional rules now consistently return empty objects with C# defaults when sources are unavailable, instead of inconsistently returning null. This fixes a bug where source-based providers (File, HTTP) behaved differently than collection-based providers (Environment, CommandLine). See [ADR-003](website/adr/ADR-003-provider-consistency-empty-objects.md) for details. - - All providers now return `{}` on failure, resulting in configuration objects with C# property defaults - - Failures are tracked via health monitoring with `Degraded` status - - Eliminates need for workarounds like adding fake `FromEnvironment()` rules - - `GetConfig()` never returns null when rules are defined for type T - - `GetRequiredConfig()` now explicitly checks for rule definitions (static check), not runtime availability - -### Changed -- Removed internal `include` flag from `RuleManager.ComputeAsync()` return type - now returns `ReadOnlyMemory?` where `null` means skip (When condition false), simplifying the API and making the decision point clearer in the recompute cycle - -## [4.0.0] - 2026-01-08 - -### Added - -**NEW: Testing Configuration Overrides** -- `CocoarTestConfiguration` API for overriding configuration in integration tests -- Two modes: `ReplaceAllRules()` (skip original rules) and `AppendTestRules()` (last-write-wins merging) -- Uses `AsyncLocal` for automatic test isolation across parallel tests -- Works universally with direct `ConfigManager` instantiation, DI, AspNetCore, and `WebApplicationFactory` -- Zero application code changes required - detection happens in ConfigManager constructors -- Comprehensive documentation in [Testing Overrides](website/guide/testing/overrides.md) -- Example project demonstrating usage patterns - -**NEW: Cocoar.Configuration.Secrets Package [Developer Preview]** -⚠️ **Developer Preview**: API may change in future releases based on feedback. Production-ready but subject to refinement. -- `Secret` type for type-safe secret handling with automatic memory zeroing -- Hybrid encryption using RSA key wrapping + AES-GCM for envelope-based secrets -- X.509 certificate-based encryption with **password-less certificates** (industry standard) - - Security model: File permissions (`chmod 600`) + full-disk encryption (BitLocker/LUKS/FileVault) - - Follows nginx, PostgreSQL, Kubernetes, Docker patterns - - No password bootstrapping problem -- Flexible folder-based key management with certificate inventory -- Support for key identifiers (kid) for multi-tenant scenarios -- Configurable certificate ordering and subdirectory search depth -- Seamless JSON deserialization support via custom converters -- Works with primitives, complex types, collections, and nested objects - [Developer Preview]** -⚠️ **Developer Preview**: CLI commands and options may evolve based on feedback. -**NEW: Cocoar.Configuration.Secrets.Cli Tool** -- Command-line tool for managing encrypted secrets in JSON configuration files -- **`generate-cert`**: Generate self-signed certificates (PFX or PEM format) - - Password-less by default (password optional for legacy compatibility) - - Smart format detection from file extension (`.pfx`, `.crt`, `.cer`, `.pem`) -- **`convert-cert`**: Convert between certificate formats and remove passwords - - Supports PFX↔PEM conversion with automatic format detection - - Output password optional - defaults to password-less (industry standard) - - Provides platform-specific file permission guidance - - Unified tool for format conversion and password removal -- **`cert-info`**: Display detailed certificate information - - Shows validity, key size, password status - - Detects password-protected vs password-less certificates - - Validates certificate thumbprints for conversion verification -- **`encrypt`**: Encrypt plaintext values in JSON files -- **`decrypt`**: Decrypt encrypted values from JSON files - -**NEW: Cocoar.Configuration.Analyzers Package** -- Roslyn analyzers for compile-time configuration validation -- **COCFG001**: Detects secret path conflicts (non-secret properties with same path as secret properties) -- **COCFG002**: Validates rule dependency ordering (prevents accessing config types not yet loaded) -- **COCFG003**: Warns about required rules referencing potentially missing resources -- **COCFG004**: Enforces type safety in configuration accessors -- **COCFG005**: Identifies duplicate unconditional rules for the same type -- **COCFG006**: Suggests optimal ordering for static/seed rules vs dynamic rules - - -### Changed - -**BREAKING: Provider contract refactored to use byte[] instead of JsonElement** -- `FetchConfigurationAsync` → `FetchConfigurationBytesAsync` (returns `byte[]`) -- `Changes` → `ChangesAsBytes` (emits `byte[]`) -- Improves performance by avoiding intermediate JsonElement conversions -- All built-in providers updated (File, Http, Environment, CommandLine, MicrosoftAdapter, etc.) -- Public ConfigManager API unchanged - byte[] conversion handled internally - -### Notes -- The provider contract change is internal - consuming applications are not affected -- **Secrets feature is in Developer Preview**: While production-ready, the API may evolve based on real-world usage and feedback -- Password-less certificates are the recommended approach (industry standard: nginx, PostgreSQL, Kubernetes, Docker) - -## [3.3.0] - 2025-10-23 - -### Added -- **Rule Naming**: New `.Named("name")` fluent API for adding human-readable names to rules - - Example: `rule.For().FromFile("db.json").Named("Primary Database")` - - Names appear in health snapshots for better observability in dashboards and logs - -- **Enhanced Health Monitoring**: Expanded `RuleHealthEntry` with additional metadata - - `Name` - Optional rule name (set via `.Named()`) - - `ProviderType` - Name of the provider type used by the rule - - `ConfigType` - Name of the configuration type being loaded - - `Skipped` status - New `RuleResultStatus.Skipped` for rules skipped by `.When()` conditions - -- **Health Summary Metrics**: New `Skipped` count in `Summary` for tracking conditional rules - - Complements existing `Total`, `RequiredFailed`, `OptionalFailed` counters - -- **Auto DI Registration**: `IConfigurationHealthService` now automatically registered in DI container - - Available immediately after `AddCocoarConfiguration()` - - No manual registration needed - -- **Comprehensive Health Tests**: 94 lines of new integration tests covering all health monitoring features - -### Improved -- **Health Documentation**: Simplified health-monitoring.md with clearer, direct usage examples - - Added Prometheus endpoint example (pull-based metrics) - - Added reactive subscription example (push-based metrics) - - Removed complex export system in favor of simple, direct `IConfigurationHealthService` access - -### Removed -- **Experimental Metrics Export APIs** (marked "Experimental / Untested" in v3.2.0): - - `HealthMetricsExporter` class - Unnecessary wrapper around health service - - `ISimpleHealthMetricsSink` interface - Over-engineered abstraction - - `HealthMetrics` struct - Redundant with `ConfigHealthSnapshot` - - `AddCocoarHealthMetricsExporter()` extension - Removed in favor of direct health service usage - - Users should access `IConfigurationHealthService.Snapshot` directly or subscribe to `SnapshotStream` - -## [3.2.0] - 2025-10-23 - -### Added -- **CommandLine Provider**: New built-in provider for parsing command-line arguments - - Supports multiple switch prefixes simultaneously: `["--", "-", "/"]` or custom semantic prefixes like `["@", "#", "%"]` - - Example: `.FromCommandLine(["--", "-", "/"])` accepts all three styles in the same command line - - Automatic longest-match-first algorithm prevents ambiguity (e.g., `--host` matches `"--"` before `"-"`) - - Flexible API: `.FromCommandLine()`, `.FromCommandLine("prefix_")`, `.FromCommandLine(["-"])` - - Supports nested configuration with `:` or `__` separators (e.g., `--database:host=localhost` or `--database__host=localhost`) - - Prefix filtering to map different arguments to different config types - - Three argument formats: `--key=value`, `--key value`, `--flag` (boolean) - - Enables self-documenting CLIs: `invoke.exe @host=server #issue=123 %env=prod` - -- **Test Example**: New CommandLineExample project with 6 comprehensive integration tests - -### Improved -- **Provider Architecture**: Enhanced ProviderOptions vs QueryOptions separation across CommandLine, Environment, and FileSource providers - - `ProviderOptions` now only contains provider-level configuration (shared across queries) - - `QueryOptions` contains query-specific parameters (different per rule) - - Enables proper provider sharing and rule-specific configuration - -### Removed -- **Dead Code Cleanup**: Removed 4 unused FileSystemObservable files (~140 lines) - - Existing `FileWatcherObservable` is used and battle-tested - - -## [3.1.1] - 2025-10-19 - -### Fixed -- **Enum String Conversion**: Added `JsonStringEnumConverter` to support deserializing enums from string values in JSON/environment variables - - Fixes issue where enum properties (e.g., `LogEventLevel.Debug`) would fail when provided as strings (e.g., `"Debug"`) - - Common scenario: Visual Studio setting `Logging={"LogLevel":{"Microsoft.AspNetCore.Watch":"Debug"}}` as environment variable - - Now supports case-insensitive string-to-enum conversion for all enum types - -## [3.1.0] - 2025-10-19 - -### Added -- **Interface Deserialization Support**: New `setup.Interface().DeserializeTo()` API for deserializing interface-typed properties in configuration classes - - Solves the problem where configuration classes with interface properties (e.g., `public ILoggingConfig Logging { get; set; }`) would fail deserialization from JSON sources - - Common scenario: Setting logging configuration via environment variables (e.g., `Logging__LogLevel__Default=Debug`) or when Visual Studio injects logging configuration for hot reload - - Supports deeply nested interface properties at any depth - - Example: `setup.Interface().DeserializeTo()` - - Includes comprehensive test coverage for nested and deeply nested scenarios - -### Usage Example -```csharp -builder.AddCocoarConfiguration(rule => [ - rule.For().FromEnvironment() -], setup => [ - setup.ConcreteType().ExposeAs(), - - // Map interface properties to concrete types - setup.Interface().DeserializeTo() -]); -``` - -## [3.0.0] - -### Added -- **Config-Aware Conditional Rules**: `.When()` method now receives `IConfigurationAccessor` parameter, allowing rules to be conditionally executed based on configuration from earlier rules - - Example: `rule.For().FromFile("premium.json").When(accessor => accessor.GetRequiredConfig().Tier == "Premium")` - - Enables powerful dynamic configuration scenarios (multi-tenant, environment-based, feature flags) -- **ConditionalRulesExample**: New example project demonstrating config-aware conditional rules - -### Changed -- **Type-First API**: Refactored rule builder API from Provider-First to Type-First pattern for better discoverability and type safety - - Old: `rule.File("...").For()` → New: `rule.For().FromFile("...")` - - All provider methods renamed: `File()` → `FromFile()`, `Environment()` → `FromEnvironment()`, etc. - - Consistent pattern across all providers (File, Environment, Static, Observable, HttpPolling, MicrosoftSource) - -### Breaking -- **Type-First API Changes** (⚠️ MAJOR): - - `rule.File(...)` → `rule.For().FromFile(...)` - - `rule.Environment(...)` → `rule.For().FromEnvironment(...)` - - `rule.StaticJson(...)` → `rule.For().FromStaticJson(...)` - - `rule.Static(...)` → `rule.For().FromStatic(...)` - - `rule.Observable(...)` → `rule.For().FromObservable(...)` - - `rule.HttpPolling(...)` → `rule.For().FromHttpPolling(...)` - - `rule.MicrosoftSource(...)` → `rule.For().FromMicrosoftSource(...)` - - `rule.FromProvider(...)` → `rule.For().FromProvider(...)` -- **When() Signature Change**: `.When(Func)` → `.When(Func)` - - Old: `rule.File("...").When(() => condition).For()` - - New: `rule.For().FromFile("...").When(_ => condition)` or with accessor: `.When(accessor => accessor.GetRequiredConfig().Property)` - -### Removed -- Removed test helper methods from production provider APIs (`CreateRule` methods in FileSourceProvider, StaticJsonProvider, ObservableProvider) - - These were internal test utilities mistakenly exposed in public API surface - -### Migration from v2.0 -```csharp -// v2.0 (Provider-First) -builder.AddCocoarConfiguration(rule => [ - rule.File("config.json").Select("App").For(), - rule.Environment("APP_").For(), - - // Conditional rule (old signature) - rule.File("premium.json") - .When(() => isPremium) - .For() -], setup => [ - setup.ConcreteType().ExposeAs() -]); - -// v3.0 (Type-First + Config-Aware When) -builder.AddCocoarConfiguration(rule => [ - rule.For().FromFile("config.json").Select("App"), - rule.For().FromEnvironment("APP_"), - - // Conditional rule (new signature with accessor) - rule.For().FromFile("premium.json") - .When(accessor => accessor.GetRequiredConfig().Tier == "Premium") -], setup => [ - setup.ConcreteType().ExposeAs() -]); -``` - -See [Migration Guide v2→v3](website/guide/migration/v2-to-v3.md) for detailed migration instructions. - -## [2.0.0] - 2025-09-30 - -### Changed -- **Builder API Modernization**: Replaced static `Rule.From.*` API with function-based `RulesBuilder` pattern (`rule => rule.File(...)`) for more intuitive configuration -- **Setup API Modernization**: Replaced `Bind.Type().To()` API with function-based `SetupBuilder` pattern (`setup => setup.ConcreteType().ExposeAs()`) with clearer naming -- Renamed "binding" terminology to "exposure" throughout the API and documentation for clarity - -### Breaking -- `Rule.From.File()`, `Rule.From.Environment()`, etc. replaced with `RulesBuilder` lambda parameter: `builder.AddCocoarConfiguration(rule => [rule.File(...), rule.Environment(...)])` -- `Bind.Type().To()` replaced with `SetupBuilder` lambda parameter: `setup => setup.ConcreteType().ExposeAs()` -- `ServiceRegistrationOptions` and `Register.Add()` replaced with direct lifetime configuration on `ConcreteTypeSetup` - -### Migration from v1.x -```csharp -// v1.x -builder.AddCocoarConfiguration([ - Rule.From.File("config.json").Select("App").For(), - Rule.From.Environment("APP_").For() -], [ - Bind.Type().To() -]); - -// v2.0 -builder.AddCocoarConfiguration(rule => [ - rule.File("config.json").Select("App").For(), - rule.Environment("APP_").For() -], setup => [ - setup.ConcreteType().ExposeAs() -]); -``` - -## [1.1.0] - 2025-09-25 - -`IReactiveConfig` now supports tuples as the generic type, e.g. -`IReactiveConfig<(AppSettings, DbSettings)>`. -When used with a tuple, all element types are recomputed and emitted atomically in the same pass. -This guarantees you never see a mix of old and new values across different configs. - -## [1.0.0] - 2025-09-21 - -### 🎉 First Stable Release - -This marks the first stable release of Cocoar.Configuration with production-ready features and comprehensive testing infrastructure. - -### Added -- **Comprehensive Test Suite**: **204 automated tests** covering core functionality, providers, edge cases, and stress scenarios -- **Health Monitoring System**: Complete health monitoring with `IConfigurationHealthService`, health snapshots, and experimental metrics export hooks -- **Reactive Configuration**: Auto-registered `IReactiveConfig` for every configuration type in dependency injection -- **Enhanced Documentation**: - - Streamlined README with practical examples and accurate feature descriptions - - Dedicated health monitoring guide (`website/guide/health/overview.md`) - - Reactive configuration guide (`website/guide/reactive/basics.md`) - -### Testing Improvements -- **Integration Tests**: Multi-provider composition, rule ordering, recompute pipeline validation -- **Stress & Performance Tests**: High-frequency changes, large JSON handling, concurrent access scenarios -- **Provider Battle Tests**: HTTP headers/caching, Microsoft adapter integration, environment variable edge cases -- **Health Pipeline Tests**: Status derivation, recovery scenarios, observable health updates -- **Fuzz Testing**: Random change sequences maintaining correctness and deterministic results -- **File Provider Resilience**: FileSystemWatcher ↔ polling fallback and automatic recovery testing - -### Enhanced -- **File Provider**: Improved resilience with automatic polling fallback when FileSystemWatcher fails -- **Test Organization**: Restructured test projects with clear separation (Core.Tests, Providers.Tests) -- **Example Projects**: Updated all 12 example projects with improved clarity and modern patterns -- **Documentation Structure**: Consolidated scattered documentation into focused, practical guides - -### Quality & Reliability -- **204 comprehensive tests** ensuring stability across all components and failure scenarios -- **Production-tested patterns** validated through extensive integration and stress testing -- **Error-resilient implementations** with proper failure handling and recovery mechanisms -- **Continuous integration** ensuring every change maintains stability and correctness - -## [0.15.0] - 2025-09-18 - -### Fixed -- **FileSystemWatcher File Locking:** Fixed file locking conflicts in FileSourceProvider by implementing proper file sharing (FileShare.ReadWrite) to prevent missed configuration changes and IOException conflicts in production scenarios. -- **Testing Anti-Patterns:** Eliminated flaky test behavior by removing emission counting anti-patterns and implementing proper final state validation in reactive configuration tests. - -### Changed -- Enhanced test organization with better naming conventions and regional structuring for improved maintainability. -- Implemented active waiting patterns and controllable testing infrastructure (ObservableProvider) for reliable FileSystemWatcher testing. -- Added comprehensive stress testing suite with multi-iteration reliability validation. - -## [0.14.0] - 2025-09-17 - -### Added -- StaticJsonProvider now supports JSON strings directly via `Rule.From.StaticJson(jsonString)`. - -### Changed -- StaticJsonProvider instances are no longer shared between rules (null-key pattern) to prevent configuration data leakage between different rules. - -## [0.13.0] - 2025-09-17 - -### Changed -- License changed from MIT to Apache-2.0 to provide an explicit patent grant and consistent downstream attribution via `NOTICE`. Previous released versions remain under MIT. - -## [0.12.0] - 2025-09-17 - -### Added -- Reactive configuration channel: automatic `IReactiveConfig` for every config type (singleton, hash-gated, error-resilient). -- Auto DI registration for `IReactiveConfig` (opt-out via `DisableAutoReactiveRegistration`). - -### Performance -- Streaming JSON → MD5 hashing pipeline for selection & emission gating (reduced allocations, faster change detection). -- Partial recompute optimizations (earliest changed rule restart) documented and hardened. - -### Documentation -- README overhaul: simplified quick start, lifetimes, reactive defaults, full examples table with direct links. -- Added `DEEP_DIVE.md` (advanced scenarios, tuning, dynamic dependencies). -- Updated architecture doc: streaming MD5 hashing + reactive channel section. -- Clarified partial recompute & reactive channel in Concepts; added migration note for merged reactive implementation. -- Converted inline doc code references to clickable links across docs & examples. - -## [0.11.1] - 2025-09-16 - -- Refactor configuration options to set default 'Required' value to false and remove unnecessary 'Optional' calls in tests -- Add DI Options for Cocoar.Configuration.AspNetCore - AddCocoarConfiguration - -## [0.11.0] - 2025-09-16 - -### Added -- New Binding System (`Bind.Type().To()`) enabling interface mapping independent of DI. -- `BindingRegistry` and runtime validation for interface→concrete compatibility. -- `Cocoar.Configuration.DI` package: separation of concerns between interface binding and DI registration. -- `ServiceRegistrationOptions.DefaultRegistrationLifetime(null)` to fully disable auto-registration. -- Keyed service registration refinement via explicit `options.Register.Add(lifetime, key)` model. -- Comprehensive documentation: [docs/BINDING.md](docs/BINDING.md), updated README Binding vs DI Registration section. - -### Changed -- Auto-registration default clarified (Scoped) and now explicitly optional. -- Examples reorganized: added dedicated Binding, DI, ServiceLifetimes demos; README minimized to one-liners. - -### Internal / Quality -- Expanded test coverage for service lifetimes, keyed registrations, binding validation. -- Documentation cleanup removing unreleased prototype terminology. - -### Migration Notes -- For consumers of 0.9.x: follow 0.10.0 migration first (selection API changes), then optionally adopt bindings (purely additive). - - -## [0.10.0] - 2025-09-15 - -### Breaking -- Removed query-level `configurationPath` and `targetPath`; use rule-level `.Select(...)` and `.MountAt(...)`. - -### Highlights -- Rule Selection Simplification: centralized rule-level selection (`.Select`) clarifies intent and simplifies providers. -- Incremental Recompute Engine: recompute only from earliest changed rule; unchanged prefix reused from cached flattened contributions. -- Change Gating & Noise Reduction: selection-hash gating skips recompute when the selected subtree is unchanged. -- Fast Cancellation & Coalescing: mid-pass cancellation plus immediate + trailing debounce collapses bursts without added latency for first change. -- Coalescer Extraction: new `RecomputeCoalescer` class isolates change accumulation & timing logic. -- Deletion Propagation: removed keys from updated rule contributions are pruned from final configs (no stale "zombie" keys). -- Snapshot Model Simplification: dropped merged/unflattened snapshot storage; now only per-rule flattened contribution + selection hash. -- Static Rule Set Decision: rules immutable post-initialization (use `UseWhen` to conditionally participate). -- Provider & Query Cleanup: normalized option/query shapes; file examples updated to selection chaining. -- Test Suite Expansion: new tests for partial recompute, cancellation, selection hash gating, key deletions; added explanatory headers. -- Performance Foundations: hashing + earliest-index logic lays groundwork for future benchmarks. -- Documentation & Examples Refresh: updated architecture & provider docs to Fetch → Select → Mount → Merge; removed legacy two-argument file section usage. -- API Surface Cleanup: removed obsolete params & helpers; pruned unused hash utilities. -- Reliability & Determinism: provider injection seam enables deterministic tests; strengthened dynamic dependency scenarios. -- Logging & Error Handling Consistency: resilient change-trigger error handling while preserving required/optional semantics. - -### Migration Summary -```diff -- Rule.From.File(_ => FileSourceRuleOptions.FromFilePath("appsettings.json", configurationPath: "A:B")).For().Build(); -+ Rule.From.File("appsettings.json").Select("A:B").For().Build(); - -- Rule.From.HttpPolling(_ => HttpPollingRuleOptions.FromPath("service.json", configurationPath: "Service")).For().Build(); -+ Rule.From.HttpPolling("service.json").Select("Service").For().Build(); - -- Rule.From.File(_ => FileSourceRuleOptions.FromFilePath("base.json", targetPath: "Config:Base"))... -+ Rule.From.File("base.json").MountAt("Config:Base")... -``` - - -## [0.9.2] - 2025-09-15 - -- Added: Concise overloads Rule.From.File(...), Rule.From.Environment(...). -- Added: .MountAt fluent API for rule mounting. -- Migration: Replace targetPath: "A:B" with .MountAt("A:B"). - -## [0.9.1] - 2025-09-14 -Branding / assets update. - -- Replaced NuGet package icon (`package-icon.png`). -- Updated README image. -- Updated GitHub social preview images (`social-preview.png`, `social-preview-small.png`) stored at repo root. -- No functional/code changes. - -## [0.9.0] - 2025-09-14 -Initial release 🎉 - -- Deterministic ordered configuration layering (last-write-wins) -- Strongly typed DI (no IOptions) -- Providers: File, Environment, Static, HTTP Polling, Microsoft Adapter -- Dynamic rule factories & atomic snapshot recompute -- DI lifetimes & keyed registrations -- Examples included under `src/Examples/` - - +# Changelog + +## [5.1.0] - 2026-05-31 + +### Added + +- **WritableStore provider** — a writable, application-controlled override layer for *overridable defaults*: the normal sources (files, environment, …) supply defaults, and the application overrides individual values at runtime. + - `IWritableStore` (type-safe facade) and `IWritableStoreOverlay` (raw key-path surface) in `Cocoar.Configuration.Abstractions` + - **Sparse writes** — `SetAsync(x => x.Smtp.Port, value)` persists only the touched leaf; unset keys keep inheriting from the lower layers + - `ResetAsync(...)` removes an override (value falls back to the inherited default); an explicit `null` override is distinct from reset + - `DescribeAsync()` returns per-key provenance (`StoreEntry`: base value, effective value, `IsSet`) for management UIs + - `.FromStore()` rule extension; file-based backend by default with a pluggable `IStoreBackend` + - `IWritableStore` / `IWritableStoreOverlay` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging + - WritableStoreExample example project +- `IProviderServiceRegistration` now supports resolve-time factory registrations (`ProviderServiceRegistration.Singleton(type, factory)`) in addition to eager instances +- **Multi-Tenancy** — the same configuration type resolves to different values per tenant, layered on a shared global base (ADR-005) + - `ITenantConfigurationAccessor` lifecycle on `ConfigManager`: `InitializeTenantAsync` / `EnsureTenantInitializedAsync` / `IsTenantInitialized` / `RemoveTenantAsync` + - `.TenantScoped()` rule marker + `Tenant` on `IConfigurationAccessor` (default-interface member, non-breaking) — author one flat rule list, no second surface + - Per-tenant access: `GetConfigForTenant` / `GetReactiveConfigForTenant` / `GetFeatureFlagsForTenant` / `GetEntitlementsForTenant` / `GetWritableStoreForTenant` + - Tenant-only types are excluded from the global DI plan (avoids the captive-dependency bug); per-tenant flags/entitlements need no source-generator change + - Tenant config consumption (DI, no ASP.NET dependency): scoped `ITenantReactiveConfig` + `ITenantContext`; `AddCocoarTenantResolver(s => s.TenantId)` resolves the current tenant from any DI service (HTTP via `IHttpContextAccessor`) — no hand-written adapter + - ASP.NET Core: `MapTenantFeatureFlagEndpoints()` / `MapTenantEntitlementEndpoints()` +- **Service-Backed (DI-aware) configuration** — a two-layer model so config providers can use DI-managed services (ADR-006) + - `UseServiceBackedConfiguration(...)` (DI package) — Layer-2 rules whose provider factories receive the application `IServiceProvider` + - `FromStore((sp, a) => IStoreBackend)`, `FromHttp((sp, a) => HttpClient)`, and `FromService(s => config)` overloads + - providers can use `IHttpClientFactory` / Marten / EF without giving up the no-DI core; activated on host start via `IHostedLifecycleService` (a recompute, never a rebuild) + - public `ServiceBackedProviderBuilder` seam so third-party provider packages can author their own `(sp, a)` overloads + - ServiceBackedConfig example project +- **Secrets encryption-key publishing** — publish the public half of the configured secrets encryption key so a browser/CLI producer can build `cocoar.secret` envelopes + - `ISecretEncryptionKeyProvider` (`GetCurrentKey()` / `GetCurrentKeyForTenant(tenantId)`) returns exactly one current public key — the newest cert (per the configured comparer); older certs stay decrypt-only for rotation + - ASP.NET Core `MapSecretEncryptionKey()` (single-tenant) and `MapTenantSecretEncryptionKey()` (per-tenant; tenant from `ITenantContext`) at `/.well-known/cocoar/encryption-key` — one key per request, never a list, no cross-tenant exposure + - `SecretEnvelope` for typed secret-overlay writes; WritableStore `SetSecretAsync` / `SetSecretEnvelopeAsync` accept pre-encrypted envelopes +- Public `ProviderObservable` / `ProviderDisposable` helpers (in `Cocoar.Configuration.Providers.Abstractions`) for authoring a custom provider's change stream without referencing System.Reactive +- `FromFile(a => …)` config-aware file-path overload (resolves the path from the accessor per recompute) — the natural shape for per-tenant file rules + +### Changed + +- Secret payloads (the decrypted value of `Secret`) now (de)serialize with lenient options: **enums as names** (round-trip-safe if the enum is later reordered) and **case-insensitive** property matching. Reading still accepts numeric enums and any casing, so **existing encrypted secrets remain fully readable** — no migration. Only the in-memory form of newly serialized typed secret values changes (enum name instead of ordinal); encrypted envelopes at rest are unaffected. +- Reading a tenant-only type (every rule `.TenantScoped()`) from the global pipeline now throws a **targeted** error pointing at `GetConfigForTenant(id)` / `GetReactiveConfigForTenant(id)` — for both `GetConfig()` and `GetReactiveConfig()` — instead of the misleading generic "no configuration rule is registered" message (a rule does exist; it is just tenant-scoped). Matches the existing mixed-scope-tuple guard. + +### Notes + +- Secret-typed members (`Secret` / `ISecret`) cannot be overridden via WritableStore — the typed facade throws `NotSupportedException` (manage secrets via the Secrets CLI/provider). +- Overlay values serialize with vanilla options (enums as strings) and overlay keys are aligned to the lower layers' casing, so an override **replaces** the base key rather than creating a casing-variant sibling. + +## [5.0.0] - 2026-03-24 + +### Added + +- .NET 8 LTS support — all library packages multi-target net8.0 and net9.0 +- Feature Flags & Entitlements framework (`FeatureFlag`, `Entitlement`, `IFeatureFlags`, `IEntitlements`) +- Source generator for feature flag/entitlement classes (produces constructor and `Config` property) +- `IFeatureFlagEvaluator` / `IEntitlementEvaluator` for contextual evaluation (Scoped) +- `IContextResolver` for bridging HTTP requests to domain context +- REST evaluation endpoints: `MapFeatureFlagEndpoints()`, `MapEntitlementEndpoints()` +- Flag expiry tracking and health degradation (`ExpiresAt`, `IFeatureFlagsDescriptors`) +- `IFlagsHealthSource` for flags contributing to health status +- SSE (Server-Sent Events) support in HTTP provider (`serverSentEvents: true`) +- One-time HTTP fetch mode (no polling, no SSE) +- `FromIConfiguration(IConfiguration)` — simplified Microsoft adapter API +- `FromHttp()` — simplified HTTP provider API with simple overload +- Resolver registration via `[]` collection expressions and `ResolverBuilder` +- Resolver lifetime customization (`.AsSingleton()`, `.AsScoped()`, `.AsTransient()`) via Capabilities +- OpenTelemetry metrics: `cocoar.config.health.status`, `cocoar.config.recompute.count`, `cocoar.config.recompute.duration`, `cocoar.config.provider.errors`, `cocoar.config.flags.evaluations` +- Activity source `Cocoar.Configuration` for distributed tracing +- ASP.NET Core health check integration (`AddCocoarConfigurationHealthCheck()`) +- `where T : class` constraint on `TypedRuleBuilder` — configuration types must be reference types +- Roslyn analyzer diagnostics: COCFLAG001 (non-static ExpiresAt), COCFLAG002 (abstract type registered), COCFLAG003 (missing description) +- File provider security: symlink/reparse point rejection, improved path traversal defense +- VitePress documentation site with complete guide, reference, and roadmap sections +- `llms.txt` and `llms-full.txt` export for LLM consumption +- How-To guide: Migrating from IOptions +- Certificate management guide +- `ConfigManager.Create()` static factory with fluent builder pattern +- `ConfigManager.CreateAsync()` async factory with `CancellationToken` support +- `UseSecretsSetup()` builder extension for secrets configuration +- Testing API: `ReplaceConfiguration()`, `AppendConfiguration()`, `ReplaceSecretsSetup()` with fluent `TestOverrideBuilder` +- Aggregate Rules: `FromFiles(params string[])` for concise file layering, `.Aggregate(r => [...])` for general-purpose rule grouping +- `AggregateRuleManager` — isolated execution boundary for grouped rules (inner Required stays within aggregate) +- `TypedProviderBuilder` base class for provider extension methods (prevents recursive nesting in aggregates) +- `IRuleManager` interface extracted from `RuleManager` for uniform engine handling +- `SubManagers` property on `IRuleManager` for ConfigHub drill-down into aggregate structure +- ADR-004: Aggregate Rules with Isolated Execution Boundary +- AggregateRules example project + +### Changed + +- `Flag` renamed to `FeatureFlag` (and `Flag` to `FeatureFlag`) +- Package `Cocoar.Configuration.HttpPolling` renamed to `Cocoar.Configuration.Http` +- `FromHttpPolling()` renamed to `FromHttp()` +- `FromMicrosoftSource()` deprecated in favor of `FromIConfiguration()` +- `HttpPollingRuleOptions` replaced by `HttpRuleOptions` +- Default resolver lifetime changed from Transient to Scoped +- File path resolution now uses `AppContext.BaseDirectory` (was `Assembly.GetEntryAssembly().Location`) +- Health API simplified: `ConfigManager.HealthStatus` and `ConfigManager.IsHealthy` properties (was `GetHealthService()`) +- Resolver registration moved from Core to DI package +- `UseFeatureFlags()` / `UseEntitlements()` now use `[]` collection expression pattern +- DI package no longer depends on ASP.NET Core FrameworkReference +- ConfigManager constructors and `Initialize()` are now `internal` — use `ConfigManager.Create()` instead +- `AddCocoarConfiguration()` now uses the builder API: `c => c.UseConfiguration(rule => [...], setup => [...])` +- Secrets setup moved from `setup` lambda to dedicated `.UseSecretsSetup()` builder method +- Runtime recomputes are now fully async (no sync-over-async in the recompute pipeline) +- Package consolidation: 10+ packages reduced to 7 (Secrets, Flags, X509Encryption merged into core; Secrets.Abstractions merged into Abstractions; Flags.Generator merged into Analyzers) +- Testing API: `ReplaceAllRules()` renamed to `ReplaceConfiguration()`, `AppendTestRules()` renamed to `AppendConfiguration()` + +### Removed + +- `Cocoar.Configuration.Secrets` package (merged into `Cocoar.Configuration`) +- `Cocoar.Configuration.Secrets.Abstractions` package (merged into `Cocoar.Configuration.Abstractions`) +- `Cocoar.Configuration.HttpPolling` package (renamed to `Cocoar.Configuration.Http`) +- System.Reactive dependency (replaced with lightweight internal reactive primitives) +- COCFG004 analyzer diagnostic (enforced by `where T : class` constraint instead) +- `IConfigurationHealthService` interface (replaced with `ConfigManager.HealthStatus` property) +- `FeatureFlagsSetupBuilder`, `FlagClassRegistrationBuilder`, `EntitlementClassRegistrationBuilder` (replaced by `FlagsBuilder`, `EntitlementsBuilder`, `ResolverBuilder`) +- `RegisterGlobalContextResolver()`, `WithContextResolver()` (replaced by `resolvers.Global()`, `resolvers.For()`) +- `WithSetup()` testing method (was broken and redundant in v5) + +### Fixed + +- CLI exit codes now consistent across all commands (0=success, 1=argument, 2=IO, 3=crypto, 4=general) +- File provider symlink escape vulnerability +- Provider consistency for optional rules (always returns `{}`, never null) +- `Secret.Open()` now deserializes directly from UTF-8 bytes instead of creating an intermediate string +- `SecretJsonConverter` uses `JsonElement.Deserialize()` instead of `GetRawText()` to avoid plaintext string intermediates +- `X509HybridCrypto.Encrypt()` zeros the heap copy of the Data Encryption Key in the `finally` block +- `ConfigSnapshotBuilder.CreateJsonPreview()` shows only property names (not values) to prevent secret leakage + +### Migration from v4.x + +```csharp +// v4.x +var manager = new ConfigManager( + rule => [rule.For().FromFile("config.json")], + logger: myLogger +).Initialize(); + +// v5.0 +var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromFile("config.json") + ]) + .UseLogger(myLogger)); +``` + +See [Migration Guide v4→v5](website/guide/migration/v4-to-v5.md) for all patterns. + +## [4.2.1] - 2026-02-03 + +### Fixed +- **Interface reactive configs**: `IReactiveConfig` now works for interfaces exposed via `setup.ConcreteType().ExposeAs()`. Previously, requesting a reactive config for an interface type threw an error. Tuples containing interface types (e.g., `IReactiveConfig<(IAppSettings, IDatabaseSettings)>`) also now work correctly. + +## [4.2.0] - 2026-02-03 + +### Added + +**NEW: Abstractions Packages** +- `Cocoar.Configuration.Abstractions` - Lightweight package containing core interfaces for decoupled architecture + - `IConfigurationAccessor` - Interface for accessing configuration values + - `IReactiveConfig` - Interface for reactive configuration (supports tuples for atomic multi-config updates) + - Enables libraries to depend on abstractions without taking a dependency on the full configuration implementation +- `Cocoar.Configuration.Secrets.Abstractions` - Lightweight package containing secrets interfaces + - `ISecret` - Interface representing a secret value that can be opened + - `SecretLease` - Struct providing controlled access to secret values with automatic cleanup + - Enables code to work with secrets through interfaces for better testability and decoupling + - `ISecret` properties in configuration classes automatically deserialize to `Secret` instances + +**NEW: AllowPlaintext() for Secrets** +- New `AllowPlaintext()` fluent API to conditionally allow plaintext JSON values to be deserialized into `Secret` properties +- Useful for development and testing scenarios where encrypted envelopes are not available +- **SECURITY WARNING**: Only enable in development/test environments; production should always use encrypted envelopes +- Example: `.UseSecretsSetup(secrets => secrets.AllowPlaintext(builder.Environment.IsDevelopment()))` (v5.0+ syntax) + +**NEW: Testing Setup Overrides** +- Extended `CocoarTestConfiguration` to support setup overrides in addition to rule overrides + - New `WithSetup()` method for setup-only overrides (keeps original rules) + - Optional `setup` parameter added to `ReplaceAllRules()`, `AppendTestRules()`, and `Apply()` + - `TestConfigurationContext.Replace()` and `Append()` factory methods now accept optional setup parameter + - Enables test-time setup options like `setup.Secrets().AllowPlaintext()` without modifying application code + - Setup overrides are always merged (appended) to configured setup, using last-write-wins for capabilities + - See [Testing Overrides Documentation](website/guide/testing/overrides.md) for usage patterns + +### Fixed +- **Deserialization failure logging**: `GetConfig()` now logs an error (EventId 5100) when deserialization fails due to missing `required` properties or type mismatches, instead of silently returning `null`. This helps diagnose configuration issues while maintaining backward-compatible behavior (still returns `null`, `GetRequiredConfig()` still throws). +- **DI registration ordering**: Service descriptors are now emitted in deterministic order (sorted by type full name). Previously, dictionary iteration order could vary between runs, affecting test assertions and diagnostic logging. +- **Provider rebuild callback ordering**: Fixed callback timing in `RuleProviderLease` to invoke the subscription reset callback *before* rebuilding the provider, matching original `RuleManager` behavior. This prevents spurious change notifications that could occur if a provider's `Dispose()` triggers events while the subscription is still active. + +### Changed +- **Type Relocation with Forwarding**: Moved core interfaces to abstractions packages with type forwarding for full backward compatibility + - `IConfigurationAccessor` moved to `Cocoar.Configuration.Abstractions` + - `IReactiveConfig` moved to `Cocoar.Configuration.Abstractions` + - `SecretLease` moved to `Cocoar.Configuration.Secrets.Abstractions` + - Existing code continues to work unchanged via `[TypeForwardedTo]` attributes + +## [4.1.0] - 2026-01-11 + +### Fixed +- **Provider consistency bug**: Optional rules now consistently return empty objects with C# defaults when sources are unavailable, instead of inconsistently returning null. This fixes a bug where source-based providers (File, HTTP) behaved differently than collection-based providers (Environment, CommandLine). See [ADR-003](website/adr/ADR-003-provider-consistency-empty-objects.md) for details. + - All providers now return `{}` on failure, resulting in configuration objects with C# property defaults + - Failures are tracked via health monitoring with `Degraded` status + - Eliminates need for workarounds like adding fake `FromEnvironment()` rules + - `GetConfig()` never returns null when rules are defined for type T + - `GetRequiredConfig()` now explicitly checks for rule definitions (static check), not runtime availability + +### Changed +- Removed internal `include` flag from `RuleManager.ComputeAsync()` return type - now returns `ReadOnlyMemory?` where `null` means skip (When condition false), simplifying the API and making the decision point clearer in the recompute cycle + +## [4.0.0] - 2026-01-08 + +### Added + +**NEW: Testing Configuration Overrides** +- `CocoarTestConfiguration` API for overriding configuration in integration tests +- Two modes: `ReplaceAllRules()` (skip original rules) and `AppendTestRules()` (last-write-wins merging) +- Uses `AsyncLocal` for automatic test isolation across parallel tests +- Works universally with direct `ConfigManager` instantiation, DI, AspNetCore, and `WebApplicationFactory` +- Zero application code changes required - detection happens in ConfigManager constructors +- Comprehensive documentation in [Testing Overrides](website/guide/testing/overrides.md) +- Example project demonstrating usage patterns + +**NEW: Cocoar.Configuration.Secrets Package [Developer Preview]** +⚠️ **Developer Preview**: API may change in future releases based on feedback. Production-ready but subject to refinement. +- `Secret` type for type-safe secret handling with automatic memory zeroing +- Hybrid encryption using RSA key wrapping + AES-GCM for envelope-based secrets +- X.509 certificate-based encryption with **password-less certificates** (industry standard) + - Security model: File permissions (`chmod 600`) + full-disk encryption (BitLocker/LUKS/FileVault) + - Follows nginx, PostgreSQL, Kubernetes, Docker patterns + - No password bootstrapping problem +- Flexible folder-based key management with certificate inventory +- Support for key identifiers (kid) for multi-tenant scenarios +- Configurable certificate ordering and subdirectory search depth +- Seamless JSON deserialization support via custom converters +- Works with primitives, complex types, collections, and nested objects + [Developer Preview]** +⚠️ **Developer Preview**: CLI commands and options may evolve based on feedback. +**NEW: Cocoar.Configuration.Secrets.Cli Tool** +- Command-line tool for managing encrypted secrets in JSON configuration files +- **`generate-cert`**: Generate self-signed certificates (PFX or PEM format) + - Password-less by default (password optional for legacy compatibility) + - Smart format detection from file extension (`.pfx`, `.crt`, `.cer`, `.pem`) +- **`convert-cert`**: Convert between certificate formats and remove passwords + - Supports PFX↔PEM conversion with automatic format detection + - Output password optional - defaults to password-less (industry standard) + - Provides platform-specific file permission guidance + - Unified tool for format conversion and password removal +- **`cert-info`**: Display detailed certificate information + - Shows validity, key size, password status + - Detects password-protected vs password-less certificates + - Validates certificate thumbprints for conversion verification +- **`encrypt`**: Encrypt plaintext values in JSON files +- **`decrypt`**: Decrypt encrypted values from JSON files + +**NEW: Cocoar.Configuration.Analyzers Package** +- Roslyn analyzers for compile-time configuration validation +- **COCFG001**: Detects secret path conflicts (non-secret properties with same path as secret properties) +- **COCFG002**: Validates rule dependency ordering (prevents accessing config types not yet loaded) +- **COCFG003**: Warns about required rules referencing potentially missing resources +- **COCFG004**: Enforces type safety in configuration accessors +- **COCFG005**: Identifies duplicate unconditional rules for the same type +- **COCFG006**: Suggests optimal ordering for static/seed rules vs dynamic rules + + +### Changed + +**BREAKING: Provider contract refactored to use byte[] instead of JsonElement** +- `FetchConfigurationAsync` → `FetchConfigurationBytesAsync` (returns `byte[]`) +- `Changes` → `ChangesAsBytes` (emits `byte[]`) +- Improves performance by avoiding intermediate JsonElement conversions +- All built-in providers updated (File, Http, Environment, CommandLine, MicrosoftAdapter, etc.) +- Public ConfigManager API unchanged - byte[] conversion handled internally + +### Notes +- The provider contract change is internal - consuming applications are not affected +- **Secrets feature is in Developer Preview**: While production-ready, the API may evolve based on real-world usage and feedback +- Password-less certificates are the recommended approach (industry standard: nginx, PostgreSQL, Kubernetes, Docker) + +## [3.3.0] - 2025-10-23 + +### Added +- **Rule Naming**: New `.Named("name")` fluent API for adding human-readable names to rules + - Example: `rule.For().FromFile("db.json").Named("Primary Database")` + - Names appear in health snapshots for better observability in dashboards and logs + +- **Enhanced Health Monitoring**: Expanded `RuleHealthEntry` with additional metadata + - `Name` - Optional rule name (set via `.Named()`) + - `ProviderType` - Name of the provider type used by the rule + - `ConfigType` - Name of the configuration type being loaded + - `Skipped` status - New `RuleResultStatus.Skipped` for rules skipped by `.When()` conditions + +- **Health Summary Metrics**: New `Skipped` count in `Summary` for tracking conditional rules + - Complements existing `Total`, `RequiredFailed`, `OptionalFailed` counters + +- **Auto DI Registration**: `IConfigurationHealthService` now automatically registered in DI container + - Available immediately after `AddCocoarConfiguration()` + - No manual registration needed + +- **Comprehensive Health Tests**: 94 lines of new integration tests covering all health monitoring features + +### Improved +- **Health Documentation**: Simplified health-monitoring.md with clearer, direct usage examples + - Added Prometheus endpoint example (pull-based metrics) + - Added reactive subscription example (push-based metrics) + - Removed complex export system in favor of simple, direct `IConfigurationHealthService` access + +### Removed +- **Experimental Metrics Export APIs** (marked "Experimental / Untested" in v3.2.0): + - `HealthMetricsExporter` class - Unnecessary wrapper around health service + - `ISimpleHealthMetricsSink` interface - Over-engineered abstraction + - `HealthMetrics` struct - Redundant with `ConfigHealthSnapshot` + - `AddCocoarHealthMetricsExporter()` extension - Removed in favor of direct health service usage + - Users should access `IConfigurationHealthService.Snapshot` directly or subscribe to `SnapshotStream` + +## [3.2.0] - 2025-10-23 + +### Added +- **CommandLine Provider**: New built-in provider for parsing command-line arguments + - Supports multiple switch prefixes simultaneously: `["--", "-", "/"]` or custom semantic prefixes like `["@", "#", "%"]` + - Example: `.FromCommandLine(["--", "-", "/"])` accepts all three styles in the same command line + - Automatic longest-match-first algorithm prevents ambiguity (e.g., `--host` matches `"--"` before `"-"`) + - Flexible API: `.FromCommandLine()`, `.FromCommandLine("prefix_")`, `.FromCommandLine(["-"])` + - Supports nested configuration with `:` or `__` separators (e.g., `--database:host=localhost` or `--database__host=localhost`) + - Prefix filtering to map different arguments to different config types + - Three argument formats: `--key=value`, `--key value`, `--flag` (boolean) + - Enables self-documenting CLIs: `invoke.exe @host=server #issue=123 %env=prod` + +- **Test Example**: New CommandLineExample project with 6 comprehensive integration tests + +### Improved +- **Provider Architecture**: Enhanced ProviderOptions vs QueryOptions separation across CommandLine, Environment, and FileSource providers + - `ProviderOptions` now only contains provider-level configuration (shared across queries) + - `QueryOptions` contains query-specific parameters (different per rule) + - Enables proper provider sharing and rule-specific configuration + +### Removed +- **Dead Code Cleanup**: Removed 4 unused FileSystemObservable files (~140 lines) + - Existing `FileWatcherObservable` is used and battle-tested + + +## [3.1.1] - 2025-10-19 + +### Fixed +- **Enum String Conversion**: Added `JsonStringEnumConverter` to support deserializing enums from string values in JSON/environment variables + - Fixes issue where enum properties (e.g., `LogEventLevel.Debug`) would fail when provided as strings (e.g., `"Debug"`) + - Common scenario: Visual Studio setting `Logging={"LogLevel":{"Microsoft.AspNetCore.Watch":"Debug"}}` as environment variable + - Now supports case-insensitive string-to-enum conversion for all enum types + +## [3.1.0] - 2025-10-19 + +### Added +- **Interface Deserialization Support**: New `setup.Interface().DeserializeTo()` API for deserializing interface-typed properties in configuration classes + - Solves the problem where configuration classes with interface properties (e.g., `public ILoggingConfig Logging { get; set; }`) would fail deserialization from JSON sources + - Common scenario: Setting logging configuration via environment variables (e.g., `Logging__LogLevel__Default=Debug`) or when Visual Studio injects logging configuration for hot reload + - Supports deeply nested interface properties at any depth + - Example: `setup.Interface().DeserializeTo()` + - Includes comprehensive test coverage for nested and deeply nested scenarios + +### Usage Example +```csharp +builder.AddCocoarConfiguration(rule => [ + rule.For().FromEnvironment() +], setup => [ + setup.ConcreteType().ExposeAs(), + + // Map interface properties to concrete types + setup.Interface().DeserializeTo() +]); +``` + +## [3.0.0] + +### Added +- **Config-Aware Conditional Rules**: `.When()` method now receives `IConfigurationAccessor` parameter, allowing rules to be conditionally executed based on configuration from earlier rules + - Example: `rule.For().FromFile("premium.json").When(accessor => accessor.GetRequiredConfig().Tier == "Premium")` + - Enables powerful dynamic configuration scenarios (multi-tenant, environment-based, feature flags) +- **ConditionalRulesExample**: New example project demonstrating config-aware conditional rules + +### Changed +- **Type-First API**: Refactored rule builder API from Provider-First to Type-First pattern for better discoverability and type safety + - Old: `rule.File("...").For()` → New: `rule.For().FromFile("...")` + - All provider methods renamed: `File()` → `FromFile()`, `Environment()` → `FromEnvironment()`, etc. + - Consistent pattern across all providers (File, Environment, Static, Observable, HttpPolling, MicrosoftSource) + +### Breaking +- **Type-First API Changes** (⚠️ MAJOR): + - `rule.File(...)` → `rule.For().FromFile(...)` + - `rule.Environment(...)` → `rule.For().FromEnvironment(...)` + - `rule.StaticJson(...)` → `rule.For().FromStaticJson(...)` + - `rule.Static(...)` → `rule.For().FromStatic(...)` + - `rule.Observable(...)` → `rule.For().FromObservable(...)` + - `rule.HttpPolling(...)` → `rule.For().FromHttpPolling(...)` + - `rule.MicrosoftSource(...)` → `rule.For().FromMicrosoftSource(...)` + - `rule.FromProvider(...)` → `rule.For().FromProvider(...)` +- **When() Signature Change**: `.When(Func)` → `.When(Func)` + - Old: `rule.File("...").When(() => condition).For()` + - New: `rule.For().FromFile("...").When(_ => condition)` or with accessor: `.When(accessor => accessor.GetRequiredConfig().Property)` + +### Removed +- Removed test helper methods from production provider APIs (`CreateRule` methods in FileSourceProvider, StaticJsonProvider, ObservableProvider) + - These were internal test utilities mistakenly exposed in public API surface + +### Migration from v2.0 +```csharp +// v2.0 (Provider-First) +builder.AddCocoarConfiguration(rule => [ + rule.File("config.json").Select("App").For(), + rule.Environment("APP_").For(), + + // Conditional rule (old signature) + rule.File("premium.json") + .When(() => isPremium) + .For() +], setup => [ + setup.ConcreteType().ExposeAs() +]); + +// v3.0 (Type-First + Config-Aware When) +builder.AddCocoarConfiguration(rule => [ + rule.For().FromFile("config.json").Select("App"), + rule.For().FromEnvironment("APP_"), + + // Conditional rule (new signature with accessor) + rule.For().FromFile("premium.json") + .When(accessor => accessor.GetRequiredConfig().Tier == "Premium") +], setup => [ + setup.ConcreteType().ExposeAs() +]); +``` + +See [Migration Guide v2→v3](website/guide/migration/v2-to-v3.md) for detailed migration instructions. + +## [2.0.0] - 2025-09-30 + +### Changed +- **Builder API Modernization**: Replaced static `Rule.From.*` API with function-based `RulesBuilder` pattern (`rule => rule.File(...)`) for more intuitive configuration +- **Setup API Modernization**: Replaced `Bind.Type().To()` API with function-based `SetupBuilder` pattern (`setup => setup.ConcreteType().ExposeAs()`) with clearer naming +- Renamed "binding" terminology to "exposure" throughout the API and documentation for clarity + +### Breaking +- `Rule.From.File()`, `Rule.From.Environment()`, etc. replaced with `RulesBuilder` lambda parameter: `builder.AddCocoarConfiguration(rule => [rule.File(...), rule.Environment(...)])` +- `Bind.Type().To()` replaced with `SetupBuilder` lambda parameter: `setup => setup.ConcreteType().ExposeAs()` +- `ServiceRegistrationOptions` and `Register.Add()` replaced with direct lifetime configuration on `ConcreteTypeSetup` + +### Migration from v1.x +```csharp +// v1.x +builder.AddCocoarConfiguration([ + Rule.From.File("config.json").Select("App").For(), + Rule.From.Environment("APP_").For() +], [ + Bind.Type().To() +]); + +// v2.0 +builder.AddCocoarConfiguration(rule => [ + rule.File("config.json").Select("App").For(), + rule.Environment("APP_").For() +], setup => [ + setup.ConcreteType().ExposeAs() +]); +``` + +## [1.1.0] - 2025-09-25 + +`IReactiveConfig` now supports tuples as the generic type, e.g. +`IReactiveConfig<(AppSettings, DbSettings)>`. +When used with a tuple, all element types are recomputed and emitted atomically in the same pass. +This guarantees you never see a mix of old and new values across different configs. + +## [1.0.0] - 2025-09-21 + +### 🎉 First Stable Release + +This marks the first stable release of Cocoar.Configuration with production-ready features and comprehensive testing infrastructure. + +### Added +- **Comprehensive Test Suite**: **204 automated tests** covering core functionality, providers, edge cases, and stress scenarios +- **Health Monitoring System**: Complete health monitoring with `IConfigurationHealthService`, health snapshots, and experimental metrics export hooks +- **Reactive Configuration**: Auto-registered `IReactiveConfig` for every configuration type in dependency injection +- **Enhanced Documentation**: + - Streamlined README with practical examples and accurate feature descriptions + - Dedicated health monitoring guide (`website/guide/health/overview.md`) + - Reactive configuration guide (`website/guide/reactive/basics.md`) + +### Testing Improvements +- **Integration Tests**: Multi-provider composition, rule ordering, recompute pipeline validation +- **Stress & Performance Tests**: High-frequency changes, large JSON handling, concurrent access scenarios +- **Provider Battle Tests**: HTTP headers/caching, Microsoft adapter integration, environment variable edge cases +- **Health Pipeline Tests**: Status derivation, recovery scenarios, observable health updates +- **Fuzz Testing**: Random change sequences maintaining correctness and deterministic results +- **File Provider Resilience**: FileSystemWatcher ↔ polling fallback and automatic recovery testing + +### Enhanced +- **File Provider**: Improved resilience with automatic polling fallback when FileSystemWatcher fails +- **Test Organization**: Restructured test projects with clear separation (Core.Tests, Providers.Tests) +- **Example Projects**: Updated all 12 example projects with improved clarity and modern patterns +- **Documentation Structure**: Consolidated scattered documentation into focused, practical guides + +### Quality & Reliability +- **204 comprehensive tests** ensuring stability across all components and failure scenarios +- **Production-tested patterns** validated through extensive integration and stress testing +- **Error-resilient implementations** with proper failure handling and recovery mechanisms +- **Continuous integration** ensuring every change maintains stability and correctness + +## [0.15.0] - 2025-09-18 + +### Fixed +- **FileSystemWatcher File Locking:** Fixed file locking conflicts in FileSourceProvider by implementing proper file sharing (FileShare.ReadWrite) to prevent missed configuration changes and IOException conflicts in production scenarios. +- **Testing Anti-Patterns:** Eliminated flaky test behavior by removing emission counting anti-patterns and implementing proper final state validation in reactive configuration tests. + +### Changed +- Enhanced test organization with better naming conventions and regional structuring for improved maintainability. +- Implemented active waiting patterns and controllable testing infrastructure (ObservableProvider) for reliable FileSystemWatcher testing. +- Added comprehensive stress testing suite with multi-iteration reliability validation. + +## [0.14.0] - 2025-09-17 + +### Added +- StaticJsonProvider now supports JSON strings directly via `Rule.From.StaticJson(jsonString)`. + +### Changed +- StaticJsonProvider instances are no longer shared between rules (null-key pattern) to prevent configuration data leakage between different rules. + +## [0.13.0] - 2025-09-17 + +### Changed +- License changed from MIT to Apache-2.0 to provide an explicit patent grant and consistent downstream attribution via `NOTICE`. Previous released versions remain under MIT. + +## [0.12.0] - 2025-09-17 + +### Added +- Reactive configuration channel: automatic `IReactiveConfig` for every config type (singleton, hash-gated, error-resilient). +- Auto DI registration for `IReactiveConfig` (opt-out via `DisableAutoReactiveRegistration`). + +### Performance +- Streaming JSON → MD5 hashing pipeline for selection & emission gating (reduced allocations, faster change detection). +- Partial recompute optimizations (earliest changed rule restart) documented and hardened. + +### Documentation +- README overhaul: simplified quick start, lifetimes, reactive defaults, full examples table with direct links. +- Added `DEEP_DIVE.md` (advanced scenarios, tuning, dynamic dependencies). +- Updated architecture doc: streaming MD5 hashing + reactive channel section. +- Clarified partial recompute & reactive channel in Concepts; added migration note for merged reactive implementation. +- Converted inline doc code references to clickable links across docs & examples. + +## [0.11.1] - 2025-09-16 + +- Refactor configuration options to set default 'Required' value to false and remove unnecessary 'Optional' calls in tests +- Add DI Options for Cocoar.Configuration.AspNetCore - AddCocoarConfiguration + +## [0.11.0] - 2025-09-16 + +### Added +- New Binding System (`Bind.Type().To()`) enabling interface mapping independent of DI. +- `BindingRegistry` and runtime validation for interface→concrete compatibility. +- `Cocoar.Configuration.DI` package: separation of concerns between interface binding and DI registration. +- `ServiceRegistrationOptions.DefaultRegistrationLifetime(null)` to fully disable auto-registration. +- Keyed service registration refinement via explicit `options.Register.Add(lifetime, key)` model. +- Comprehensive documentation: [docs/BINDING.md](docs/BINDING.md), updated README Binding vs DI Registration section. + +### Changed +- Auto-registration default clarified (Scoped) and now explicitly optional. +- Examples reorganized: added dedicated Binding, DI, ServiceLifetimes demos; README minimized to one-liners. + +### Internal / Quality +- Expanded test coverage for service lifetimes, keyed registrations, binding validation. +- Documentation cleanup removing unreleased prototype terminology. + +### Migration Notes +- For consumers of 0.9.x: follow 0.10.0 migration first (selection API changes), then optionally adopt bindings (purely additive). + + +## [0.10.0] - 2025-09-15 + +### Breaking +- Removed query-level `configurationPath` and `targetPath`; use rule-level `.Select(...)` and `.MountAt(...)`. + +### Highlights +- Rule Selection Simplification: centralized rule-level selection (`.Select`) clarifies intent and simplifies providers. +- Incremental Recompute Engine: recompute only from earliest changed rule; unchanged prefix reused from cached flattened contributions. +- Change Gating & Noise Reduction: selection-hash gating skips recompute when the selected subtree is unchanged. +- Fast Cancellation & Coalescing: mid-pass cancellation plus immediate + trailing debounce collapses bursts without added latency for first change. +- Coalescer Extraction: new `RecomputeCoalescer` class isolates change accumulation & timing logic. +- Deletion Propagation: removed keys from updated rule contributions are pruned from final configs (no stale "zombie" keys). +- Snapshot Model Simplification: dropped merged/unflattened snapshot storage; now only per-rule flattened contribution + selection hash. +- Static Rule Set Decision: rules immutable post-initialization (use `UseWhen` to conditionally participate). +- Provider & Query Cleanup: normalized option/query shapes; file examples updated to selection chaining. +- Test Suite Expansion: new tests for partial recompute, cancellation, selection hash gating, key deletions; added explanatory headers. +- Performance Foundations: hashing + earliest-index logic lays groundwork for future benchmarks. +- Documentation & Examples Refresh: updated architecture & provider docs to Fetch → Select → Mount → Merge; removed legacy two-argument file section usage. +- API Surface Cleanup: removed obsolete params & helpers; pruned unused hash utilities. +- Reliability & Determinism: provider injection seam enables deterministic tests; strengthened dynamic dependency scenarios. +- Logging & Error Handling Consistency: resilient change-trigger error handling while preserving required/optional semantics. + +### Migration Summary +```diff +- Rule.From.File(_ => FileSourceRuleOptions.FromFilePath("appsettings.json", configurationPath: "A:B")).For().Build(); ++ Rule.From.File("appsettings.json").Select("A:B").For().Build(); + +- Rule.From.HttpPolling(_ => HttpPollingRuleOptions.FromPath("service.json", configurationPath: "Service")).For().Build(); ++ Rule.From.HttpPolling("service.json").Select("Service").For().Build(); + +- Rule.From.File(_ => FileSourceRuleOptions.FromFilePath("base.json", targetPath: "Config:Base"))... ++ Rule.From.File("base.json").MountAt("Config:Base")... +``` + + +## [0.9.2] - 2025-09-15 + +- Added: Concise overloads Rule.From.File(...), Rule.From.Environment(...). +- Added: .MountAt fluent API for rule mounting. +- Migration: Replace targetPath: "A:B" with .MountAt("A:B"). + +## [0.9.1] - 2025-09-14 +Branding / assets update. + +- Replaced NuGet package icon (`package-icon.png`). +- Updated README image. +- Updated GitHub social preview images (`social-preview.png`, `social-preview-small.png`) stored at repo root. +- No functional/code changes. + +## [0.9.0] - 2025-09-14 +Initial release 🎉 + +- Deterministic ordered configuration layering (last-write-wins) +- Strongly typed DI (no IOptions) +- Providers: File, Environment, Static, HTTP Polling, Microsoft Adapter +- Dynamic rule factories & atomic snapshot recompute +- DI lifetimes & keyed registrations +- Examples included under `src/Examples/` + + diff --git a/GitVersion.yml b/GitVersion.yml index 02a98ef..f45528a 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,31 +1,31 @@ -mode: ContinuousDeployment - -branches: - develop: - regex: ^develop$ - label: beta - is-main-branch: true - - release: - regex: ^release[/-] - label: rc - - feature: - regex: ^(feature|bugfix|fix)[/-](?.+) - label: '{BranchName}' - - hotfix: - regex: ^hotfix[/-] - label: hotfix - - pull-request: - label: pr - - other: - regex: .* - label: alpha - -tag-prefix: 'v' -commit-message-incrementing: Enabled -ignore: - sha: [] +mode: ContinuousDeployment + +branches: + develop: + regex: ^develop$ + label: beta + is-main-branch: true + + release: + regex: ^release[/-] + label: rc + + feature: + regex: ^(feature|bugfix|fix)[/-](?.+) + label: '{BranchName}' + + hotfix: + regex: ^hotfix[/-] + label: hotfix + + pull-request: + label: pr + + other: + regex: .* + label: alpha + +tag-prefix: 'v' +commit-message-incrementing: Enabled +ignore: + sha: [] diff --git a/LICENSE b/LICENSE index 3539cca..108a986 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,201 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2025 COCOAR e.U. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 COCOAR e.U. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE index 4240691..213c5cd 100644 --- a/NOTICE +++ b/NOTICE @@ -1,16 +1,16 @@ -Cocoar.Configuration -Copyright (c) 2025 COCOAR e.U. - -Powerful layered configuration for .NET -Simple • Strongly typed • Reactive - -Licensed under the Apache License, Version 2.0 (the "License"); -You may not use this project except in compliance with the License. -You may obtain a copy at: http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - -Third-Party Notices: -(None at this time) +Cocoar.Configuration +Copyright (c) 2025 COCOAR e.U. + +Powerful layered configuration for .NET +Simple • Strongly typed • Reactive + +Licensed under the Apache License, Version 2.0 (the "License"); +You may not use this project except in compliance with the License. +You may obtain a copy at: http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +Third-Party Notices: +(None at this time) diff --git a/SECURITY.md b/SECURITY.md index 8fceb55..b1865b9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,13 +1,13 @@ -# Security Policy - -## Supported versions -The latest main branch and the most recent release are supported for security fixes. - -## Reporting a vulnerability -Please do not open public issues for potential vulnerabilities. Instead: -- Email: bwi@cocoar.dev (or use your private channel if you have one) -- Include a minimal reproduction, impact assessment, and affected versions/commits. -- We aim to acknowledge reports within 72 hours. - -## Disclosure +# Security Policy + +## Supported versions +The latest main branch and the most recent release are supported for security fixes. + +## Reporting a vulnerability +Please do not open public issues for potential vulnerabilities. Instead: +- Email: bwi@cocoar.dev (or use your private channel if you have one) +- Include a minimal reproduction, impact assessment, and affected versions/commits. +- We aim to acknowledge reports within 72 hours. + +## Disclosure We prefer coordinated disclosure. After a fix is available, we’ll publish release notes with mitigation guidance. \ No newline at end of file diff --git a/TRADEMARKS.md b/TRADEMARKS.md index 6ec7a66..2db9684 100644 --- a/TRADEMARKS.md +++ b/TRADEMARKS.md @@ -1,23 +1,23 @@ -# Trademarks - -“Cocoar”, “Cocoar.Configuration”, the Cocoar logo, and related marks are trademarks of COCOAR e.U. - -## Permitted Use -You may: -- Factually state that your project “uses” or “is built with” Cocoar.Configuration. -- Link to the official repository, documentation, or website. -- Use unmodified logos/screenshots in comparative or educational material. - -## Restricted Use -You may not: -- Present Cocoar or Cocoar.Configuration as if it endorses your fork, distribution, or service. -- Use the Cocoar name or logo as the primary branding of a derivative product. -- Modify the Cocoar logo and present it as an official mark. - -## Naming Forks / Derivatives -Forks should use a name that avoids confusion. - -## Questions / Permissions -For partnership, commercial use of branding beyond the above, or clarifications, contact: bwi@cocoar.dev (replace with your actual email when ready). - -COCOAR e.U. reserves all rights not expressly granted here. +# Trademarks + +“Cocoar”, “Cocoar.Configuration”, the Cocoar logo, and related marks are trademarks of COCOAR e.U. + +## Permitted Use +You may: +- Factually state that your project “uses” or “is built with” Cocoar.Configuration. +- Link to the official repository, documentation, or website. +- Use unmodified logos/screenshots in comparative or educational material. + +## Restricted Use +You may not: +- Present Cocoar or Cocoar.Configuration as if it endorses your fork, distribution, or service. +- Use the Cocoar name or logo as the primary branding of a derivative product. +- Modify the Cocoar logo and present it as an official mark. + +## Naming Forks / Derivatives +Forks should use a name that avoids confusion. + +## Questions / Permissions +For partnership, commercial use of branding beyond the above, or clarifications, contact: bwi@cocoar.dev (replace with your actual email when ready). + +COCOAR e.U. reserves all rights not expressly granted here. diff --git a/src/Cocoar.Configuration.Abstractions/Cocoar.Configuration.Abstractions.csproj b/src/Cocoar.Configuration.Abstractions/Cocoar.Configuration.Abstractions.csproj index 92622a8..b8b164c 100644 --- a/src/Cocoar.Configuration.Abstractions/Cocoar.Configuration.Abstractions.csproj +++ b/src/Cocoar.Configuration.Abstractions/Cocoar.Configuration.Abstractions.csproj @@ -1,11 +1,11 @@ - - - - true - Abstractions for Cocoar.Configuration. Reference this package to use IConfigurationAccessor and IReactiveConfig in library APIs without the full implementation. - configuration;reactive;dotnet;abstractions;interfaces - - - - - + + + + true + Abstractions for Cocoar.Configuration. Reference this package to use IConfigurationAccessor and IReactiveConfig in library APIs without the full implementation. + configuration;reactive;dotnet;abstractions;interfaces + + + + + diff --git a/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs b/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs index 647f351..9b088b3 100644 --- a/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs +++ b/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs @@ -1,80 +1,80 @@ -using System.Text.Json; - -namespace Cocoar.Configuration.Core; - -/// -/// Provides access to configuration snapshots. -/// Used by rule factories to reference earlier configuration state when building dependent rules. -/// -/// -/// -/// DO NOT mutate configuration instances. All GetConfig methods return cached, -/// shared instances. Mutations would affect all consumers and cause inconsistent behavior. -/// -/// -public interface IConfigurationAccessor -{ - /// - /// Gets a configuration instance from the cached snapshot. - /// - /// - /// After initialization completes, this method returns the cached instance for registered types. - /// Throws if no rule is registered for the type. - /// DO NOT mutate the returned instance. - /// - /// No configuration rule is registered for type T. - T? GetConfig() where T : class; - - /// - /// Tries to get a configuration instance without throwing. - /// - /// True if configuration exists for the type; false otherwise. - bool TryGetConfig(out T? value) where T : class; - - /// - /// Gets configuration, throwing if not found. - /// - /// - /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. - /// - [Obsolete("Use GetConfig() instead - it now throws if no rule is registered. " + - "This method will be removed in a future version.")] - T GetRequiredConfig(); - - /// - /// Gets a configuration instance from the cached snapshot. - /// - /// - /// After initialization completes, this method returns the cached instance for registered types. - /// Throws if no rule is registered for the type. - /// DO NOT mutate the returned instance. - /// - /// No configuration rule is registered for the type. - object GetConfig(Type type); - - /// - /// Tries to get a configuration instance without throwing. - /// - /// True if configuration exists for the type; false otherwise. - bool TryGetConfig(Type type, out object? value); - - /// - /// Gets configuration, throwing if not found. - /// - /// - /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. - /// - [Obsolete("Use GetConfig(Type) instead - it now throws if no rule is registered. " + - "This method will be removed in a future version.")] - object GetRequiredConfig(Type type); - - JsonElement? GetConfigAsJson(Type type); - - /// - /// The tenant this accessor resolves configuration for, or null in the global (tenant-agnostic) pipeline. - /// Tenant-scoped rules (.TenantScoped()) are skipped when this is null/empty; tenant-varying rule - /// factories interpolate it (e.g. FromFile(a => $"db.{a.Tenant}.json")). - /// - /// Default interface member (returns null) so existing implementers are not broken. - string? Tenant => null; -} +using System.Text.Json; + +namespace Cocoar.Configuration.Core; + +/// +/// Provides access to configuration snapshots. +/// Used by rule factories to reference earlier configuration state when building dependent rules. +/// +/// +/// +/// DO NOT mutate configuration instances. All GetConfig methods return cached, +/// shared instances. Mutations would affect all consumers and cause inconsistent behavior. +/// +/// +public interface IConfigurationAccessor +{ + /// + /// Gets a configuration instance from the cached snapshot. + /// + /// + /// After initialization completes, this method returns the cached instance for registered types. + /// Throws if no rule is registered for the type. + /// DO NOT mutate the returned instance. + /// + /// No configuration rule is registered for type T. + T? GetConfig() where T : class; + + /// + /// Tries to get a configuration instance without throwing. + /// + /// True if configuration exists for the type; false otherwise. + bool TryGetConfig(out T? value) where T : class; + + /// + /// Gets configuration, throwing if not found. + /// + /// + /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. + /// + [Obsolete("Use GetConfig() instead - it now throws if no rule is registered. " + + "This method will be removed in a future version.")] + T GetRequiredConfig(); + + /// + /// Gets a configuration instance from the cached snapshot. + /// + /// + /// After initialization completes, this method returns the cached instance for registered types. + /// Throws if no rule is registered for the type. + /// DO NOT mutate the returned instance. + /// + /// No configuration rule is registered for the type. + object GetConfig(Type type); + + /// + /// Tries to get a configuration instance without throwing. + /// + /// True if configuration exists for the type; false otherwise. + bool TryGetConfig(Type type, out object? value); + + /// + /// Gets configuration, throwing if not found. + /// + /// + /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. + /// + [Obsolete("Use GetConfig(Type) instead - it now throws if no rule is registered. " + + "This method will be removed in a future version.")] + object GetRequiredConfig(Type type); + + JsonElement? GetConfigAsJson(Type type); + + /// + /// The tenant this accessor resolves configuration for, or null in the global (tenant-agnostic) pipeline. + /// Tenant-scoped rules (.TenantScoped()) are skipped when this is null/empty; tenant-varying rule + /// factories interpolate it (e.g. FromFile(a => $"db.{a.Tenant}.json")). + /// + /// Default interface member (returns null) so existing implementers are not broken. + string? Tenant => null; +} diff --git a/src/Cocoar.Configuration.Abstractions/ISecret.cs b/src/Cocoar.Configuration.Abstractions/ISecret.cs index 412f17c..5a5f957 100644 --- a/src/Cocoar.Configuration.Abstractions/ISecret.cs +++ b/src/Cocoar.Configuration.Abstractions/ISecret.cs @@ -1,17 +1,17 @@ -namespace Cocoar.Configuration.Secrets.SecretTypes; - -/// -/// Represents a secret value that can be opened to access its contents. -/// The secret value is protected and only revealed when is called. -/// -/// The type of the secret value. -public interface ISecret : IDisposable -{ - /// - /// Opens the secret and returns a lease to access the decrypted value. - /// The returned lease should be disposed when the value is no longer needed - /// to allow secure cleanup of sensitive data in memory. - /// - /// A lease containing the decrypted secret value. - SecretLease Open(); -} +namespace Cocoar.Configuration.Secrets.SecretTypes; + +/// +/// Represents a secret value that can be opened to access its contents. +/// The secret value is protected and only revealed when is called. +/// +/// The type of the secret value. +public interface ISecret : IDisposable +{ + /// + /// Opens the secret and returns a lease to access the decrypted value. + /// The returned lease should be disposed when the value is no longer needed + /// to allow secure cleanup of sensitive data in memory. + /// + /// A lease containing the decrypted secret value. + SecretLease Open(); +} diff --git a/src/Cocoar.Configuration.Abstractions/Reactive/IReactiveConfig.cs b/src/Cocoar.Configuration.Abstractions/Reactive/IReactiveConfig.cs index 2a9bf50..906d687 100644 --- a/src/Cocoar.Configuration.Abstractions/Reactive/IReactiveConfig.cs +++ b/src/Cocoar.Configuration.Abstractions/Reactive/IReactiveConfig.cs @@ -1,20 +1,20 @@ -namespace Cocoar.Configuration.Reactive; - -/// -/// Provides reactive access to configuration snapshots. -/// Emits new values whenever the underlying configuration changes, after debouncing and validation. -/// -/// -/// Implements BehaviorSubject / replay-1 semantics: calling -/// immediately emits the current configuration value to the new subscriber, so subscribers never -/// miss the initial state regardless of when they subscribe. -/// -public interface IReactiveConfig : IObservable -{ - /// - /// Gets the most recent configuration snapshot. - /// Always reflects the last emitted value; never null after initialization completes. - /// Safe to call at any time - will not throw if configuration is temporarily unavailable. - /// - T CurrentValue { get; } -} +namespace Cocoar.Configuration.Reactive; + +/// +/// Provides reactive access to configuration snapshots. +/// Emits new values whenever the underlying configuration changes, after debouncing and validation. +/// +/// +/// Implements BehaviorSubject / replay-1 semantics: calling +/// immediately emits the current configuration value to the new subscriber, so subscribers never +/// miss the initial state regardless of when they subscribe. +/// +public interface IReactiveConfig : IObservable +{ + /// + /// Gets the most recent configuration snapshot. + /// Always reflects the last emitted value; never null after initialization completes. + /// Safe to call at any time - will not throw if configuration is temporarily unavailable. + /// + T CurrentValue { get; } +} diff --git a/src/Cocoar.Configuration.Abstractions/SecretLease.cs b/src/Cocoar.Configuration.Abstractions/SecretLease.cs index 9d836af..fcee5bb 100644 --- a/src/Cocoar.Configuration.Abstractions/SecretLease.cs +++ b/src/Cocoar.Configuration.Abstractions/SecretLease.cs @@ -1,36 +1,36 @@ -namespace Cocoar.Configuration.Secrets.SecretTypes; - -/// -/// A lease that provides access to a secret value. -/// When disposed, the secret value is securely zeroed from memory. -/// -/// The type of the secret value. -public readonly struct SecretLease : IDisposable -{ - /// - /// Gets the secret value. - /// - public T Value { get; } - - private readonly Action? _onDispose; - - /// - /// Creates a new secret lease with the specified value and optional cleanup action. - /// - /// The secret value. - /// Action to invoke when the lease is disposed (typically zeroes memory). - public SecretLease(T value, Action? onDispose) - { - Value = value; - _onDispose = onDispose; - } - - /// - /// Disposes the lease, invoking the cleanup action to zero sensitive data. - /// - public void Dispose() - { - try { _onDispose?.Invoke(); } - catch { /* best-effort zeroization */ } - } -} +namespace Cocoar.Configuration.Secrets.SecretTypes; + +/// +/// A lease that provides access to a secret value. +/// When disposed, the secret value is securely zeroed from memory. +/// +/// The type of the secret value. +public readonly struct SecretLease : IDisposable +{ + /// + /// Gets the secret value. + /// + public T Value { get; } + + private readonly Action? _onDispose; + + /// + /// Creates a new secret lease with the specified value and optional cleanup action. + /// + /// The secret value. + /// Action to invoke when the lease is disposed (typically zeroes memory). + public SecretLease(T value, Action? onDispose) + { + Value = value; + _onDispose = onDispose; + } + + /// + /// Disposes the lease, invoking the cleanup action to zero sensitive data. + /// + public void Dispose() + { + try { _onDispose?.Invoke(); } + catch { /* best-effort zeroization */ } + } +} diff --git a/src/Cocoar.Configuration.Analyzers/AnalyzerReleases.Shipped.md b/src/Cocoar.Configuration.Analyzers/AnalyzerReleases.Shipped.md index 64569bb..74d1c89 100644 --- a/src/Cocoar.Configuration.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/Cocoar.Configuration.Analyzers/AnalyzerReleases.Shipped.md @@ -1,14 +1,14 @@ -; Shipped analyzer releases -; See https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md - -## Release 3.0.0 - -### New Rules -Rule ID | Category | Severity | Notes ---------|----------|----------|-------------------- -COCFG001 | Cocoar.Configuration | Warning | Secret path conflict detected -COCFG002 | Cocoar.Configuration | Error | Rule dependency ordering violation -COCFG003 | Cocoar.Configuration | Warning | Required rule configuration validation -COCFG004 | Cocoar.Configuration | Error | Configuration accessor type safety violation -COCFG005 | Cocoar.Configuration | Info | Duplicate unconditional rules detected -COCFG006 | Cocoar.Configuration | Info | Static provider ordering suggestion +; Shipped analyzer releases +; See https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +## Release 3.0.0 + +### New Rules +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +COCFG001 | Cocoar.Configuration | Warning | Secret path conflict detected +COCFG002 | Cocoar.Configuration | Error | Rule dependency ordering violation +COCFG003 | Cocoar.Configuration | Warning | Required rule configuration validation +COCFG004 | Cocoar.Configuration | Error | Configuration accessor type safety violation +COCFG005 | Cocoar.Configuration | Info | Duplicate unconditional rules detected +COCFG006 | Cocoar.Configuration | Info | Static provider ordering suggestion diff --git a/src/Cocoar.Configuration.Analyzers/AnalyzerReleases.Unshipped.md b/src/Cocoar.Configuration.Analyzers/AnalyzerReleases.Unshipped.md index 6a19803..6f2a82d 100644 --- a/src/Cocoar.Configuration.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Cocoar.Configuration.Analyzers/AnalyzerReleases.Unshipped.md @@ -1,13 +1,13 @@ -### New Rules - -Rule ID | Category | Severity | Notes ---------|----------|----------|------- -COCFLAG001 | CocoarFlags | Warning | Non-static ExpiresAt -COCFLAG002 | CocoarFlags | Warning | Abstract type registered -COCFLAG003 | CocoarFlags | Info | Missing flag/entitlement description - -### Removed Rules - -Rule ID | Category | Severity | Notes ---------|----------|----------|------- -COCFG004 | Cocoar.Configuration | Error | Replaced by `where T : class` constraint on TypedRuleBuilder +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +COCFLAG001 | CocoarFlags | Warning | Non-static ExpiresAt +COCFLAG002 | CocoarFlags | Warning | Abstract type registered +COCFLAG003 | CocoarFlags | Info | Missing flag/entitlement description + +### Removed Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +COCFG004 | Cocoar.Configuration | Error | Replaced by `where T : class` constraint on TypedRuleBuilder diff --git a/src/Cocoar.Configuration.Analyzers/Analyzers/DuplicateUnconditionalRulesAnalyzer.cs b/src/Cocoar.Configuration.Analyzers/Analyzers/DuplicateUnconditionalRulesAnalyzer.cs index 95bc2a0..6d719cc 100644 --- a/src/Cocoar.Configuration.Analyzers/Analyzers/DuplicateUnconditionalRulesAnalyzer.cs +++ b/src/Cocoar.Configuration.Analyzers/Analyzers/DuplicateUnconditionalRulesAnalyzer.cs @@ -1,171 +1,171 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Cocoar.Configuration.Analyzers.Analyzers; - -/// -/// Analyzer that detects multiple unconditional rules for the same configuration type. -/// Warns that only the last rule will be effective (last write wins). -/// -[DiagnosticAnalyzer(LanguageNames.CSharp)] -public class DuplicateUnconditionalRulesAnalyzer : DiagnosticAnalyzer -{ - public override ImmutableArray SupportedDiagnostics => - ImmutableArray.Create(DiagnosticDescriptors.DuplicateUnconditionalRules); - - public override void Initialize(AnalysisContext context) - { - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.EnableConcurrentExecution(); - - context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); - } - - private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) - { - var invocation = (InvocationExpressionSyntax)context.Node; - - if (!IsAddCocoarConfigurationCall(invocation, context.SemanticModel)) - { - return; - } - - // Extract rules from lambda - var lambdas = invocation.DescendantNodes().OfType(); - foreach (var lambda in lambdas) - { - AnalyzeDuplicates(lambda, context); - } - } - - private static bool IsAddCocoarConfigurationCall(InvocationExpressionSyntax invocation, SemanticModel semanticModel) - { - var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; - if (memberAccess == null) - { - return false; - } - - var methodSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol as IMethodSymbol; - return methodSymbol?.Name == "AddCocoarConfiguration"; - } - - private static void AnalyzeDuplicates(SimpleLambdaExpressionSyntax lambda, SyntaxNodeAnalysisContext context) - { - // Group rules by configuration type - var rulesByType = new Dictionary>(); - - var invocations = lambda.DescendantNodes().OfType(); - - foreach (var inv in invocations) - { - var ruleInfo = ExtractRuleInfo(inv, context.SemanticModel); - if (ruleInfo == null) - { - continue; - } - - var typeName = ruleInfo.ConfigurationType.ToDisplayString(); - if (!rulesByType.TryGetValue(typeName, out var list)) - { - list = new List(); - rulesByType[typeName] = list; - } - - list.Add(ruleInfo); - } - - // Check each type for duplicate unconditional rules - foreach (var (typeName, rules) in rulesByType) - { - if (rules.Count <= 1) - { - continue; // No duplicates - } - - // Count unconditional rules (no .When() clause) - var unconditionalRules = rules.Where(r => !r.HasCondition).ToList(); - - if (unconditionalRules.Count > 1) - { - // Report diagnostic on all but the last (which will win) - for (int i = 0; i < unconditionalRules.Count - 1; i++) - { - var rule = unconditionalRules[i]; - var diagnostic = Diagnostic.Create( - DiagnosticDescriptors.DuplicateUnconditionalRules, - rule.Location, - rule.ConfigurationType.Name); - - context.ReportDiagnostic(diagnostic); - } - } - } - } - - private static RuleInfo? ExtractRuleInfo(InvocationExpressionSyntax invocation, SemanticModel semanticModel) - { - // Look for .For() pattern - var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; - if (memberAccess?.Name.Identifier.Text != "For") - { - return null; - } - - if (memberAccess.Name is not GenericNameSyntax genericName) - { - return null; - } - - var typeArg = genericName.TypeArgumentList.Arguments.FirstOrDefault(); - if (typeArg == null) - { - return null; - } - - var typeInfo = semanticModel.GetTypeInfo(typeArg); - if (typeInfo.Type == null) - { - return null; - } - - // Check if rule has .When() condition - bool hasCondition = HasWhenCondition(invocation); - - return new RuleInfo - { - ConfigurationType = typeInfo.Type, - HasCondition = hasCondition, - Location = invocation.GetLocation() - }; - } - - private static bool HasWhenCondition(InvocationExpressionSyntax invocation) - { - // Traverse up the chain looking for .When() - var parent = invocation.Parent; - while (parent != null) - { - if (parent is InvocationExpressionSyntax parentInv) - { - var memberAccess = parentInv.Expression as MemberAccessExpressionSyntax; - if (memberAccess?.Name.Identifier.Text == "When") - { - return true; - } - } - parent = parent.Parent; - } - return false; - } - - private class RuleInfo - { - public required ITypeSymbol ConfigurationType { get; init; } - public required bool HasCondition { get; init; } - public required Location Location { get; init; } - } -} +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Cocoar.Configuration.Analyzers.Analyzers; + +/// +/// Analyzer that detects multiple unconditional rules for the same configuration type. +/// Warns that only the last rule will be effective (last write wins). +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class DuplicateUnconditionalRulesAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.DuplicateUnconditionalRules); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + if (!IsAddCocoarConfigurationCall(invocation, context.SemanticModel)) + { + return; + } + + // Extract rules from lambda + var lambdas = invocation.DescendantNodes().OfType(); + foreach (var lambda in lambdas) + { + AnalyzeDuplicates(lambda, context); + } + } + + private static bool IsAddCocoarConfigurationCall(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; + if (memberAccess == null) + { + return false; + } + + var methodSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol as IMethodSymbol; + return methodSymbol?.Name == "AddCocoarConfiguration"; + } + + private static void AnalyzeDuplicates(SimpleLambdaExpressionSyntax lambda, SyntaxNodeAnalysisContext context) + { + // Group rules by configuration type + var rulesByType = new Dictionary>(); + + var invocations = lambda.DescendantNodes().OfType(); + + foreach (var inv in invocations) + { + var ruleInfo = ExtractRuleInfo(inv, context.SemanticModel); + if (ruleInfo == null) + { + continue; + } + + var typeName = ruleInfo.ConfigurationType.ToDisplayString(); + if (!rulesByType.TryGetValue(typeName, out var list)) + { + list = new List(); + rulesByType[typeName] = list; + } + + list.Add(ruleInfo); + } + + // Check each type for duplicate unconditional rules + foreach (var (typeName, rules) in rulesByType) + { + if (rules.Count <= 1) + { + continue; // No duplicates + } + + // Count unconditional rules (no .When() clause) + var unconditionalRules = rules.Where(r => !r.HasCondition).ToList(); + + if (unconditionalRules.Count > 1) + { + // Report diagnostic on all but the last (which will win) + for (int i = 0; i < unconditionalRules.Count - 1; i++) + { + var rule = unconditionalRules[i]; + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.DuplicateUnconditionalRules, + rule.Location, + rule.ConfigurationType.Name); + + context.ReportDiagnostic(diagnostic); + } + } + } + } + + private static RuleInfo? ExtractRuleInfo(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + // Look for .For() pattern + var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; + if (memberAccess?.Name.Identifier.Text != "For") + { + return null; + } + + if (memberAccess.Name is not GenericNameSyntax genericName) + { + return null; + } + + var typeArg = genericName.TypeArgumentList.Arguments.FirstOrDefault(); + if (typeArg == null) + { + return null; + } + + var typeInfo = semanticModel.GetTypeInfo(typeArg); + if (typeInfo.Type == null) + { + return null; + } + + // Check if rule has .When() condition + bool hasCondition = HasWhenCondition(invocation); + + return new RuleInfo + { + ConfigurationType = typeInfo.Type, + HasCondition = hasCondition, + Location = invocation.GetLocation() + }; + } + + private static bool HasWhenCondition(InvocationExpressionSyntax invocation) + { + // Traverse up the chain looking for .When() + var parent = invocation.Parent; + while (parent != null) + { + if (parent is InvocationExpressionSyntax parentInv) + { + var memberAccess = parentInv.Expression as MemberAccessExpressionSyntax; + if (memberAccess?.Name.Identifier.Text == "When") + { + return true; + } + } + parent = parent.Parent; + } + return false; + } + + private class RuleInfo + { + public required ITypeSymbol ConfigurationType { get; init; } + public required bool HasCondition { get; init; } + public required Location Location { get; init; } + } +} diff --git a/src/Cocoar.Configuration.Analyzers/Analyzers/RequiredRuleValidationAnalyzer.cs b/src/Cocoar.Configuration.Analyzers/Analyzers/RequiredRuleValidationAnalyzer.cs index e321c61..61a1a7e 100644 --- a/src/Cocoar.Configuration.Analyzers/Analyzers/RequiredRuleValidationAnalyzer.cs +++ b/src/Cocoar.Configuration.Analyzers/Analyzers/RequiredRuleValidationAnalyzer.cs @@ -1,158 +1,158 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Cocoar.Configuration.Analyzers.Analyzers; - -/// -/// Analyzer that validates required configuration rules. -/// Warns when required rules reference files or resources that may not exist. -/// -[DiagnosticAnalyzer(LanguageNames.CSharp)] -public class RequiredRuleValidationAnalyzer : DiagnosticAnalyzer -{ - public override ImmutableArray SupportedDiagnostics => - ImmutableArray.Create(DiagnosticDescriptors.RequiredRuleValidation); - - public override void Initialize(AnalysisContext context) - { - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.EnableConcurrentExecution(); - - context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); - } - - private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) - { - var invocation = (InvocationExpressionSyntax)context.Node; - - if (!IsAddCocoarConfigurationCall(invocation, context.SemanticModel)) - { - return; - } - - // Extract rules from lambda - var lambdas = invocation.DescendantNodes().OfType(); - foreach (var lambda in lambdas) - { - AnalyzeRequiredRules(lambda, context); - } - } - - private static bool IsAddCocoarConfigurationCall(InvocationExpressionSyntax invocation, SemanticModel semanticModel) - { - var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; - if (memberAccess == null) - { - return false; - } - - var methodSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol as IMethodSymbol; - return methodSymbol?.Name == "AddCocoarConfiguration"; - } - - private static void AnalyzeRequiredRules(SimpleLambdaExpressionSyntax lambda, SyntaxNodeAnalysisContext context) - { - // Look for rule chains that include .Required() - var invocations = lambda.DescendantNodes().OfType().ToList(); - - foreach (var inv in invocations) - { - var ruleInfo = ExtractRequiredRuleInfo(inv, context.SemanticModel); - if (ruleInfo == null) - { - continue; - } - - // Check if the file/resource exists - if (ruleInfo.FilePath != null && !string.IsNullOrEmpty(ruleInfo.FilePath)) - { - // Note: In a real implementation, we'd check the project files - // For now, we'll emit a warning for any required file rule - // to remind developers to ensure the file exists - - var diagnostic = Diagnostic.Create( - DiagnosticDescriptors.RequiredRuleValidation, - ruleInfo.Location, - ruleInfo.ConfigurationType?.Name ?? "configuration", - ruleInfo.FilePath); - - // Only report if the file path looks absolute or relative (basic heuristic) - if (ruleInfo.FilePath.Contains(".json") || - ruleInfo.FilePath.Contains(".xml") || - ruleInfo.FilePath.Contains(".yaml")) - { - context.ReportDiagnostic(diagnostic); - } - } - } - } - - private static RequiredRuleInfo? ExtractRequiredRuleInfo(InvocationExpressionSyntax invocation, SemanticModel semanticModel) - { - // Look for .Required() calls - var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; - if (memberAccess?.Name.Identifier.Text != "Required") - { - return null; - } - - // Traverse back through the chain to find .For() and .FromFile() - ITypeSymbol? configurationType = null; - string? filePath = null; - Location? location = null; - - var current = invocation.Parent; - while (current != null) - { - if (current is InvocationExpressionSyntax parentInv) - { - var parentMember = parentInv.Expression as MemberAccessExpressionSyntax; - - // Extract For type - if (parentMember?.Name is GenericNameSyntax { Identifier.Text: "For" } genericName) - { - var typeArg = genericName.TypeArgumentList.Arguments.FirstOrDefault(); - if (typeArg != null) - { - var typeInfo = semanticModel.GetTypeInfo(typeArg); - configurationType = typeInfo.Type; - location = parentInv.GetLocation(); - } - } - - // Extract FromFile path - if (parentMember?.Name.Identifier.Text == "FromFile") - { - var arg = parentInv.ArgumentList.Arguments.FirstOrDefault(); - if (arg?.Expression is LiteralExpressionSyntax literal) - { - filePath = literal.Token.ValueText; - } - } - } - current = current.Parent; - } - - if (configurationType == null || location == null) - { - return null; - } - - return new RequiredRuleInfo - { - ConfigurationType = configurationType, - FilePath = filePath, - Location = location - }; - } - - private class RequiredRuleInfo - { - public ITypeSymbol? ConfigurationType { get; init; } - public string? FilePath { get; init; } - public required Location Location { get; init; } - } -} +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Cocoar.Configuration.Analyzers.Analyzers; + +/// +/// Analyzer that validates required configuration rules. +/// Warns when required rules reference files or resources that may not exist. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class RequiredRuleValidationAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.RequiredRuleValidation); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + if (!IsAddCocoarConfigurationCall(invocation, context.SemanticModel)) + { + return; + } + + // Extract rules from lambda + var lambdas = invocation.DescendantNodes().OfType(); + foreach (var lambda in lambdas) + { + AnalyzeRequiredRules(lambda, context); + } + } + + private static bool IsAddCocoarConfigurationCall(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; + if (memberAccess == null) + { + return false; + } + + var methodSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol as IMethodSymbol; + return methodSymbol?.Name == "AddCocoarConfiguration"; + } + + private static void AnalyzeRequiredRules(SimpleLambdaExpressionSyntax lambda, SyntaxNodeAnalysisContext context) + { + // Look for rule chains that include .Required() + var invocations = lambda.DescendantNodes().OfType().ToList(); + + foreach (var inv in invocations) + { + var ruleInfo = ExtractRequiredRuleInfo(inv, context.SemanticModel); + if (ruleInfo == null) + { + continue; + } + + // Check if the file/resource exists + if (ruleInfo.FilePath != null && !string.IsNullOrEmpty(ruleInfo.FilePath)) + { + // Note: In a real implementation, we'd check the project files + // For now, we'll emit a warning for any required file rule + // to remind developers to ensure the file exists + + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.RequiredRuleValidation, + ruleInfo.Location, + ruleInfo.ConfigurationType?.Name ?? "configuration", + ruleInfo.FilePath); + + // Only report if the file path looks absolute or relative (basic heuristic) + if (ruleInfo.FilePath.Contains(".json") || + ruleInfo.FilePath.Contains(".xml") || + ruleInfo.FilePath.Contains(".yaml")) + { + context.ReportDiagnostic(diagnostic); + } + } + } + } + + private static RequiredRuleInfo? ExtractRequiredRuleInfo(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + // Look for .Required() calls + var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; + if (memberAccess?.Name.Identifier.Text != "Required") + { + return null; + } + + // Traverse back through the chain to find .For() and .FromFile() + ITypeSymbol? configurationType = null; + string? filePath = null; + Location? location = null; + + var current = invocation.Parent; + while (current != null) + { + if (current is InvocationExpressionSyntax parentInv) + { + var parentMember = parentInv.Expression as MemberAccessExpressionSyntax; + + // Extract For type + if (parentMember?.Name is GenericNameSyntax { Identifier.Text: "For" } genericName) + { + var typeArg = genericName.TypeArgumentList.Arguments.FirstOrDefault(); + if (typeArg != null) + { + var typeInfo = semanticModel.GetTypeInfo(typeArg); + configurationType = typeInfo.Type; + location = parentInv.GetLocation(); + } + } + + // Extract FromFile path + if (parentMember?.Name.Identifier.Text == "FromFile") + { + var arg = parentInv.ArgumentList.Arguments.FirstOrDefault(); + if (arg?.Expression is LiteralExpressionSyntax literal) + { + filePath = literal.Token.ValueText; + } + } + } + current = current.Parent; + } + + if (configurationType == null || location == null) + { + return null; + } + + return new RequiredRuleInfo + { + ConfigurationType = configurationType, + FilePath = filePath, + Location = location + }; + } + + private class RequiredRuleInfo + { + public ITypeSymbol? ConfigurationType { get; init; } + public string? FilePath { get; init; } + public required Location Location { get; init; } + } +} diff --git a/src/Cocoar.Configuration.Analyzers/Analyzers/SecretPathConflictAnalyzer.cs b/src/Cocoar.Configuration.Analyzers/Analyzers/SecretPathConflictAnalyzer.cs index f765277..0d5c860 100644 --- a/src/Cocoar.Configuration.Analyzers/Analyzers/SecretPathConflictAnalyzer.cs +++ b/src/Cocoar.Configuration.Analyzers/Analyzers/SecretPathConflictAnalyzer.cs @@ -1,224 +1,224 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace Cocoar.Configuration.Analyzers.Analyzers; - -/// -/// Analyzer that detects secret path conflicts in configuration rules. -/// Warns when a non-secret property might conflict with a Secret<T> property. -/// -[DiagnosticAnalyzer(LanguageNames.CSharp)] -public class SecretPathConflictAnalyzer : DiagnosticAnalyzer -{ - public override ImmutableArray SupportedDiagnostics => - ImmutableArray.Create(DiagnosticDescriptors.SecretPathConflict); - - public override void Initialize(AnalysisContext context) - { - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.EnableConcurrentExecution(); - - // Register for invocation expressions (method calls like rule.For()) - context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); - } - - private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) - { - var invocation = (InvocationExpressionSyntax)context.Node; - - // Look for AddCocoarConfiguration calls - if (!IsAddCocoarConfigurationCall(invocation, context.SemanticModel)) - { - return; - } - - // Extract lambda parameter (the rules builder) - var lambdas = invocation.DescendantNodes().OfType(); - foreach (var lambda in lambdas) - { - AnalyzeRules(lambda, context); - } - } - - private static bool IsAddCocoarConfigurationCall(InvocationExpressionSyntax invocation, SemanticModel semanticModel) - { - var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; - if (memberAccess == null) - { - return false; - } - - var methodSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol as IMethodSymbol; - return methodSymbol?.Name == "AddCocoarConfiguration"; - } - - private static void AnalyzeRules(SimpleLambdaExpressionSyntax lambda, SyntaxNodeAnalysisContext context) - { - // Look for rule.For() calls - var invocations = lambda.DescendantNodes().OfType(); - - var rules = new List(); - - foreach (var inv in invocations) - { - var ruleInfo = ExtractRuleInfo(inv, context.SemanticModel); - if (ruleInfo != null) - { - rules.Add(ruleInfo); - } - } - - // Check for path conflicts between secrets and non-secrets - DetectPathConflicts(rules, context); - } - - private static RuleInfo? ExtractRuleInfo(InvocationExpressionSyntax invocation, SemanticModel semanticModel) - { - // Look for .For() pattern - var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; - if (memberAccess?.Name.Identifier.Text != "For") - { - return null; - } - - // Extract type argument from For - if (memberAccess.Name is not GenericNameSyntax genericName) - { - return null; - } - - var typeArg = genericName.TypeArgumentList.Arguments.FirstOrDefault(); - if (typeArg == null) - { - return null; - } - - var typeInfo = semanticModel.GetTypeInfo(typeArg); - if (typeInfo.Type == null) - { - return null; - } - - // Extract select path if present - string? selectPath = ExtractSelectPath(invocation); - - return new RuleInfo - { - ConfigurationType = typeInfo.Type, - SelectPath = selectPath, - Location = invocation.GetLocation() - }; - } - - private static string? ExtractSelectPath(InvocationExpressionSyntax invocation) - { - // Look for .Select("path") in the chain - var parent = invocation.Parent; - while (parent != null) - { - if (parent is InvocationExpressionSyntax parentInv) - { - var memberAccess = parentInv.Expression as MemberAccessExpressionSyntax; - if (memberAccess?.Name.Identifier.Text == "Select") - { - // Extract string argument - var arg = parentInv.ArgumentList.Arguments.FirstOrDefault(); - if (arg?.Expression is LiteralExpressionSyntax literal) - { - return literal.Token.ValueText; - } - } - } - parent = parent.Parent; - } - return null; - } - - private static void DetectPathConflicts(List rules, SyntaxNodeAnalysisContext context) - { - // Group rules by configuration type - var rulesByType = rules.GroupBy(r => r.ConfigurationType.ToDisplayString()); - - foreach (var group in rulesByType) - { - var typeSymbol = group.First().ConfigurationType; - var hasSecretProperties = HasSecretProperties(typeSymbol); - - if (!hasSecretProperties) - { - continue; // No secrets, no conflict possible - } - - // Check each rule's select path against secret property paths - foreach (var rule in group) - { - if (rule.SelectPath == null) - { - continue; - } - - // Check if select path targets a property that should be Secret - var secretPropertyPath = FindConflictingSecretProperty(typeSymbol, rule.SelectPath); - if (secretPropertyPath != null) - { - var diagnostic = Diagnostic.Create( - DiagnosticDescriptors.SecretPathConflict, - rule.Location, - rule.SelectPath, - secretPropertyPath); - - context.ReportDiagnostic(diagnostic); - } - } - } - } - - private static bool HasSecretProperties(ITypeSymbol typeSymbol) - { - // Check if type has any properties of type Secret - var members = typeSymbol.GetMembers().OfType(); - return members.Any(p => IsSecretType(p.Type)); - } - - private static bool IsSecretType(ITypeSymbol typeSymbol) - { - // Check if type is Secret from Cocoar.Configuration.Secrets - if (typeSymbol is not INamedTypeSymbol namedType) - { - return false; - } - - return namedType.Name == "Secret" && - namedType.ContainingNamespace?.ToDisplayString().StartsWith("Cocoar.Configuration", StringComparison.Ordinal) == true; - } - - private static string? FindConflictingSecretProperty(ITypeSymbol typeSymbol, string selectPath) - { - // Simple path matching - look for properties with Secret type - var properties = typeSymbol.GetMembers().OfType(); - - foreach (var prop in properties) - { - if (IsSecretType(prop.Type)) - { - // Check if selectPath could conflict with this secret property - if (selectPath.IndexOf(prop.Name, StringComparison.OrdinalIgnoreCase) >= 0) - { - return $"{typeSymbol.Name}.{prop.Name}"; - } - } - } - - return null; - } - - private class RuleInfo - { - public required ITypeSymbol ConfigurationType { get; init; } - public string? SelectPath { get; init; } - public required Location Location { get; init; } - } -} +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cocoar.Configuration.Analyzers.Analyzers; + +/// +/// Analyzer that detects secret path conflicts in configuration rules. +/// Warns when a non-secret property might conflict with a Secret<T> property. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class SecretPathConflictAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.SecretPathConflict); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + // Register for invocation expressions (method calls like rule.For()) + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + // Look for AddCocoarConfiguration calls + if (!IsAddCocoarConfigurationCall(invocation, context.SemanticModel)) + { + return; + } + + // Extract lambda parameter (the rules builder) + var lambdas = invocation.DescendantNodes().OfType(); + foreach (var lambda in lambdas) + { + AnalyzeRules(lambda, context); + } + } + + private static bool IsAddCocoarConfigurationCall(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; + if (memberAccess == null) + { + return false; + } + + var methodSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol as IMethodSymbol; + return methodSymbol?.Name == "AddCocoarConfiguration"; + } + + private static void AnalyzeRules(SimpleLambdaExpressionSyntax lambda, SyntaxNodeAnalysisContext context) + { + // Look for rule.For() calls + var invocations = lambda.DescendantNodes().OfType(); + + var rules = new List(); + + foreach (var inv in invocations) + { + var ruleInfo = ExtractRuleInfo(inv, context.SemanticModel); + if (ruleInfo != null) + { + rules.Add(ruleInfo); + } + } + + // Check for path conflicts between secrets and non-secrets + DetectPathConflicts(rules, context); + } + + private static RuleInfo? ExtractRuleInfo(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + // Look for .For() pattern + var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; + if (memberAccess?.Name.Identifier.Text != "For") + { + return null; + } + + // Extract type argument from For + if (memberAccess.Name is not GenericNameSyntax genericName) + { + return null; + } + + var typeArg = genericName.TypeArgumentList.Arguments.FirstOrDefault(); + if (typeArg == null) + { + return null; + } + + var typeInfo = semanticModel.GetTypeInfo(typeArg); + if (typeInfo.Type == null) + { + return null; + } + + // Extract select path if present + string? selectPath = ExtractSelectPath(invocation); + + return new RuleInfo + { + ConfigurationType = typeInfo.Type, + SelectPath = selectPath, + Location = invocation.GetLocation() + }; + } + + private static string? ExtractSelectPath(InvocationExpressionSyntax invocation) + { + // Look for .Select("path") in the chain + var parent = invocation.Parent; + while (parent != null) + { + if (parent is InvocationExpressionSyntax parentInv) + { + var memberAccess = parentInv.Expression as MemberAccessExpressionSyntax; + if (memberAccess?.Name.Identifier.Text == "Select") + { + // Extract string argument + var arg = parentInv.ArgumentList.Arguments.FirstOrDefault(); + if (arg?.Expression is LiteralExpressionSyntax literal) + { + return literal.Token.ValueText; + } + } + } + parent = parent.Parent; + } + return null; + } + + private static void DetectPathConflicts(List rules, SyntaxNodeAnalysisContext context) + { + // Group rules by configuration type + var rulesByType = rules.GroupBy(r => r.ConfigurationType.ToDisplayString()); + + foreach (var group in rulesByType) + { + var typeSymbol = group.First().ConfigurationType; + var hasSecretProperties = HasSecretProperties(typeSymbol); + + if (!hasSecretProperties) + { + continue; // No secrets, no conflict possible + } + + // Check each rule's select path against secret property paths + foreach (var rule in group) + { + if (rule.SelectPath == null) + { + continue; + } + + // Check if select path targets a property that should be Secret + var secretPropertyPath = FindConflictingSecretProperty(typeSymbol, rule.SelectPath); + if (secretPropertyPath != null) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.SecretPathConflict, + rule.Location, + rule.SelectPath, + secretPropertyPath); + + context.ReportDiagnostic(diagnostic); + } + } + } + } + + private static bool HasSecretProperties(ITypeSymbol typeSymbol) + { + // Check if type has any properties of type Secret + var members = typeSymbol.GetMembers().OfType(); + return members.Any(p => IsSecretType(p.Type)); + } + + private static bool IsSecretType(ITypeSymbol typeSymbol) + { + // Check if type is Secret from Cocoar.Configuration.Secrets + if (typeSymbol is not INamedTypeSymbol namedType) + { + return false; + } + + return namedType.Name == "Secret" && + namedType.ContainingNamespace?.ToDisplayString().StartsWith("Cocoar.Configuration", StringComparison.Ordinal) == true; + } + + private static string? FindConflictingSecretProperty(ITypeSymbol typeSymbol, string selectPath) + { + // Simple path matching - look for properties with Secret type + var properties = typeSymbol.GetMembers().OfType(); + + foreach (var prop in properties) + { + if (IsSecretType(prop.Type)) + { + // Check if selectPath could conflict with this secret property + if (selectPath.IndexOf(prop.Name, StringComparison.OrdinalIgnoreCase) >= 0) + { + return $"{typeSymbol.Name}.{prop.Name}"; + } + } + } + + return null; + } + + private class RuleInfo + { + public required ITypeSymbol ConfigurationType { get; init; } + public string? SelectPath { get; init; } + public required Location Location { get; init; } + } +} diff --git a/src/Cocoar.Configuration.Analyzers/CompilerServicesPolyfill.cs b/src/Cocoar.Configuration.Analyzers/CompilerServicesPolyfill.cs index 3b1d6db..bd3cbd2 100644 --- a/src/Cocoar.Configuration.Analyzers/CompilerServicesPolyfill.cs +++ b/src/Cocoar.Configuration.Analyzers/CompilerServicesPolyfill.cs @@ -1,42 +1,42 @@ -// ReSharper disable CheckNamespace -// Polyfill for C# 9+ features when targeting netstandard2.0 - -#if NETSTANDARD2_0 - -namespace System.Runtime.CompilerServices -{ - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false, Inherited = false)] - internal sealed class RequiredMemberAttribute : Attribute - { - } - - [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)] - internal sealed class CompilerFeatureRequiredAttribute : Attribute - { - public CompilerFeatureRequiredAttribute(string featureName) - { - FeatureName = featureName; - } - - public string FeatureName { get; } - public bool IsOptional { get; init; } - } - - internal static class IsExternalInit - { - } -} - -namespace System.Collections.Generic -{ - internal static class KeyValuePairExtensions - { - public static void Deconstruct(this KeyValuePair kvp, out TKey key, out TValue value) - { - key = kvp.Key; - value = kvp.Value; - } - } -} - -#endif +// ReSharper disable CheckNamespace +// Polyfill for C# 9+ features when targeting netstandard2.0 + +#if NETSTANDARD2_0 + +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false, Inherited = false)] + internal sealed class RequiredMemberAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)] + internal sealed class CompilerFeatureRequiredAttribute : Attribute + { + public CompilerFeatureRequiredAttribute(string featureName) + { + FeatureName = featureName; + } + + public string FeatureName { get; } + public bool IsOptional { get; init; } + } + + internal static class IsExternalInit + { + } +} + +namespace System.Collections.Generic +{ + internal static class KeyValuePairExtensions + { + public static void Deconstruct(this KeyValuePair kvp, out TKey key, out TValue value) + { + key = kvp.Key; + value = kvp.Value; + } + } +} + +#endif diff --git a/src/Cocoar.Configuration.Analyzers/DiagnosticDescriptors.cs b/src/Cocoar.Configuration.Analyzers/DiagnosticDescriptors.cs index b235f74..ba6bc82 100644 --- a/src/Cocoar.Configuration.Analyzers/DiagnosticDescriptors.cs +++ b/src/Cocoar.Configuration.Analyzers/DiagnosticDescriptors.cs @@ -1,85 +1,85 @@ -using Microsoft.CodeAnalysis; - -namespace Cocoar.Configuration.Analyzers; - -/// -/// Diagnostic descriptors for Cocoar.Configuration analyzers. -/// -internal static class DiagnosticDescriptors -{ - private const string Category = "Cocoar.Configuration"; - - /// - /// CA001: Secret path conflict detected. - /// A non-secret property has the same path as a secret property, risking plaintext exposure. - /// - public static readonly DiagnosticDescriptor SecretPathConflict = new( - id: "COCFG001", - title: "Secret path conflict detected", - messageFormat: "Property '{0}' conflicts with secret property '{1}'. Consider using Secret or renaming to avoid plaintext exposure.", - category: Category, - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true, - description: "A configuration property has the same path as a property marked with Secret, which may cause sensitive data to be exposed as plaintext.", - helpLinkUri: "https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/analyzers/COCFG001.md"); - - /// - /// CA002: Rule dependency ordering violation. - /// A rule depends on configuration that hasn't been loaded yet. - /// - public static readonly DiagnosticDescriptor RuleOrderingViolation = new( - id: "COCFG002", - title: "Rule dependency ordering violation", - messageFormat: "Rule for '{0}' depends on '{1}' which is not available yet. Move this rule after the '{1}' rule.", - category: Category, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true, - description: "A configuration rule uses GetConfig for a type that hasn't been loaded yet, which will cause a runtime exception.", - helpLinkUri: "https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/analyzers/COCFG002.md"); - - /// - /// CA003: Required rule validation. - /// A required rule references a file or resource that may not exist. - /// - public static readonly DiagnosticDescriptor RequiredRuleValidation = new( - id: "COCFG003", - title: "Required rule configuration validation", - messageFormat: "Required rule for '{0}' references '{1}' which may not exist. Application will fail to start if this resource is missing.", - category: Category, - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true, - description: "A required configuration rule references a file or resource that doesn't exist in the project, which will cause startup failure.", - helpLinkUri: "https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/analyzers/COCFG003.md"); - - // COCFG004 was removed — the `where T : class` constraint on TypedRuleBuilder and - // IConfigurationAccessor.GetConfig() now enforces type safety at compile time. - // The diagnostic ID is reserved to avoid reuse. - - /// - /// CA005: Duplicate unconditional rules for same type. - /// Multiple rules configure the same type without conditions (last write wins). - /// - public static readonly DiagnosticDescriptor DuplicateUnconditionalRules = new( - id: "COCFG005", - title: "Duplicate unconditional rules detected", - messageFormat: "Multiple unconditional rules for type '{0}'. Last rule will override earlier rules. Consider using .When() conditions or removing duplicates.", - category: Category, - defaultSeverity: DiagnosticSeverity.Info, - isEnabledByDefault: true, - description: "Multiple configuration rules target the same type without conditions. Only the last rule will be effective (last write wins).", - helpLinkUri: "https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/analyzers/COCFG005.md"); - - /// - /// CA006: Static provider ordering suggestion. - /// Static/seed rules should generally appear before dynamic rules that may depend on them. - /// - public static readonly DiagnosticDescriptor StaticProviderOrdering = new( - id: "COCFG006", - title: "Static provider ordering suggestion", - messageFormat: "Static/seed rule found after dynamic rules. Consider moving static rules first to ensure they're available to dynamic rules.", - category: Category, - defaultSeverity: DiagnosticSeverity.Info, - isEnabledByDefault: true, - description: "Static or seed rules (FromStatic, FromObservable) should generally appear before dynamic rules (FromFile, FromHttp) that may depend on their configuration.", - helpLinkUri: "https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/analyzers/COCFG006.md"); -} +using Microsoft.CodeAnalysis; + +namespace Cocoar.Configuration.Analyzers; + +/// +/// Diagnostic descriptors for Cocoar.Configuration analyzers. +/// +internal static class DiagnosticDescriptors +{ + private const string Category = "Cocoar.Configuration"; + + /// + /// CA001: Secret path conflict detected. + /// A non-secret property has the same path as a secret property, risking plaintext exposure. + /// + public static readonly DiagnosticDescriptor SecretPathConflict = new( + id: "COCFG001", + title: "Secret path conflict detected", + messageFormat: "Property '{0}' conflicts with secret property '{1}'. Consider using Secret or renaming to avoid plaintext exposure.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A configuration property has the same path as a property marked with Secret, which may cause sensitive data to be exposed as plaintext.", + helpLinkUri: "https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/analyzers/COCFG001.md"); + + /// + /// CA002: Rule dependency ordering violation. + /// A rule depends on configuration that hasn't been loaded yet. + /// + public static readonly DiagnosticDescriptor RuleOrderingViolation = new( + id: "COCFG002", + title: "Rule dependency ordering violation", + messageFormat: "Rule for '{0}' depends on '{1}' which is not available yet. Move this rule after the '{1}' rule.", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A configuration rule uses GetConfig for a type that hasn't been loaded yet, which will cause a runtime exception.", + helpLinkUri: "https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/analyzers/COCFG002.md"); + + /// + /// CA003: Required rule validation. + /// A required rule references a file or resource that may not exist. + /// + public static readonly DiagnosticDescriptor RequiredRuleValidation = new( + id: "COCFG003", + title: "Required rule configuration validation", + messageFormat: "Required rule for '{0}' references '{1}' which may not exist. Application will fail to start if this resource is missing.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A required configuration rule references a file or resource that doesn't exist in the project, which will cause startup failure.", + helpLinkUri: "https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/analyzers/COCFG003.md"); + + // COCFG004 was removed — the `where T : class` constraint on TypedRuleBuilder and + // IConfigurationAccessor.GetConfig() now enforces type safety at compile time. + // The diagnostic ID is reserved to avoid reuse. + + /// + /// CA005: Duplicate unconditional rules for same type. + /// Multiple rules configure the same type without conditions (last write wins). + /// + public static readonly DiagnosticDescriptor DuplicateUnconditionalRules = new( + id: "COCFG005", + title: "Duplicate unconditional rules detected", + messageFormat: "Multiple unconditional rules for type '{0}'. Last rule will override earlier rules. Consider using .When() conditions or removing duplicates.", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Multiple configuration rules target the same type without conditions. Only the last rule will be effective (last write wins).", + helpLinkUri: "https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/analyzers/COCFG005.md"); + + /// + /// CA006: Static provider ordering suggestion. + /// Static/seed rules should generally appear before dynamic rules that may depend on them. + /// + public static readonly DiagnosticDescriptor StaticProviderOrdering = new( + id: "COCFG006", + title: "Static provider ordering suggestion", + messageFormat: "Static/seed rule found after dynamic rules. Consider moving static rules first to ensure they're available to dynamic rules.", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Static or seed rules (FromStatic, FromObservable) should generally appear before dynamic rules (FromFile, FromHttp) that may depend on their configuration.", + helpLinkUri: "https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/analyzers/COCFG006.md"); +} diff --git a/src/Cocoar.Configuration.Analyzers/Flags/CocoarFlagsGenerator.cs b/src/Cocoar.Configuration.Analyzers/Flags/CocoarFlagsGenerator.cs index 44d9ad4..0736a8d 100644 --- a/src/Cocoar.Configuration.Analyzers/Flags/CocoarFlagsGenerator.cs +++ b/src/Cocoar.Configuration.Analyzers/Flags/CocoarFlagsGenerator.cs @@ -1,720 +1,720 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Text; -using System.Threading; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Text; - -namespace Cocoar.Configuration.Flags.Generator; - -[Generator] -public sealed class CocoarFlagsGenerator : IIncrementalGenerator -{ - private const string IFeatureFlagsFqn = "Cocoar.Configuration.Flags.IFeatureFlags"; - private const string IEntitlementsFqn = "Cocoar.Configuration.Flags.IEntitlements"; - - public void Initialize(IncrementalGeneratorInitializationContext context) - { - // ── Pipeline 1: Descriptor generation (existing) ──────────────────── - var classInfos = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => - node is InvocationExpressionSyntax inv - && inv.Expression is MemberAccessExpressionSyntax { Name: GenericNameSyntax g } - && g.Identifier.Text == "Register" - && g.TypeArgumentList.Arguments.Count == 1, - transform: static (ctx, ct) => ExtractClassInfoFromInvocation(ctx, ct)) - .Where(static x => x is not null); - - var collected = classInfos.Collect(); - - context.RegisterSourceOutput(collected, static (spc, items) => - { - var validItems = items.Where(x => x is not null).ToList(); - if (validItems.Count == 0) return; - - // Emit diagnostics - foreach (var item in validItems) - { - foreach (var diag in item!.Diagnostics) - spc.ReportDiagnostic(diag); - } - - // Deduplicate by FullTypeName (same type may be registered multiple times) - var flagItems = validItems - .Where(x => x!.IsFlags) - .GroupBy(x => x!.FullTypeName) - .Select(g => g.First()) - .OrderBy(x => x!.FullTypeName, StringComparer.Ordinal) - .ToList(); - - var entitlementItems = validItems - .Where(x => !x!.IsFlags) - .GroupBy(x => x!.FullTypeName) - .Select(g => g.First()) - .OrderBy(x => x!.FullTypeName, StringComparer.Ordinal) - .ToList(); - - if (flagItems.Count == 0 && entitlementItems.Count == 0) return; - - var source = GenerateSource(flagItems!, entitlementItems!); - spc.AddSource("CocoarFlagsGenerated.g.cs", SourceText.From(source, Encoding.UTF8)); - }); - - // ── Pipeline 2: Partial class generation for IFeatureFlags / IEntitlements ── - var partialClassInfos = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => - node is ClassDeclarationSyntax cls - && cls.Modifiers.Any(SyntaxKind.PartialKeyword) - && cls.BaseList is not null, - transform: static (ctx, ct) => ExtractPartialClassInfo(ctx, ct)) - .Where(static x => x is not null); - - context.RegisterSourceOutput(partialClassInfos, static (spc, info) => - { - if (info is null) return; - var source = GeneratePartialClassSource(info); - spc.AddSource($"{info.ClassName}.g.cs", SourceText.From(source, Encoding.UTF8)); - }); - } - - private static ClassInfo? ExtractClassInfoFromInvocation(GeneratorSyntaxContext ctx, CancellationToken ct) - { - var invocation = (InvocationExpressionSyntax)ctx.Node; - var memberAccess = (MemberAccessExpressionSyntax)invocation.Expression; - var genericName = (GenericNameSyntax)memberAccess.Name; - var typeArgSyntax = genericName.TypeArgumentList.Arguments[0]; - - var typeSymbol = ctx.SemanticModel.GetTypeInfo(typeArgSyntax, ct).Type as INamedTypeSymbol; - if (typeSymbol == null) - return null; - - if (typeSymbol.IsAbstract) - { - // Return a ClassInfo with only a diagnostic — no members, no code generation - return new ClassInfo( - isFlags: ImplementsInterface(typeSymbol, IFeatureFlagsFqn), - fullTypeName: typeSymbol.ToDisplayString(), - expiresAt: DateTimeOffset.MinValue, - members: new List(), - diagnostics: new List - { - Diagnostic.Create( - FlagsGeneratorDiagnostics.AbstractTypeRegistered, - typeArgSyntax.GetLocation(), - typeSymbol.ToDisplayString()) - }); - } - - if (HasFlagProperties(typeSymbol) || ImplementsInterface(typeSymbol, IFeatureFlagsFqn)) - { - // Iterate ALL partial declarations to collect every FeatureFlag<> property - var allMembers = new List(); - var diagnostics = new List(); - var expiresAt = DateTimeOffset.MinValue; - var fullTypeName = typeSymbol.ToDisplayString(); - var foundSource = false; - - foreach (var syntaxRef in typeSymbol.DeclaringSyntaxReferences) - { - ct.ThrowIfCancellationRequested(); - if (syntaxRef.GetSyntax(ct) is not ClassDeclarationSyntax classDecl) continue; - foundSource = true; - var sm = ctx.SemanticModel.Compilation.GetSemanticModel(syntaxRef.SyntaxTree); - - // ExpiresAt: take from whichever partial declares it - if (expiresAt == DateTimeOffset.MinValue) - expiresAt = ExtractClassExpiresAt(classDecl, sm, fullTypeName, diagnostics); - - allMembers.AddRange(ExtractFlagProperties(classDecl, fullTypeName, diagnostics)); - } - - if (!foundSource) - { - // Type from a referenced assembly — no source available - return new ClassInfo(true, fullTypeName, DateTimeOffset.MinValue, - new List(), new List()); - } - - // Deduplicate by property name (same property may appear in multiple partials' trivia) - var dedupedMembers = allMembers - .GroupBy(m => m.Name) - .Select(g => g.First()) - .ToList(); - - return new ClassInfo(true, fullTypeName, expiresAt, dedupedMembers, diagnostics); - } - - if (HasEntitlementProperties(typeSymbol) || ImplementsInterface(typeSymbol, IEntitlementsFqn)) - { - // Iterate ALL partial declarations to collect every Entitlement<> property - var allMembers = new List(); - var diagnostics = new List(); - var fullTypeName = typeSymbol.ToDisplayString(); - var foundSource = false; - - foreach (var syntaxRef in typeSymbol.DeclaringSyntaxReferences) - { - ct.ThrowIfCancellationRequested(); - if (syntaxRef.GetSyntax(ct) is not ClassDeclarationSyntax classDecl) continue; - foundSource = true; - var sm = ctx.SemanticModel.Compilation.GetSemanticModel(syntaxRef.SyntaxTree); - allMembers.AddRange(ExtractEntitlementProperties(classDecl, fullTypeName, diagnostics)); - } - - if (!foundSource) - { - return new ClassInfo(false, fullTypeName, DateTimeOffset.MinValue, - new List(), new List()); - } - - var dedupedMembers = allMembers - .GroupBy(m => m.Name) - .Select(g => g.First()) - .ToList(); - - return new ClassInfo(false, fullTypeName, DateTimeOffset.MinValue, dedupedMembers, diagnostics); - } - - return null; - } - - private static bool ImplementsInterface(INamedTypeSymbol symbol, string interfaceFqn) - { - foreach (var iface in symbol.AllInterfaces) - { - var name = iface.IsGenericType - ? iface.ConstructedFrom.ToDisplayString() - : iface.ToDisplayString(); - if (name == interfaceFqn + "") - return true; - } - return false; - } - - /// - /// Checks whether the type has any FeatureFlag<> properties (used to detect - /// flag classes that don't implement IFeatureFlags<T> but use FeatureFlag properties directly). - /// - private static bool HasFlagProperties(INamedTypeSymbol symbol) - { - foreach (var member in symbol.GetMembers()) - { - if (member is IPropertySymbol prop && prop.Type is INamedTypeSymbol propType && propType.IsGenericType) - { - var typeName = propType.ConstructedFrom.Name; - if (typeName == "FeatureFlag") - return true; - } - } - return false; - } - - /// - /// Checks whether the type has any Entitlement<> properties (used to detect - /// entitlement classes that don't implement IEntitlements<T> but use Entitlement properties directly). - /// - private static bool HasEntitlementProperties(INamedTypeSymbol symbol) - { - foreach (var member in symbol.GetMembers()) - { - if (member is IPropertySymbol prop && prop.Type is INamedTypeSymbol propType && propType.IsGenericType) - { - var typeName = propType.ConstructedFrom.Name; - if (typeName == "Entitlement") - return true; - } - } - return false; - } - - // ─── FeatureFlags extraction ────────────────────────────────────────────── - - private static DateTimeOffset ExtractClassExpiresAt( - ClassDeclarationSyntax classDecl, - SemanticModel semanticModel, - string typeName, - List diagnostics) - { - foreach (var member in classDecl.Members) - { - if (member is not PropertyDeclarationSyntax prop) continue; - if (prop.Identifier.Text != "ExpiresAt") continue; - - ExpressionSyntax? expr = null; - if (prop.ExpressionBody?.Expression is not null) - expr = prop.ExpressionBody.Expression; - else if (prop.Initializer?.Value is not null) - expr = prop.Initializer.Value; - - if (expr != null) - { - var result = TryParseDateTimeOffsetLiteral(expr); - if (result.HasValue) return result.Value; - } - - // Could not statically determine - diagnostics.Add(Diagnostic.Create( - FlagsGeneratorDiagnostics.NonStaticExpiresAt, - prop.GetLocation(), - typeName)); - return DateTimeOffset.MinValue; - } - - // ExpiresAt not found in this partial — MinValue fallback - diagnostics.Add(Diagnostic.Create( - FlagsGeneratorDiagnostics.NonStaticExpiresAt, - classDecl.Identifier.GetLocation(), - typeName)); - return DateTimeOffset.MinValue; - } - - private static List ExtractFlagProperties( - ClassDeclarationSyntax classDecl, string fullTypeName, List diagnostics) - { - var flags = new List(); - foreach (var member in classDecl.Members.OfType()) - { - if (!IsFlagPropertyType(member.Type)) continue; - var description = GetXmlSummary(member); - if (description == null) - { - diagnostics.Add(Diagnostic.Create( - FlagsGeneratorDiagnostics.MissingPropertyDescription, - member.Identifier.GetLocation(), - member.Identifier.Text, - fullTypeName)); - } - flags.Add(new MemberInfo(member.Identifier.Text, description)); - } - return flags; - } - - // ─── Entitlements extraction ────────────────────────────────────────────── - - private static List ExtractEntitlementProperties( - ClassDeclarationSyntax classDecl, string fullTypeName, List diagnostics) - { - var entitlements = new List(); - foreach (var member in classDecl.Members.OfType()) - { - if (!IsEntitlementPropertyType(member.Type)) continue; - var description = GetXmlSummary(member); - if (description == null) - { - diagnostics.Add(Diagnostic.Create( - FlagsGeneratorDiagnostics.MissingPropertyDescription, - member.Identifier.GetLocation(), - member.Identifier.Text, - fullTypeName)); - } - entitlements.Add(new MemberInfo(member.Identifier.Text, description)); - } - return entitlements; - } - - // ─── XML doc helper ─────────────────────────────────────────────────────── - - /// - /// Extracts the text content of the <summary> XML doc comment on a property, - /// or returns null if no summary is present. - /// - private static string? GetXmlSummary(PropertyDeclarationSyntax prop) - { - var docTrivia = prop.GetLeadingTrivia() - .Select(t => t.GetStructure()) - .OfType() - .FirstOrDefault(); - - if (docTrivia is null) return null; - - var summary = docTrivia.ChildNodes() - .OfType() - .FirstOrDefault(e => e.StartTag.Name.LocalName.Text == "summary"); - - if (summary is null) return null; - - var text = string.Concat(summary.Content.OfType() - .SelectMany(t => t.TextTokens) - .Select(t => t.ValueText)); - - // Normalize: trim each line and join with a single space - var lines = text.Split('\n') - .Select(l => l.TrimStart().TrimStart('/').Trim()) - .Where(l => l.Length > 0); - - var result = string.Join(" ", lines); - return string.IsNullOrWhiteSpace(result) ? null : result; - } - - // ─── Syntax helpers ─────────────────────────────────────────────────────── - - private static bool IsFlagPropertyType(TypeSyntax typeSyntax) - => GetBaseTypeName(typeSyntax) == "FeatureFlag"; - - private static bool IsEntitlementPropertyType(TypeSyntax typeSyntax) - => GetBaseTypeName(typeSyntax) == "Entitlement"; - - private static string? GetBaseTypeName(TypeSyntax typeSyntax) => typeSyntax switch - { - GenericNameSyntax g => g.Identifier.Text, - QualifiedNameSyntax { Right: GenericNameSyntax gn } => gn.Identifier.Text, - _ => null - }; - - private static DateTimeOffset? TryParseDateTimeOffsetLiteral(ExpressionSyntax expr) - { - ArgumentListSyntax? args = expr switch - { - ObjectCreationExpressionSyntax oce => oce.ArgumentList, - ImplicitObjectCreationExpressionSyntax ioce => ioce.ArgumentList, - _ => null - }; - - if (args == null || args.Arguments.Count < 1) return null; - - // Handle: new DateTimeOffset(new DateTime(year, month, day, ...), TimeSpan.Zero) - var firstArg = args.Arguments[0].Expression; - if (firstArg is ObjectCreationExpressionSyntax innerOce - && IsDateTimeTypeName(innerOce.Type)) - { - return TryParseDateTimeLiteralArgs(innerOce.ArgumentList); - } - if (firstArg is ImplicitObjectCreationExpressionSyntax && args.Arguments.Count >= 2) - { - // new DateTimeOffset(new(...), TimeSpan.Zero) — can't verify type without semantic model, - // but if the outer type is DateTimeOffset and first arg is implicit new with 3+ int args, try it - if (firstArg is ImplicitObjectCreationExpressionSyntax innerIoce) - return TryParseDateTimeLiteralArgs(innerIoce.ArgumentList); - } - - if (args.Arguments.Count < 3) return null; - - // Handle: new DateTimeOffset(year, month, day, h, m, s, TimeSpan.Zero) - var ints = new List(); - foreach (var arg in args.Arguments) - { - if (arg.Expression is LiteralExpressionSyntax lit - && lit.Token.Value is int v) - { - ints.Add(v); - } - else - { - // Non-integer argument — could be TimeSpan.Zero at position 6 or 3 - // We stop collecting ints but still try to parse if we have 3+ - break; - } - } - - if (ints.Count < 3) return null; - - try - { - return ints.Count >= 6 - ? new DateTimeOffset(ints[0], ints[1], ints[2], ints[3], ints[4], ints[5], TimeSpan.Zero) - : ints.Count == 3 - ? new DateTimeOffset(ints[0], ints[1], ints[2], 0, 0, 0, TimeSpan.Zero) - : (DateTimeOffset?)null; - } - catch - { - return null; - } - } - - private static DateTimeOffset? TryParseDateTimeLiteralArgs(ArgumentListSyntax? args) - { - if (args == null || args.Arguments.Count < 3) return null; - - var ints = new List(); - foreach (var arg in args.Arguments) - { - if (arg.Expression is LiteralExpressionSyntax lit && lit.Token.Value is int v) - ints.Add(v); - else - break; - } - - if (ints.Count < 3) return null; - - try - { - return ints.Count >= 6 - ? new DateTimeOffset(ints[0], ints[1], ints[2], ints[3], ints[4], ints[5], TimeSpan.Zero) - : new DateTimeOffset(ints[0], ints[1], ints[2], 0, 0, 0, TimeSpan.Zero); - } - catch - { - return null; - } - } - - private static bool IsDateTimeTypeName(TypeSyntax type) => type switch - { - IdentifierNameSyntax id => id.Identifier.Text == "DateTime", - QualifiedNameSyntax q => q.Right.Identifier.Text == "DateTime", - _ => false - }; - - private static string EscapeString(string s) - { - return s - .Replace("\\", "\\\\") - .Replace("\"", "\\\"") - .Replace("\n", "\\n") - .Replace("\r", "\\r") - .Replace("\t", "\\t") - .Replace("\0", "\\0"); - } - - // ─── Code generation ────────────────────────────────────────────────────── - - private static string GenerateSource(List flagClasses, List entitlementClasses) - { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine(); - sb.AppendLine("namespace Cocoar.Configuration.Flags.Generated"); - sb.AppendLine("{"); - sb.AppendLine(" internal static class CocoarFlagsDescriptors"); - sb.AppendLine(" {"); - - // Flags dictionary - sb.AppendLine(" internal static readonly global::System.Collections.Generic.IReadOnlyDictionary<"); - sb.AppendLine(" global::System.Type,"); - sb.AppendLine(" global::Cocoar.Configuration.Flags.FeatureFlagClassDescriptor> Flags ="); - sb.AppendLine(" new global::System.Collections.Generic.Dictionary<"); - sb.AppendLine(" global::System.Type,"); - sb.AppendLine(" global::Cocoar.Configuration.Flags.FeatureFlagClassDescriptor>"); - sb.AppendLine(" {"); - foreach (var cls in flagClasses) - { - sb.AppendLine(" {"); - sb.AppendLine($" typeof(global::{cls.FullTypeName}),"); - sb.AppendLine($" new global::Cocoar.Configuration.Flags.FeatureFlagClassDescriptor("); - sb.AppendLine($" Type: typeof(global::{cls.FullTypeName}),"); - sb.AppendLine($" ExpiresAt: {RenderDateTimeOffset(cls.ExpiresAt)},"); - sb.AppendLine(" Flags: new global::Cocoar.Configuration.Flags.FlagDefinitionDescriptor[]"); - sb.AppendLine(" {"); - foreach (var flag in cls.Members) - { - var descArg = flag.Description != null - ? $"\"{EscapeString(flag.Description)}\"" - : "null"; - sb.AppendLine($" new(\"{flag.Name}\", {descArg}),"); - } - sb.AppendLine(" })"); - sb.AppendLine(" },"); - } - sb.AppendLine(" };"); - sb.AppendLine(); - - // Entitlements dictionary - sb.AppendLine(" internal static readonly global::System.Collections.Generic.IReadOnlyDictionary<"); - sb.AppendLine(" global::System.Type,"); - sb.AppendLine(" global::Cocoar.Configuration.Flags.EntitlementClassDescriptor> Entitlements ="); - sb.AppendLine(" new global::System.Collections.Generic.Dictionary<"); - sb.AppendLine(" global::System.Type,"); - sb.AppendLine(" global::Cocoar.Configuration.Flags.EntitlementClassDescriptor>"); - sb.AppendLine(" {"); - foreach (var cls in entitlementClasses) - { - sb.AppendLine(" {"); - sb.AppendLine($" typeof(global::{cls.FullTypeName}),"); - sb.AppendLine($" new global::Cocoar.Configuration.Flags.EntitlementClassDescriptor("); - sb.AppendLine($" Type: typeof(global::{cls.FullTypeName}),"); - sb.AppendLine(" Entitlements: new global::Cocoar.Configuration.Flags.EntitlementDefinitionDescriptor[]"); - sb.AppendLine(" {"); - foreach (var ent in cls.Members) - { - var descArg = ent.Description != null - ? $"\"{EscapeString(ent.Description)}\"" - : "null"; - sb.AppendLine($" new(\"{ent.Name}\", {descArg}),"); - } - sb.AppendLine(" })"); - sb.AppendLine(" },"); - } - sb.AppendLine(" };"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - - return sb.ToString(); - } - - private static string RenderDateTimeOffset(DateTimeOffset dto) - { - if (dto == DateTimeOffset.MinValue) - return "global::System.DateTimeOffset.MinValue"; - - return $"new global::System.DateTimeOffset({dto.Year}, {dto.Month}, {dto.Day}, {dto.Hour}, {dto.Minute}, {dto.Second}, global::System.TimeSpan.Zero)"; - } - - // ─── Pipeline 2: Partial class extraction ────────────────────────────── - - private static PartialClassInfo? ExtractPartialClassInfo(GeneratorSyntaxContext ctx, CancellationToken ct) - { - var classDecl = (ClassDeclarationSyntax)ctx.Node; - var symbol = ctx.SemanticModel.GetDeclaredSymbol(classDecl, ct) as INamedTypeSymbol; - if (symbol is null) return null; - - foreach (var iface in symbol.AllInterfaces) - { - if (!iface.IsGenericType || iface.TypeArguments.Length != 1) continue; - - var unboundName = iface.ConstructedFrom.ToDisplayString(); - bool isFlags = unboundName == IFeatureFlagsFqn + ""; - bool isEntitlements = unboundName == IEntitlementsFqn + ""; - if (!isFlags && !isEntitlements) continue; - - var configTypeArg = iface.TypeArguments[0]; - var configTypeFqn = configTypeArg.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - var containingNamespace = symbol.ContainingNamespace.IsGlobalNamespace - ? null - : symbol.ContainingNamespace.ToDisplayString(); - - // Collect containing type hierarchy for nested classes - var containingTypes = new List(); - var outer = symbol.ContainingType; - while (outer is not null) - { - containingTypes.Insert(0, outer.Name); - outer = outer.ContainingType; - } - - return new PartialClassInfo( - className: symbol.Name, - namespaceName: containingNamespace, - configTypeFqn: configTypeFqn, - isFlags: isFlags, - containingTypes: containingTypes); - } - - return null; - } - - private static string GeneratePartialClassSource(PartialClassInfo info) - { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine(); - - var indentLevel = 0; - - if (info.NamespaceName is not null) - { - sb.AppendLine($"namespace {info.NamespaceName}"); - sb.AppendLine("{"); - indentLevel++; - } - - // Open containing types (for nested classes) - foreach (var containingType in info.ContainingTypes) - { - var outerIndent = new string(' ', indentLevel * 4); - sb.AppendLine($"{outerIndent}partial class {containingType}"); - sb.AppendLine($"{outerIndent}{{"); - indentLevel++; - } - - var indent = new string(' ', indentLevel * 4); - var memberIndent = new string(' ', (indentLevel + 1) * 4); - - sb.AppendLine($"{indent}partial class {info.ClassName}"); - sb.AppendLine($"{indent}{{"); - sb.AppendLine($"{memberIndent}private readonly global::Cocoar.Configuration.Reactive.IReactiveConfig<{info.ConfigTypeFqn}> _reactive;"); - sb.AppendLine(); - sb.AppendLine($"{memberIndent}protected {info.ConfigTypeFqn} Config => _reactive.CurrentValue;"); - sb.AppendLine(); - - if (info.IsFlags) - { - sb.AppendLine($"{memberIndent}/// "); - sb.AppendLine($"{memberIndent}/// Is this feature flag class past its expiration?"); - sb.AppendLine($"{memberIndent}/// When true, the flags still work but the code should be cleaned up."); - sb.AppendLine($"{memberIndent}/// "); - sb.AppendLine($"{memberIndent}public bool IsExpired => global::System.DateTimeOffset.UtcNow > ExpiresAt;"); - sb.AppendLine(); - } - - sb.AppendLine($"{memberIndent}public {info.ClassName}(global::Cocoar.Configuration.Reactive.IReactiveConfig<{info.ConfigTypeFqn}> reactive)"); - sb.AppendLine($"{memberIndent}{{"); - sb.AppendLine($"{memberIndent} _reactive = reactive;"); - sb.AppendLine($"{memberIndent}}}"); - sb.AppendLine($"{indent}}}"); - - // Close containing types - for (var i = info.ContainingTypes.Count - 1; i >= 0; i--) - { - indentLevel--; - var outerIndent = new string(' ', indentLevel * 4); - sb.AppendLine($"{outerIndent}}}"); - } - - if (info.NamespaceName is not null) - { - sb.AppendLine("}"); - } - - return sb.ToString(); - } - - // ─── Data models ────────────────────────────────────────────────────────── - - private sealed class ClassInfo - { - public bool IsFlags { get; } - public string FullTypeName { get; } - public DateTimeOffset ExpiresAt { get; } - public List Members { get; } - public List Diagnostics { get; } - - public ClassInfo(bool isFlags, string fullTypeName, DateTimeOffset expiresAt, List members, List diagnostics) - { - IsFlags = isFlags; - FullTypeName = fullTypeName; - ExpiresAt = expiresAt; - Members = members; - Diagnostics = diagnostics; - } - } - - private sealed class MemberInfo - { - public string Name { get; } - public string? Description { get; } - - public MemberInfo(string name, string? description) - { - Name = name; - Description = description; - } - } - - private sealed class PartialClassInfo - { - public string ClassName { get; } - public string? NamespaceName { get; } - public string ConfigTypeFqn { get; } - public bool IsFlags { get; } - public List ContainingTypes { get; } - - public PartialClassInfo(string className, string? namespaceName, string configTypeFqn, bool isFlags, List containingTypes) - { - ClassName = className; - NamespaceName = namespaceName; - ConfigTypeFqn = configTypeFqn; - IsFlags = isFlags; - ContainingTypes = containingTypes; - } - } -} +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Cocoar.Configuration.Flags.Generator; + +[Generator] +public sealed class CocoarFlagsGenerator : IIncrementalGenerator +{ + private const string IFeatureFlagsFqn = "Cocoar.Configuration.Flags.IFeatureFlags"; + private const string IEntitlementsFqn = "Cocoar.Configuration.Flags.IEntitlements"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // ── Pipeline 1: Descriptor generation (existing) ──────────────────── + var classInfos = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => + node is InvocationExpressionSyntax inv + && inv.Expression is MemberAccessExpressionSyntax { Name: GenericNameSyntax g } + && g.Identifier.Text == "Register" + && g.TypeArgumentList.Arguments.Count == 1, + transform: static (ctx, ct) => ExtractClassInfoFromInvocation(ctx, ct)) + .Where(static x => x is not null); + + var collected = classInfos.Collect(); + + context.RegisterSourceOutput(collected, static (spc, items) => + { + var validItems = items.Where(x => x is not null).ToList(); + if (validItems.Count == 0) return; + + // Emit diagnostics + foreach (var item in validItems) + { + foreach (var diag in item!.Diagnostics) + spc.ReportDiagnostic(diag); + } + + // Deduplicate by FullTypeName (same type may be registered multiple times) + var flagItems = validItems + .Where(x => x!.IsFlags) + .GroupBy(x => x!.FullTypeName) + .Select(g => g.First()) + .OrderBy(x => x!.FullTypeName, StringComparer.Ordinal) + .ToList(); + + var entitlementItems = validItems + .Where(x => !x!.IsFlags) + .GroupBy(x => x!.FullTypeName) + .Select(g => g.First()) + .OrderBy(x => x!.FullTypeName, StringComparer.Ordinal) + .ToList(); + + if (flagItems.Count == 0 && entitlementItems.Count == 0) return; + + var source = GenerateSource(flagItems!, entitlementItems!); + spc.AddSource("CocoarFlagsGenerated.g.cs", SourceText.From(source, Encoding.UTF8)); + }); + + // ── Pipeline 2: Partial class generation for IFeatureFlags / IEntitlements ── + var partialClassInfos = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => + node is ClassDeclarationSyntax cls + && cls.Modifiers.Any(SyntaxKind.PartialKeyword) + && cls.BaseList is not null, + transform: static (ctx, ct) => ExtractPartialClassInfo(ctx, ct)) + .Where(static x => x is not null); + + context.RegisterSourceOutput(partialClassInfos, static (spc, info) => + { + if (info is null) return; + var source = GeneratePartialClassSource(info); + spc.AddSource($"{info.ClassName}.g.cs", SourceText.From(source, Encoding.UTF8)); + }); + } + + private static ClassInfo? ExtractClassInfoFromInvocation(GeneratorSyntaxContext ctx, CancellationToken ct) + { + var invocation = (InvocationExpressionSyntax)ctx.Node; + var memberAccess = (MemberAccessExpressionSyntax)invocation.Expression; + var genericName = (GenericNameSyntax)memberAccess.Name; + var typeArgSyntax = genericName.TypeArgumentList.Arguments[0]; + + var typeSymbol = ctx.SemanticModel.GetTypeInfo(typeArgSyntax, ct).Type as INamedTypeSymbol; + if (typeSymbol == null) + return null; + + if (typeSymbol.IsAbstract) + { + // Return a ClassInfo with only a diagnostic — no members, no code generation + return new ClassInfo( + isFlags: ImplementsInterface(typeSymbol, IFeatureFlagsFqn), + fullTypeName: typeSymbol.ToDisplayString(), + expiresAt: DateTimeOffset.MinValue, + members: new List(), + diagnostics: new List + { + Diagnostic.Create( + FlagsGeneratorDiagnostics.AbstractTypeRegistered, + typeArgSyntax.GetLocation(), + typeSymbol.ToDisplayString()) + }); + } + + if (HasFlagProperties(typeSymbol) || ImplementsInterface(typeSymbol, IFeatureFlagsFqn)) + { + // Iterate ALL partial declarations to collect every FeatureFlag<> property + var allMembers = new List(); + var diagnostics = new List(); + var expiresAt = DateTimeOffset.MinValue; + var fullTypeName = typeSymbol.ToDisplayString(); + var foundSource = false; + + foreach (var syntaxRef in typeSymbol.DeclaringSyntaxReferences) + { + ct.ThrowIfCancellationRequested(); + if (syntaxRef.GetSyntax(ct) is not ClassDeclarationSyntax classDecl) continue; + foundSource = true; + var sm = ctx.SemanticModel.Compilation.GetSemanticModel(syntaxRef.SyntaxTree); + + // ExpiresAt: take from whichever partial declares it + if (expiresAt == DateTimeOffset.MinValue) + expiresAt = ExtractClassExpiresAt(classDecl, sm, fullTypeName, diagnostics); + + allMembers.AddRange(ExtractFlagProperties(classDecl, fullTypeName, diagnostics)); + } + + if (!foundSource) + { + // Type from a referenced assembly — no source available + return new ClassInfo(true, fullTypeName, DateTimeOffset.MinValue, + new List(), new List()); + } + + // Deduplicate by property name (same property may appear in multiple partials' trivia) + var dedupedMembers = allMembers + .GroupBy(m => m.Name) + .Select(g => g.First()) + .ToList(); + + return new ClassInfo(true, fullTypeName, expiresAt, dedupedMembers, diagnostics); + } + + if (HasEntitlementProperties(typeSymbol) || ImplementsInterface(typeSymbol, IEntitlementsFqn)) + { + // Iterate ALL partial declarations to collect every Entitlement<> property + var allMembers = new List(); + var diagnostics = new List(); + var fullTypeName = typeSymbol.ToDisplayString(); + var foundSource = false; + + foreach (var syntaxRef in typeSymbol.DeclaringSyntaxReferences) + { + ct.ThrowIfCancellationRequested(); + if (syntaxRef.GetSyntax(ct) is not ClassDeclarationSyntax classDecl) continue; + foundSource = true; + var sm = ctx.SemanticModel.Compilation.GetSemanticModel(syntaxRef.SyntaxTree); + allMembers.AddRange(ExtractEntitlementProperties(classDecl, fullTypeName, diagnostics)); + } + + if (!foundSource) + { + return new ClassInfo(false, fullTypeName, DateTimeOffset.MinValue, + new List(), new List()); + } + + var dedupedMembers = allMembers + .GroupBy(m => m.Name) + .Select(g => g.First()) + .ToList(); + + return new ClassInfo(false, fullTypeName, DateTimeOffset.MinValue, dedupedMembers, diagnostics); + } + + return null; + } + + private static bool ImplementsInterface(INamedTypeSymbol symbol, string interfaceFqn) + { + foreach (var iface in symbol.AllInterfaces) + { + var name = iface.IsGenericType + ? iface.ConstructedFrom.ToDisplayString() + : iface.ToDisplayString(); + if (name == interfaceFqn + "") + return true; + } + return false; + } + + /// + /// Checks whether the type has any FeatureFlag<> properties (used to detect + /// flag classes that don't implement IFeatureFlags<T> but use FeatureFlag properties directly). + /// + private static bool HasFlagProperties(INamedTypeSymbol symbol) + { + foreach (var member in symbol.GetMembers()) + { + if (member is IPropertySymbol prop && prop.Type is INamedTypeSymbol propType && propType.IsGenericType) + { + var typeName = propType.ConstructedFrom.Name; + if (typeName == "FeatureFlag") + return true; + } + } + return false; + } + + /// + /// Checks whether the type has any Entitlement<> properties (used to detect + /// entitlement classes that don't implement IEntitlements<T> but use Entitlement properties directly). + /// + private static bool HasEntitlementProperties(INamedTypeSymbol symbol) + { + foreach (var member in symbol.GetMembers()) + { + if (member is IPropertySymbol prop && prop.Type is INamedTypeSymbol propType && propType.IsGenericType) + { + var typeName = propType.ConstructedFrom.Name; + if (typeName == "Entitlement") + return true; + } + } + return false; + } + + // ─── FeatureFlags extraction ────────────────────────────────────────────── + + private static DateTimeOffset ExtractClassExpiresAt( + ClassDeclarationSyntax classDecl, + SemanticModel semanticModel, + string typeName, + List diagnostics) + { + foreach (var member in classDecl.Members) + { + if (member is not PropertyDeclarationSyntax prop) continue; + if (prop.Identifier.Text != "ExpiresAt") continue; + + ExpressionSyntax? expr = null; + if (prop.ExpressionBody?.Expression is not null) + expr = prop.ExpressionBody.Expression; + else if (prop.Initializer?.Value is not null) + expr = prop.Initializer.Value; + + if (expr != null) + { + var result = TryParseDateTimeOffsetLiteral(expr); + if (result.HasValue) return result.Value; + } + + // Could not statically determine + diagnostics.Add(Diagnostic.Create( + FlagsGeneratorDiagnostics.NonStaticExpiresAt, + prop.GetLocation(), + typeName)); + return DateTimeOffset.MinValue; + } + + // ExpiresAt not found in this partial — MinValue fallback + diagnostics.Add(Diagnostic.Create( + FlagsGeneratorDiagnostics.NonStaticExpiresAt, + classDecl.Identifier.GetLocation(), + typeName)); + return DateTimeOffset.MinValue; + } + + private static List ExtractFlagProperties( + ClassDeclarationSyntax classDecl, string fullTypeName, List diagnostics) + { + var flags = new List(); + foreach (var member in classDecl.Members.OfType()) + { + if (!IsFlagPropertyType(member.Type)) continue; + var description = GetXmlSummary(member); + if (description == null) + { + diagnostics.Add(Diagnostic.Create( + FlagsGeneratorDiagnostics.MissingPropertyDescription, + member.Identifier.GetLocation(), + member.Identifier.Text, + fullTypeName)); + } + flags.Add(new MemberInfo(member.Identifier.Text, description)); + } + return flags; + } + + // ─── Entitlements extraction ────────────────────────────────────────────── + + private static List ExtractEntitlementProperties( + ClassDeclarationSyntax classDecl, string fullTypeName, List diagnostics) + { + var entitlements = new List(); + foreach (var member in classDecl.Members.OfType()) + { + if (!IsEntitlementPropertyType(member.Type)) continue; + var description = GetXmlSummary(member); + if (description == null) + { + diagnostics.Add(Diagnostic.Create( + FlagsGeneratorDiagnostics.MissingPropertyDescription, + member.Identifier.GetLocation(), + member.Identifier.Text, + fullTypeName)); + } + entitlements.Add(new MemberInfo(member.Identifier.Text, description)); + } + return entitlements; + } + + // ─── XML doc helper ─────────────────────────────────────────────────────── + + /// + /// Extracts the text content of the <summary> XML doc comment on a property, + /// or returns null if no summary is present. + /// + private static string? GetXmlSummary(PropertyDeclarationSyntax prop) + { + var docTrivia = prop.GetLeadingTrivia() + .Select(t => t.GetStructure()) + .OfType() + .FirstOrDefault(); + + if (docTrivia is null) return null; + + var summary = docTrivia.ChildNodes() + .OfType() + .FirstOrDefault(e => e.StartTag.Name.LocalName.Text == "summary"); + + if (summary is null) return null; + + var text = string.Concat(summary.Content.OfType() + .SelectMany(t => t.TextTokens) + .Select(t => t.ValueText)); + + // Normalize: trim each line and join with a single space + var lines = text.Split('\n') + .Select(l => l.TrimStart().TrimStart('/').Trim()) + .Where(l => l.Length > 0); + + var result = string.Join(" ", lines); + return string.IsNullOrWhiteSpace(result) ? null : result; + } + + // ─── Syntax helpers ─────────────────────────────────────────────────────── + + private static bool IsFlagPropertyType(TypeSyntax typeSyntax) + => GetBaseTypeName(typeSyntax) == "FeatureFlag"; + + private static bool IsEntitlementPropertyType(TypeSyntax typeSyntax) + => GetBaseTypeName(typeSyntax) == "Entitlement"; + + private static string? GetBaseTypeName(TypeSyntax typeSyntax) => typeSyntax switch + { + GenericNameSyntax g => g.Identifier.Text, + QualifiedNameSyntax { Right: GenericNameSyntax gn } => gn.Identifier.Text, + _ => null + }; + + private static DateTimeOffset? TryParseDateTimeOffsetLiteral(ExpressionSyntax expr) + { + ArgumentListSyntax? args = expr switch + { + ObjectCreationExpressionSyntax oce => oce.ArgumentList, + ImplicitObjectCreationExpressionSyntax ioce => ioce.ArgumentList, + _ => null + }; + + if (args == null || args.Arguments.Count < 1) return null; + + // Handle: new DateTimeOffset(new DateTime(year, month, day, ...), TimeSpan.Zero) + var firstArg = args.Arguments[0].Expression; + if (firstArg is ObjectCreationExpressionSyntax innerOce + && IsDateTimeTypeName(innerOce.Type)) + { + return TryParseDateTimeLiteralArgs(innerOce.ArgumentList); + } + if (firstArg is ImplicitObjectCreationExpressionSyntax && args.Arguments.Count >= 2) + { + // new DateTimeOffset(new(...), TimeSpan.Zero) — can't verify type without semantic model, + // but if the outer type is DateTimeOffset and first arg is implicit new with 3+ int args, try it + if (firstArg is ImplicitObjectCreationExpressionSyntax innerIoce) + return TryParseDateTimeLiteralArgs(innerIoce.ArgumentList); + } + + if (args.Arguments.Count < 3) return null; + + // Handle: new DateTimeOffset(year, month, day, h, m, s, TimeSpan.Zero) + var ints = new List(); + foreach (var arg in args.Arguments) + { + if (arg.Expression is LiteralExpressionSyntax lit + && lit.Token.Value is int v) + { + ints.Add(v); + } + else + { + // Non-integer argument — could be TimeSpan.Zero at position 6 or 3 + // We stop collecting ints but still try to parse if we have 3+ + break; + } + } + + if (ints.Count < 3) return null; + + try + { + return ints.Count >= 6 + ? new DateTimeOffset(ints[0], ints[1], ints[2], ints[3], ints[4], ints[5], TimeSpan.Zero) + : ints.Count == 3 + ? new DateTimeOffset(ints[0], ints[1], ints[2], 0, 0, 0, TimeSpan.Zero) + : (DateTimeOffset?)null; + } + catch + { + return null; + } + } + + private static DateTimeOffset? TryParseDateTimeLiteralArgs(ArgumentListSyntax? args) + { + if (args == null || args.Arguments.Count < 3) return null; + + var ints = new List(); + foreach (var arg in args.Arguments) + { + if (arg.Expression is LiteralExpressionSyntax lit && lit.Token.Value is int v) + ints.Add(v); + else + break; + } + + if (ints.Count < 3) return null; + + try + { + return ints.Count >= 6 + ? new DateTimeOffset(ints[0], ints[1], ints[2], ints[3], ints[4], ints[5], TimeSpan.Zero) + : new DateTimeOffset(ints[0], ints[1], ints[2], 0, 0, 0, TimeSpan.Zero); + } + catch + { + return null; + } + } + + private static bool IsDateTimeTypeName(TypeSyntax type) => type switch + { + IdentifierNameSyntax id => id.Identifier.Text == "DateTime", + QualifiedNameSyntax q => q.Right.Identifier.Text == "DateTime", + _ => false + }; + + private static string EscapeString(string s) + { + return s + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r") + .Replace("\t", "\\t") + .Replace("\0", "\\0"); + } + + // ─── Code generation ────────────────────────────────────────────────────── + + private static string GenerateSource(List flagClasses, List entitlementClasses) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("namespace Cocoar.Configuration.Flags.Generated"); + sb.AppendLine("{"); + sb.AppendLine(" internal static class CocoarFlagsDescriptors"); + sb.AppendLine(" {"); + + // Flags dictionary + sb.AppendLine(" internal static readonly global::System.Collections.Generic.IReadOnlyDictionary<"); + sb.AppendLine(" global::System.Type,"); + sb.AppendLine(" global::Cocoar.Configuration.Flags.FeatureFlagClassDescriptor> Flags ="); + sb.AppendLine(" new global::System.Collections.Generic.Dictionary<"); + sb.AppendLine(" global::System.Type,"); + sb.AppendLine(" global::Cocoar.Configuration.Flags.FeatureFlagClassDescriptor>"); + sb.AppendLine(" {"); + foreach (var cls in flagClasses) + { + sb.AppendLine(" {"); + sb.AppendLine($" typeof(global::{cls.FullTypeName}),"); + sb.AppendLine($" new global::Cocoar.Configuration.Flags.FeatureFlagClassDescriptor("); + sb.AppendLine($" Type: typeof(global::{cls.FullTypeName}),"); + sb.AppendLine($" ExpiresAt: {RenderDateTimeOffset(cls.ExpiresAt)},"); + sb.AppendLine(" Flags: new global::Cocoar.Configuration.Flags.FlagDefinitionDescriptor[]"); + sb.AppendLine(" {"); + foreach (var flag in cls.Members) + { + var descArg = flag.Description != null + ? $"\"{EscapeString(flag.Description)}\"" + : "null"; + sb.AppendLine($" new(\"{flag.Name}\", {descArg}),"); + } + sb.AppendLine(" })"); + sb.AppendLine(" },"); + } + sb.AppendLine(" };"); + sb.AppendLine(); + + // Entitlements dictionary + sb.AppendLine(" internal static readonly global::System.Collections.Generic.IReadOnlyDictionary<"); + sb.AppendLine(" global::System.Type,"); + sb.AppendLine(" global::Cocoar.Configuration.Flags.EntitlementClassDescriptor> Entitlements ="); + sb.AppendLine(" new global::System.Collections.Generic.Dictionary<"); + sb.AppendLine(" global::System.Type,"); + sb.AppendLine(" global::Cocoar.Configuration.Flags.EntitlementClassDescriptor>"); + sb.AppendLine(" {"); + foreach (var cls in entitlementClasses) + { + sb.AppendLine(" {"); + sb.AppendLine($" typeof(global::{cls.FullTypeName}),"); + sb.AppendLine($" new global::Cocoar.Configuration.Flags.EntitlementClassDescriptor("); + sb.AppendLine($" Type: typeof(global::{cls.FullTypeName}),"); + sb.AppendLine(" Entitlements: new global::Cocoar.Configuration.Flags.EntitlementDefinitionDescriptor[]"); + sb.AppendLine(" {"); + foreach (var ent in cls.Members) + { + var descArg = ent.Description != null + ? $"\"{EscapeString(ent.Description)}\"" + : "null"; + sb.AppendLine($" new(\"{ent.Name}\", {descArg}),"); + } + sb.AppendLine(" })"); + sb.AppendLine(" },"); + } + sb.AppendLine(" };"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static string RenderDateTimeOffset(DateTimeOffset dto) + { + if (dto == DateTimeOffset.MinValue) + return "global::System.DateTimeOffset.MinValue"; + + return $"new global::System.DateTimeOffset({dto.Year}, {dto.Month}, {dto.Day}, {dto.Hour}, {dto.Minute}, {dto.Second}, global::System.TimeSpan.Zero)"; + } + + // ─── Pipeline 2: Partial class extraction ────────────────────────────── + + private static PartialClassInfo? ExtractPartialClassInfo(GeneratorSyntaxContext ctx, CancellationToken ct) + { + var classDecl = (ClassDeclarationSyntax)ctx.Node; + var symbol = ctx.SemanticModel.GetDeclaredSymbol(classDecl, ct) as INamedTypeSymbol; + if (symbol is null) return null; + + foreach (var iface in symbol.AllInterfaces) + { + if (!iface.IsGenericType || iface.TypeArguments.Length != 1) continue; + + var unboundName = iface.ConstructedFrom.ToDisplayString(); + bool isFlags = unboundName == IFeatureFlagsFqn + ""; + bool isEntitlements = unboundName == IEntitlementsFqn + ""; + if (!isFlags && !isEntitlements) continue; + + var configTypeArg = iface.TypeArguments[0]; + var configTypeFqn = configTypeArg.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var containingNamespace = symbol.ContainingNamespace.IsGlobalNamespace + ? null + : symbol.ContainingNamespace.ToDisplayString(); + + // Collect containing type hierarchy for nested classes + var containingTypes = new List(); + var outer = symbol.ContainingType; + while (outer is not null) + { + containingTypes.Insert(0, outer.Name); + outer = outer.ContainingType; + } + + return new PartialClassInfo( + className: symbol.Name, + namespaceName: containingNamespace, + configTypeFqn: configTypeFqn, + isFlags: isFlags, + containingTypes: containingTypes); + } + + return null; + } + + private static string GeneratePartialClassSource(PartialClassInfo info) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var indentLevel = 0; + + if (info.NamespaceName is not null) + { + sb.AppendLine($"namespace {info.NamespaceName}"); + sb.AppendLine("{"); + indentLevel++; + } + + // Open containing types (for nested classes) + foreach (var containingType in info.ContainingTypes) + { + var outerIndent = new string(' ', indentLevel * 4); + sb.AppendLine($"{outerIndent}partial class {containingType}"); + sb.AppendLine($"{outerIndent}{{"); + indentLevel++; + } + + var indent = new string(' ', indentLevel * 4); + var memberIndent = new string(' ', (indentLevel + 1) * 4); + + sb.AppendLine($"{indent}partial class {info.ClassName}"); + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{memberIndent}private readonly global::Cocoar.Configuration.Reactive.IReactiveConfig<{info.ConfigTypeFqn}> _reactive;"); + sb.AppendLine(); + sb.AppendLine($"{memberIndent}protected {info.ConfigTypeFqn} Config => _reactive.CurrentValue;"); + sb.AppendLine(); + + if (info.IsFlags) + { + sb.AppendLine($"{memberIndent}/// "); + sb.AppendLine($"{memberIndent}/// Is this feature flag class past its expiration?"); + sb.AppendLine($"{memberIndent}/// When true, the flags still work but the code should be cleaned up."); + sb.AppendLine($"{memberIndent}/// "); + sb.AppendLine($"{memberIndent}public bool IsExpired => global::System.DateTimeOffset.UtcNow > ExpiresAt;"); + sb.AppendLine(); + } + + sb.AppendLine($"{memberIndent}public {info.ClassName}(global::Cocoar.Configuration.Reactive.IReactiveConfig<{info.ConfigTypeFqn}> reactive)"); + sb.AppendLine($"{memberIndent}{{"); + sb.AppendLine($"{memberIndent} _reactive = reactive;"); + sb.AppendLine($"{memberIndent}}}"); + sb.AppendLine($"{indent}}}"); + + // Close containing types + for (var i = info.ContainingTypes.Count - 1; i >= 0; i--) + { + indentLevel--; + var outerIndent = new string(' ', indentLevel * 4); + sb.AppendLine($"{outerIndent}}}"); + } + + if (info.NamespaceName is not null) + { + sb.AppendLine("}"); + } + + return sb.ToString(); + } + + // ─── Data models ────────────────────────────────────────────────────────── + + private sealed class ClassInfo + { + public bool IsFlags { get; } + public string FullTypeName { get; } + public DateTimeOffset ExpiresAt { get; } + public List Members { get; } + public List Diagnostics { get; } + + public ClassInfo(bool isFlags, string fullTypeName, DateTimeOffset expiresAt, List members, List diagnostics) + { + IsFlags = isFlags; + FullTypeName = fullTypeName; + ExpiresAt = expiresAt; + Members = members; + Diagnostics = diagnostics; + } + } + + private sealed class MemberInfo + { + public string Name { get; } + public string? Description { get; } + + public MemberInfo(string name, string? description) + { + Name = name; + Description = description; + } + } + + private sealed class PartialClassInfo + { + public string ClassName { get; } + public string? NamespaceName { get; } + public string ConfigTypeFqn { get; } + public bool IsFlags { get; } + public List ContainingTypes { get; } + + public PartialClassInfo(string className, string? namespaceName, string configTypeFqn, bool isFlags, List containingTypes) + { + ClassName = className; + NamespaceName = namespaceName; + ConfigTypeFqn = configTypeFqn; + IsFlags = isFlags; + ContainingTypes = containingTypes; + } + } +} diff --git a/src/Cocoar.Configuration.Analyzers/Flags/FlagsGeneratorDiagnostics.cs b/src/Cocoar.Configuration.Analyzers/Flags/FlagsGeneratorDiagnostics.cs index 2126bfd..888bd28 100644 --- a/src/Cocoar.Configuration.Analyzers/Flags/FlagsGeneratorDiagnostics.cs +++ b/src/Cocoar.Configuration.Analyzers/Flags/FlagsGeneratorDiagnostics.cs @@ -1,45 +1,45 @@ -using Microsoft.CodeAnalysis; - -namespace Cocoar.Configuration.Flags.Generator; - -internal static class FlagsGeneratorDiagnostics -{ - private const string Category = "CocoarFlags"; - - /// - /// Emitted when ExpiresAt is not a statically determinable DateTimeOffset literal. - /// The class will be included but ExpiresAt will default to DateTimeOffset.MinValue. - /// - public static readonly DiagnosticDescriptor NonStaticExpiresAt = new( - id: "COCFLAG001", - title: "Non-static ExpiresAt", - messageFormat: "'{0}.ExpiresAt' could not be statically determined. The class will be registered with ExpiresAt = DateTimeOffset.MinValue (treated as expired). Use a DateTimeOffset literal: new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero).", - category: Category, - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - /// - /// Emitted when Register<T>() is called with an abstract type. - /// Abstract classes cannot be instantiated as flag/entitlement classes. - /// - public static readonly DiagnosticDescriptor AbstractTypeRegistered = new( - id: "COCFLAG002", - title: "Abstract type registered", - messageFormat: "'{0}' is abstract and cannot be used with Register(). Use a concrete subclass instead.", - category: Category, - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - /// - /// Emitted when a FeatureFlag or Entitlement property has no <summary> XML doc comment. - /// Descriptions are surfaced through IFeatureFlagsDescriptors / IEntitlementsDescriptors - /// and help operators understand what each flag controls. - /// - public static readonly DiagnosticDescriptor MissingPropertyDescription = new( - id: "COCFLAG003", - title: "Missing flag/entitlement description", - messageFormat: "Property '{0}' on '{1}' has no XML doc comment. Add a description so it appears in flag/entitlement descriptors.", - category: Category, - defaultSeverity: DiagnosticSeverity.Info, - isEnabledByDefault: true); -} +using Microsoft.CodeAnalysis; + +namespace Cocoar.Configuration.Flags.Generator; + +internal static class FlagsGeneratorDiagnostics +{ + private const string Category = "CocoarFlags"; + + /// + /// Emitted when ExpiresAt is not a statically determinable DateTimeOffset literal. + /// The class will be included but ExpiresAt will default to DateTimeOffset.MinValue. + /// + public static readonly DiagnosticDescriptor NonStaticExpiresAt = new( + id: "COCFLAG001", + title: "Non-static ExpiresAt", + messageFormat: "'{0}.ExpiresAt' could not be statically determined. The class will be registered with ExpiresAt = DateTimeOffset.MinValue (treated as expired). Use a DateTimeOffset literal: new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero).", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + /// + /// Emitted when Register<T>() is called with an abstract type. + /// Abstract classes cannot be instantiated as flag/entitlement classes. + /// + public static readonly DiagnosticDescriptor AbstractTypeRegistered = new( + id: "COCFLAG002", + title: "Abstract type registered", + messageFormat: "'{0}' is abstract and cannot be used with Register(). Use a concrete subclass instead.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + /// + /// Emitted when a FeatureFlag or Entitlement property has no <summary> XML doc comment. + /// Descriptions are surfaced through IFeatureFlagsDescriptors / IEntitlementsDescriptors + /// and help operators understand what each flag controls. + /// + public static readonly DiagnosticDescriptor MissingPropertyDescription = new( + id: "COCFLAG003", + title: "Missing flag/entitlement description", + messageFormat: "Property '{0}' on '{1}' has no XML doc comment. Add a description so it appears in flag/entitlement descriptors.", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true); +} diff --git a/src/Cocoar.Configuration.Analyzers/README.md b/src/Cocoar.Configuration.Analyzers/README.md index e56d2c8..1733da9 100644 --- a/src/Cocoar.Configuration.Analyzers/README.md +++ b/src/Cocoar.Configuration.Analyzers/README.md @@ -1,119 +1,119 @@ -# Cocoar.Configuration.Analyzers - -Roslyn analyzers for Cocoar.Configuration that provide **compile-time validation** of configuration rules. - -## Features - -- **COCFG001**: Detects secret property path conflicts -- **COCFG002**: Validates rule dependency ordering -- **COCFG003**: Checks required rule configuration -- **COCFG005**: Detects duplicate unconditional rules (last-write-wins warning) -- **COCFG006**: Static provider ordering suggestions -- **Compile-time diagnostics**: Catches configuration mistakes before runtime - -## Installation - -```xml - - all - runtime; build; native; contentfiles; analyzers - -``` - -## Benefits - -✅ **Compile-time validation** - Catch configuration errors while coding -✅ **IDE integration** - Red squiggles in Visual Studio, Rider, VS Code -✅ **Zero runtime cost** - No reflection or analysis at startup -✅ **Actionable messages** - Clear guidance on how to fix each issue -✅ **CI/CD integration** - Build fails on misconfiguration - -## Diagnostics - -### COCFG001: Secret Path Conflict - -**Severity:** Warning - -Detects when a non-secret property has the same path as a secret property. - -```csharp -// ❌ Warning: -rule.For().FromFile("app.json").Select("Database.Password"), -rule.For().FromFile("secrets.json") -// COCFG001: Property 'Password' conflicts with secret 'Secrets.ConnectionString' -``` - -### COCFG002: Rule Dependency Ordering - -**Severity:** Error - -Validates that rules appear after their dependencies. - -```csharp -// ❌ Error: -rule.For() - .When(accessor => accessor.GetRequiredConfig().IsEnabled), -rule.For().FromFile("api.json"), -// COCFG002: ApiSettings not available - move this rule after ApiSettings rule -``` - -### COCFG003: Required Rule Validation - -**Severity:** Warning - -Checks required rules have valid configuration. - -```csharp -// ❌ Warning: -rule.For() - .FromFile("missing.json") - .Required() -// COCFG003: File 'missing.json' not found - app will fail to start -``` - -### COCFG005: Duplicate Unconditional Rules - -**Severity:** Info - -Warns when multiple rules configure the same type without conditions (last-write-wins). - -```csharp -// ℹ️ Info: -rule.For().FromFile("appsettings.json"), -rule.For().FromFile("appsettings.override.json"), -// COCFG005: Multiple unconditional rules for type 'AppSettings'. -// Last rule will override earlier rules. -``` - -### COCFG006: Static Provider Ordering - -**Severity:** Info - -Suggests moving static/seed rules before dynamic rules that may depend on them. - -```csharp -// ℹ️ Info: -rule.For().FromFile("api.json"), -rule.For().FromStatic("""{"Feature1": true}"""), -// COCFG006: Static rule found after dynamic rules. -// Consider moving static rules first. -``` - -## Migration from Runtime Analysis - -The analyzer replaces runtime `ConfigurationAnalyzer`: - -**Before (Runtime):** -```csharp -ConfigurationAnalyzer.AnalyzeDependencies(rules, logger, accessor); // At startup -``` - -**After (Compile-time):** -```xml - - -``` - -## License - -Apache 2.0 - See [LICENSE](https://github.com/cocoar-dev/cocoar.configuration/blob/develop/LICENSE) +# Cocoar.Configuration.Analyzers + +Roslyn analyzers for Cocoar.Configuration that provide **compile-time validation** of configuration rules. + +## Features + +- **COCFG001**: Detects secret property path conflicts +- **COCFG002**: Validates rule dependency ordering +- **COCFG003**: Checks required rule configuration +- **COCFG005**: Detects duplicate unconditional rules (last-write-wins warning) +- **COCFG006**: Static provider ordering suggestions +- **Compile-time diagnostics**: Catches configuration mistakes before runtime + +## Installation + +```xml + + all + runtime; build; native; contentfiles; analyzers + +``` + +## Benefits + +✅ **Compile-time validation** - Catch configuration errors while coding +✅ **IDE integration** - Red squiggles in Visual Studio, Rider, VS Code +✅ **Zero runtime cost** - No reflection or analysis at startup +✅ **Actionable messages** - Clear guidance on how to fix each issue +✅ **CI/CD integration** - Build fails on misconfiguration + +## Diagnostics + +### COCFG001: Secret Path Conflict + +**Severity:** Warning + +Detects when a non-secret property has the same path as a secret property. + +```csharp +// ❌ Warning: +rule.For().FromFile("app.json").Select("Database.Password"), +rule.For().FromFile("secrets.json") +// COCFG001: Property 'Password' conflicts with secret 'Secrets.ConnectionString' +``` + +### COCFG002: Rule Dependency Ordering + +**Severity:** Error + +Validates that rules appear after their dependencies. + +```csharp +// ❌ Error: +rule.For() + .When(accessor => accessor.GetRequiredConfig().IsEnabled), +rule.For().FromFile("api.json"), +// COCFG002: ApiSettings not available - move this rule after ApiSettings rule +``` + +### COCFG003: Required Rule Validation + +**Severity:** Warning + +Checks required rules have valid configuration. + +```csharp +// ❌ Warning: +rule.For() + .FromFile("missing.json") + .Required() +// COCFG003: File 'missing.json' not found - app will fail to start +``` + +### COCFG005: Duplicate Unconditional Rules + +**Severity:** Info + +Warns when multiple rules configure the same type without conditions (last-write-wins). + +```csharp +// ℹ️ Info: +rule.For().FromFile("appsettings.json"), +rule.For().FromFile("appsettings.override.json"), +// COCFG005: Multiple unconditional rules for type 'AppSettings'. +// Last rule will override earlier rules. +``` + +### COCFG006: Static Provider Ordering + +**Severity:** Info + +Suggests moving static/seed rules before dynamic rules that may depend on them. + +```csharp +// ℹ️ Info: +rule.For().FromFile("api.json"), +rule.For().FromStatic("""{"Feature1": true}"""), +// COCFG006: Static rule found after dynamic rules. +// Consider moving static rules first. +``` + +## Migration from Runtime Analysis + +The analyzer replaces runtime `ConfigurationAnalyzer`: + +**Before (Runtime):** +```csharp +ConfigurationAnalyzer.AnalyzeDependencies(rules, logger, accessor); // At startup +``` + +**After (Compile-time):** +```xml + + +``` + +## License + +Apache 2.0 - See [LICENSE](https://github.com/cocoar-dev/cocoar.configuration/blob/develop/LICENSE) diff --git a/src/Cocoar.Configuration.AspNetCore/Cocoar.Configuration.AspNetCore.csproj b/src/Cocoar.Configuration.AspNetCore/Cocoar.Configuration.AspNetCore.csproj index 24acb8b..61e132d 100644 --- a/src/Cocoar.Configuration.AspNetCore/Cocoar.Configuration.AspNetCore.csproj +++ b/src/Cocoar.Configuration.AspNetCore/Cocoar.Configuration.AspNetCore.csproj @@ -1,17 +1,17 @@ - - - - true - ASP.NET Core integration for Cocoar.Configuration. Health check endpoints, feature flag and entitlement REST evaluation endpoints, and WebApplicationBuilder extensions. - configuration;aspnetcore;health-checks;feature-flags;entitlements;rest-api;web - - - - - - - - - - - + + + + true + ASP.NET Core integration for Cocoar.Configuration. Health check endpoints, feature flag and entitlement REST evaluation endpoints, and WebApplicationBuilder extensions. + configuration;aspnetcore;health-checks;feature-flags;entitlements;rest-api;web + + + + + + + + + + + diff --git a/src/Cocoar.Configuration.AspNetCore/CocoarConfigurationAspNetCoreExtensions.cs b/src/Cocoar.Configuration.AspNetCore/CocoarConfigurationAspNetCoreExtensions.cs index 2159e0d..67e9016 100644 --- a/src/Cocoar.Configuration.AspNetCore/CocoarConfigurationAspNetCoreExtensions.cs +++ b/src/Cocoar.Configuration.AspNetCore/CocoarConfigurationAspNetCoreExtensions.cs @@ -1,49 +1,49 @@ -using System.Runtime.CompilerServices; -using Microsoft.AspNetCore.Builder; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.DI; - -namespace Cocoar.Configuration.AspNetCore; - -public static class CocoarConfigurationAspNetCoreExtensions -{ - private static readonly ConditionalWeakTable _registrations = new(); - - /// - /// Adds Cocoar configuration to the WebApplicationBuilder using the builder API. - /// Provides access to the full ConfigManagerBuilder for configuring rules, setup, secrets, logging, etc. - /// Test configuration overrides are automatically applied via ConfigManager when CocoarTestConfiguration is active. - /// - public static WebApplicationBuilder AddCocoarConfiguration( - this WebApplicationBuilder builder, - Action configure) - { - var configManager = ConfigManager.Create(configure); - - builder.Services.AddCocoarConfiguration(configManager); - _registrations.AddOrUpdate(builder, configManager); - - return builder; - } - - public static WebApplicationBuilder AddCocoarConfiguration( - this WebApplicationBuilder builder, - ConfigManager configManager) - { - builder.Services.AddCocoarConfiguration(configManager); - _registrations.AddOrUpdate(builder, configManager); - - return builder; - } - - public static ConfigManager GetCocoarConfigManager(this WebApplicationBuilder builder) - { - if (!_registrations.TryGetValue(builder, out var configManager)) - { - throw new InvalidOperationException( - "No ConfigManager has been registered for this WebApplicationBuilder. " + - "Call AddCocoarConfiguration() before GetCocoarConfigManager()."); - } - return configManager; - } -} +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Builder; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; + +namespace Cocoar.Configuration.AspNetCore; + +public static class CocoarConfigurationAspNetCoreExtensions +{ + private static readonly ConditionalWeakTable _registrations = new(); + + /// + /// Adds Cocoar configuration to the WebApplicationBuilder using the builder API. + /// Provides access to the full ConfigManagerBuilder for configuring rules, setup, secrets, logging, etc. + /// Test configuration overrides are automatically applied via ConfigManager when CocoarTestConfiguration is active. + /// + public static WebApplicationBuilder AddCocoarConfiguration( + this WebApplicationBuilder builder, + Action configure) + { + var configManager = ConfigManager.Create(configure); + + builder.Services.AddCocoarConfiguration(configManager); + _registrations.AddOrUpdate(builder, configManager); + + return builder; + } + + public static WebApplicationBuilder AddCocoarConfiguration( + this WebApplicationBuilder builder, + ConfigManager configManager) + { + builder.Services.AddCocoarConfiguration(configManager); + _registrations.AddOrUpdate(builder, configManager); + + return builder; + } + + public static ConfigManager GetCocoarConfigManager(this WebApplicationBuilder builder) + { + if (!_registrations.TryGetValue(builder, out var configManager)) + { + throw new InvalidOperationException( + "No ConfigManager has been registered for this WebApplicationBuilder. " + + "Call AddCocoarConfiguration() before GetCocoarConfigManager()."); + } + return configManager; + } +} diff --git a/src/Cocoar.Configuration.AspNetCore/FlagEvaluationExtensions.cs b/src/Cocoar.Configuration.AspNetCore/FlagEvaluationExtensions.cs index c2a0d03..96b5394 100644 --- a/src/Cocoar.Configuration.AspNetCore/FlagEvaluationExtensions.cs +++ b/src/Cocoar.Configuration.AspNetCore/FlagEvaluationExtensions.cs @@ -1,193 +1,193 @@ -using System.Reflection; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Flags; -using Cocoar.Configuration.Flags.Internal; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; - -namespace Cocoar.Configuration.AspNetCore; - -/// -/// Extension methods for mapping auto-generated REST evaluation endpoints for registered -/// feature flags and entitlements. -/// -public static class FlagEvaluationExtensions -{ - private static readonly Type FlagNoContextDef = typeof(FeatureFlag<>); - private static readonly Type EntitlementNoContextDef = typeof(Entitlement<>); - - /// - /// Maps REST evaluation endpoints for all registered feature flag classes. - /// Returns a so callers can chain ASP.NET Core - /// endpoint conventions such as authorization, rate limiting, or CORS. - /// - /// The endpoint route builder (e.g. WebApplication). - /// URL prefix for all generated endpoints. Defaults to /flags. - /// - /// A scoped to for further - /// configuration (e.g. .RequireAuthorization()). - /// - /// - /// - /// // Unsecured (development / internal only): - /// app.MapFeatureFlagEndpoints(); - /// - /// // Secured with an authorization policy: - /// app.MapFeatureFlagEndpoints() - /// .RequireAuthorization("AdminPolicy"); - /// - /// // GET /flags/AppFeatureFlags/NewDashboardEnabled -> { "value": true } - /// // POST /flags/AppFeatureFlags/NewDashboardForUser -> body { "userId": "beta_123" } -> { "value": true } - /// - /// - public static RouteGroupBuilder MapFeatureFlagEndpoints( - this IEndpointRouteBuilder app, - string pathPrefix = "/flags") - { - var group = app.MapGroup(pathPrefix); - - var services = ((IApplicationBuilder)app).ApplicationServices; - var configManager = services.GetRequiredService(); - - if (configManager.FlagsSetup is not { } capability) - return group; - - // GET endpoints — no-context flags: direct invocation, no resolver needed - foreach (var registration in capability.Registrations) - { - var flagClassType = registration.Descriptor.Type; - var className = flagClassType.Name; - - foreach (var prop in flagClassType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - if (!prop.PropertyType.IsGenericType) continue; - if (prop.PropertyType.GetGenericTypeDefinition() != FlagNoContextDef) continue; - - var capturedProp = prop; - var capturedType = flagClassType; - group.MapGet($"{className}/{prop.Name}", (IServiceProvider sp) => - { - try - { - var flagClass = sp.GetRequiredService(capturedType); - var del = (Delegate)capturedProp.GetValue(flagClass)!; - return Results.Json(new { value = del.DynamicInvoke() }); - } - catch (TargetInvocationException ex) when (ex.InnerException is not null) - { - return Results.Problem( - detail: ex.InnerException.Message, - title: "FeatureFlag evaluation failed", - statusCode: 500); - } - }); - } - } - - // POST endpoints — contextual flags: body deserialized to TRequest, then delegated to IFeatureFlagEvaluator. - foreach (var (key, entry) in capability.EvaluationEntries) - { - var capturedKey = key; - var requestType = entry.Resolver.RequestType; - - group.MapPost(capturedKey, async (HttpContext ctx, IFeatureFlagEvaluator evaluator) => - { - var request = await ctx.Request.ReadFromJsonAsync(requestType, ctx.RequestAborted); - if (request is null) - return Results.BadRequest("Unable to deserialize request body."); - - var result = await evaluator.EvaluateAsync(capturedKey, request, ctx.RequestAborted); - return Results.Json(new { value = result }); - }); - } - - return group; - } - - /// - /// Maps REST evaluation endpoints for all registered entitlement classes. - /// Returns a so callers can chain ASP.NET Core - /// endpoint conventions such as authorization, rate limiting, or CORS. - /// - /// The endpoint route builder (e.g. WebApplication). - /// URL prefix for all generated endpoints. Defaults to /entitlements. - /// - /// A scoped to for further - /// configuration (e.g. .RequireAuthorization()). - /// - /// - /// - /// // Secured: - /// app.MapEntitlementEndpoints() - /// .RequireAuthorization("AdminPolicy"); - /// - /// // GET /entitlements/PlanEntitlements/MaxUsers -> { "value": 100 } - /// // POST /entitlements/PlanEntitlements/MaxUsersForTenant -> body { "tenantId": "t_123" } -> { "value": 50 } - /// - /// - public static RouteGroupBuilder MapEntitlementEndpoints( - this IEndpointRouteBuilder app, - string pathPrefix = "/entitlements") - { - var group = app.MapGroup(pathPrefix); - - var services = ((IApplicationBuilder)app).ApplicationServices; - var configManager = services.GetRequiredService(); - - if (configManager.EntitlementsSetup is not { } capability) - return group; - - // GET endpoints — no-context entitlements: direct invocation, no resolver needed - foreach (var registration in capability.Registrations) - { - var entitlementClassType = registration.Descriptor.Type; - var className = entitlementClassType.Name; - - foreach (var prop in entitlementClassType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - if (!prop.PropertyType.IsGenericType) continue; - if (prop.PropertyType.GetGenericTypeDefinition() != EntitlementNoContextDef) continue; - - var capturedProp = prop; - var capturedType = entitlementClassType; - group.MapGet($"{className}/{prop.Name}", (IServiceProvider sp) => - { - try - { - var entitlementClass = sp.GetRequiredService(capturedType); - var del = (Delegate)capturedProp.GetValue(entitlementClass)!; - return Results.Json(new { value = del.DynamicInvoke() }); - } - catch (TargetInvocationException ex) when (ex.InnerException is not null) - { - return Results.Problem( - detail: ex.InnerException.Message, - title: "Entitlement evaluation failed", - statusCode: 500); - } - }); - } - } - - // POST endpoints — contextual entitlements: body deserialized to TRequest, then delegated to IEntitlementEvaluator. - foreach (var (key, entry) in capability.EvaluationEntries) - { - var capturedKey = key; - var requestType = entry.Resolver.RequestType; - - group.MapPost(capturedKey, async (HttpContext ctx, IEntitlementEvaluator evaluator) => - { - var request = await ctx.Request.ReadFromJsonAsync(requestType, ctx.RequestAborted); - if (request is null) - return Results.BadRequest("Unable to deserialize request body."); - - var result = await evaluator.EvaluateAsync(capturedKey, request, ctx.RequestAborted); - return Results.Json(new { value = result }); - }); - } - - return group; - } -} +using System.Reflection; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Flags; +using Cocoar.Configuration.Flags.Internal; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.AspNetCore; + +/// +/// Extension methods for mapping auto-generated REST evaluation endpoints for registered +/// feature flags and entitlements. +/// +public static class FlagEvaluationExtensions +{ + private static readonly Type FlagNoContextDef = typeof(FeatureFlag<>); + private static readonly Type EntitlementNoContextDef = typeof(Entitlement<>); + + /// + /// Maps REST evaluation endpoints for all registered feature flag classes. + /// Returns a so callers can chain ASP.NET Core + /// endpoint conventions such as authorization, rate limiting, or CORS. + /// + /// The endpoint route builder (e.g. WebApplication). + /// URL prefix for all generated endpoints. Defaults to /flags. + /// + /// A scoped to for further + /// configuration (e.g. .RequireAuthorization()). + /// + /// + /// + /// // Unsecured (development / internal only): + /// app.MapFeatureFlagEndpoints(); + /// + /// // Secured with an authorization policy: + /// app.MapFeatureFlagEndpoints() + /// .RequireAuthorization("AdminPolicy"); + /// + /// // GET /flags/AppFeatureFlags/NewDashboardEnabled -> { "value": true } + /// // POST /flags/AppFeatureFlags/NewDashboardForUser -> body { "userId": "beta_123" } -> { "value": true } + /// + /// + public static RouteGroupBuilder MapFeatureFlagEndpoints( + this IEndpointRouteBuilder app, + string pathPrefix = "/flags") + { + var group = app.MapGroup(pathPrefix); + + var services = ((IApplicationBuilder)app).ApplicationServices; + var configManager = services.GetRequiredService(); + + if (configManager.FlagsSetup is not { } capability) + return group; + + // GET endpoints — no-context flags: direct invocation, no resolver needed + foreach (var registration in capability.Registrations) + { + var flagClassType = registration.Descriptor.Type; + var className = flagClassType.Name; + + foreach (var prop in flagClassType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!prop.PropertyType.IsGenericType) continue; + if (prop.PropertyType.GetGenericTypeDefinition() != FlagNoContextDef) continue; + + var capturedProp = prop; + var capturedType = flagClassType; + group.MapGet($"{className}/{prop.Name}", (IServiceProvider sp) => + { + try + { + var flagClass = sp.GetRequiredService(capturedType); + var del = (Delegate)capturedProp.GetValue(flagClass)!; + return Results.Json(new { value = del.DynamicInvoke() }); + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + return Results.Problem( + detail: ex.InnerException.Message, + title: "FeatureFlag evaluation failed", + statusCode: 500); + } + }); + } + } + + // POST endpoints — contextual flags: body deserialized to TRequest, then delegated to IFeatureFlagEvaluator. + foreach (var (key, entry) in capability.EvaluationEntries) + { + var capturedKey = key; + var requestType = entry.Resolver.RequestType; + + group.MapPost(capturedKey, async (HttpContext ctx, IFeatureFlagEvaluator evaluator) => + { + var request = await ctx.Request.ReadFromJsonAsync(requestType, ctx.RequestAborted); + if (request is null) + return Results.BadRequest("Unable to deserialize request body."); + + var result = await evaluator.EvaluateAsync(capturedKey, request, ctx.RequestAborted); + return Results.Json(new { value = result }); + }); + } + + return group; + } + + /// + /// Maps REST evaluation endpoints for all registered entitlement classes. + /// Returns a so callers can chain ASP.NET Core + /// endpoint conventions such as authorization, rate limiting, or CORS. + /// + /// The endpoint route builder (e.g. WebApplication). + /// URL prefix for all generated endpoints. Defaults to /entitlements. + /// + /// A scoped to for further + /// configuration (e.g. .RequireAuthorization()). + /// + /// + /// + /// // Secured: + /// app.MapEntitlementEndpoints() + /// .RequireAuthorization("AdminPolicy"); + /// + /// // GET /entitlements/PlanEntitlements/MaxUsers -> { "value": 100 } + /// // POST /entitlements/PlanEntitlements/MaxUsersForTenant -> body { "tenantId": "t_123" } -> { "value": 50 } + /// + /// + public static RouteGroupBuilder MapEntitlementEndpoints( + this IEndpointRouteBuilder app, + string pathPrefix = "/entitlements") + { + var group = app.MapGroup(pathPrefix); + + var services = ((IApplicationBuilder)app).ApplicationServices; + var configManager = services.GetRequiredService(); + + if (configManager.EntitlementsSetup is not { } capability) + return group; + + // GET endpoints — no-context entitlements: direct invocation, no resolver needed + foreach (var registration in capability.Registrations) + { + var entitlementClassType = registration.Descriptor.Type; + var className = entitlementClassType.Name; + + foreach (var prop in entitlementClassType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!prop.PropertyType.IsGenericType) continue; + if (prop.PropertyType.GetGenericTypeDefinition() != EntitlementNoContextDef) continue; + + var capturedProp = prop; + var capturedType = entitlementClassType; + group.MapGet($"{className}/{prop.Name}", (IServiceProvider sp) => + { + try + { + var entitlementClass = sp.GetRequiredService(capturedType); + var del = (Delegate)capturedProp.GetValue(entitlementClass)!; + return Results.Json(new { value = del.DynamicInvoke() }); + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + return Results.Problem( + detail: ex.InnerException.Message, + title: "Entitlement evaluation failed", + statusCode: 500); + } + }); + } + } + + // POST endpoints — contextual entitlements: body deserialized to TRequest, then delegated to IEntitlementEvaluator. + foreach (var (key, entry) in capability.EvaluationEntries) + { + var capturedKey = key; + var requestType = entry.Resolver.RequestType; + + group.MapPost(capturedKey, async (HttpContext ctx, IEntitlementEvaluator evaluator) => + { + var request = await ctx.Request.ReadFromJsonAsync(requestType, ctx.RequestAborted); + if (request is null) + return Results.BadRequest("Unable to deserialize request body."); + + var result = await evaluator.EvaluateAsync(capturedKey, request, ctx.RequestAborted); + return Results.Json(new { value = result }); + }); + } + + return group; + } +} diff --git a/src/Cocoar.Configuration.AspNetCore/Health/CocoarConfigurationHealthCheck.cs b/src/Cocoar.Configuration.AspNetCore/Health/CocoarConfigurationHealthCheck.cs index 9934975..62ed715 100644 --- a/src/Cocoar.Configuration.AspNetCore/Health/CocoarConfigurationHealthCheck.cs +++ b/src/Cocoar.Configuration.AspNetCore/Health/CocoarConfigurationHealthCheck.cs @@ -1,33 +1,33 @@ -using Cocoar.Configuration.Core; -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace Cocoar.Configuration.AspNetCore.Health; - -/// -/// ASP.NET Core that maps -/// to . -/// -internal sealed class CocoarConfigurationHealthCheck : IHealthCheck -{ - private readonly ConfigManager _configManager; - - public CocoarConfigurationHealthCheck(ConfigManager configManager) - { - _configManager = configManager; - } - - public Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) - { - var result = _configManager.HealthStatus switch - { - Configuration.Health.HealthStatus.Healthy => HealthCheckResult.Healthy("All rules healthy"), - Configuration.Health.HealthStatus.Degraded => HealthCheckResult.Degraded(_configManager.HealthDescription), - Configuration.Health.HealthStatus.Unhealthy => HealthCheckResult.Unhealthy(_configManager.HealthDescription), - _ => HealthCheckResult.Degraded("Health status unknown") - }; - - return Task.FromResult(result); - } -} +using Cocoar.Configuration.Core; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Cocoar.Configuration.AspNetCore.Health; + +/// +/// ASP.NET Core that maps +/// to . +/// +internal sealed class CocoarConfigurationHealthCheck : IHealthCheck +{ + private readonly ConfigManager _configManager; + + public CocoarConfigurationHealthCheck(ConfigManager configManager) + { + _configManager = configManager; + } + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var result = _configManager.HealthStatus switch + { + Configuration.Health.HealthStatus.Healthy => HealthCheckResult.Healthy("All rules healthy"), + Configuration.Health.HealthStatus.Degraded => HealthCheckResult.Degraded(_configManager.HealthDescription), + Configuration.Health.HealthStatus.Unhealthy => HealthCheckResult.Unhealthy(_configManager.HealthDescription), + _ => HealthCheckResult.Degraded("Health status unknown") + }; + + return Task.FromResult(result); + } +} diff --git a/src/Cocoar.Configuration.AspNetCore/Health/CocoarHealthCheckExtensions.cs b/src/Cocoar.Configuration.AspNetCore/Health/CocoarHealthCheckExtensions.cs index ed5ac10..3c6c4cd 100644 --- a/src/Cocoar.Configuration.AspNetCore/Health/CocoarHealthCheckExtensions.cs +++ b/src/Cocoar.Configuration.AspNetCore/Health/CocoarHealthCheckExtensions.cs @@ -1,20 +1,20 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Cocoar.Configuration.AspNetCore.Health; - -public static class CocoarHealthCheckExtensions -{ - /// - /// Adds the Cocoar configuration health check to the health check builder. - /// Maps to ASP.NET Core - /// . - /// - public static IHealthChecksBuilder AddCocoarConfigurationHealthCheck( - this IHealthChecksBuilder builder, - string name = "cocoar-configuration", - params string[] tags) - { - builder.AddCheck(name, tags: tags); - return builder; - } -} +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.AspNetCore.Health; + +public static class CocoarHealthCheckExtensions +{ + /// + /// Adds the Cocoar configuration health check to the health check builder. + /// Maps to ASP.NET Core + /// . + /// + public static IHealthChecksBuilder AddCocoarConfigurationHealthCheck( + this IHealthChecksBuilder builder, + string name = "cocoar-configuration", + params string[] tags) + { + builder.AddCheck(name, tags: tags); + return builder; + } +} diff --git a/src/Cocoar.Configuration.DI/Capabilities/DiCapabilities.cs b/src/Cocoar.Configuration.DI/Capabilities/DiCapabilities.cs index 6aa79ce..4abb8cd 100644 --- a/src/Cocoar.Configuration.DI/Capabilities/DiCapabilities.cs +++ b/src/Cocoar.Configuration.DI/Capabilities/DiCapabilities.cs @@ -1,15 +1,15 @@ -using Cocoar.Capabilities; -using Microsoft.Extensions.DependencyInjection; - -namespace Cocoar.Configuration.DI.Capabilities; - -/// -/// Capability that specifies the service lifetime for DI registration. -/// -public record ServiceLifetimeCapability(ServiceLifetime Lifetime, object? Key); - -/// -/// Capability that prevents automatic DI registration of the concrete type. -/// -public record DisableAutoRegistrationCapability; - +using Cocoar.Capabilities; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.DI.Capabilities; + +/// +/// Capability that specifies the service lifetime for DI registration. +/// +public record ServiceLifetimeCapability(ServiceLifetime Lifetime, object? Key); + +/// +/// Capability that prevents automatic DI registration of the concrete type. +/// +public record DisableAutoRegistrationCapability; + diff --git a/src/Cocoar.Configuration.DI/Cocoar.Configuration.DI.csproj b/src/Cocoar.Configuration.DI/Cocoar.Configuration.DI.csproj index a28a818..cb9988e 100644 --- a/src/Cocoar.Configuration.DI/Cocoar.Configuration.DI.csproj +++ b/src/Cocoar.Configuration.DI/Cocoar.Configuration.DI.csproj @@ -1,19 +1,19 @@ - - - - true - Dependency injection integration for Cocoar.Configuration. Configuration type registration, lifetime customization, keyed services, and context resolver registration for feature flags and entitlements. - configuration;dependency-injection;di;microsoft-extensions;service-registration;feature-flags;resolvers - - - - - - - - - - - - - + + + + true + Dependency injection integration for Cocoar.Configuration. Configuration type registration, lifetime customization, keyed services, and context resolver registration for feature flags and entitlements. + configuration;dependency-injection;di;microsoft-extensions;service-registration;feature-flags;resolvers + + + + + + + + + + + + + diff --git a/src/Cocoar.Configuration.DI/CocoarConfigurationExtensions.cs b/src/Cocoar.Configuration.DI/CocoarConfigurationExtensions.cs index 9db406a..e09ff28 100644 --- a/src/Cocoar.Configuration.DI/CocoarConfigurationExtensions.cs +++ b/src/Cocoar.Configuration.DI/CocoarConfigurationExtensions.cs @@ -1,53 +1,53 @@ -using Cocoar.Configuration.Core; -using Microsoft.Extensions.DependencyInjection; - -namespace Cocoar.Configuration.DI; - -public static class CocoarConfigurationExtensions -{ - public static IServiceCollection AddCocoarConfiguration( - this IServiceCollection services, - ConfigManager configManager) - { - services.ThrowIfAlreadyRegistered(); - - // Register core services - services.AddSingleton(configManager); - services.AddSingleton(sp => sp.GetRequiredService()); - - // Build registration plan and apply it - var plan = ServiceRegistrationPlanner.CreatePlan(configManager); - ServiceDescriptorEmitter.Emit(services, plan, configManager); - - // ADR-006: if UseServiceBackedConfiguration added Layer-2 rules, register the holder + activation hosted - // service so they come alive on host start. No-op otherwise (zero impact for Layer-1-only apps). Wired - // HERE — the single point every entry path funnels through (DI Action overload, AspNetCore, manual). - ServiceBackedConfigurationCoordinator.WireActivation(services, configManager); - - return services; - } - - /// - /// Adds Cocoar configuration to the service collection using the builder API. - /// Provides access to the full ConfigManagerBuilder for configuring rules, setup, secrets, logging, etc. - /// Test configuration overrides are automatically applied via ConfigManager when CocoarTestConfiguration is active. - /// - public static IServiceCollection AddCocoarConfiguration( - this IServiceCollection services, - Action configure) - { - services.ThrowIfAlreadyRegistered(); - - var configManager = ConfigManager.Create(configure); - services.AddCocoarConfiguration(configManager); // WireActivation runs inside the instance overload - return services; - } - - private static void ThrowIfAlreadyRegistered(this IServiceCollection services) - { - if (services.Any(s => s.ServiceType == typeof(ConfigManager))) - { - throw new InvalidOperationException("Cocoar Configuration has already been registered. AddCocoarConfiguration should only be called once."); - } - } -} +using Cocoar.Configuration.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.DI; + +public static class CocoarConfigurationExtensions +{ + public static IServiceCollection AddCocoarConfiguration( + this IServiceCollection services, + ConfigManager configManager) + { + services.ThrowIfAlreadyRegistered(); + + // Register core services + services.AddSingleton(configManager); + services.AddSingleton(sp => sp.GetRequiredService()); + + // Build registration plan and apply it + var plan = ServiceRegistrationPlanner.CreatePlan(configManager); + ServiceDescriptorEmitter.Emit(services, plan, configManager); + + // ADR-006: if UseServiceBackedConfiguration added Layer-2 rules, register the holder + activation hosted + // service so they come alive on host start. No-op otherwise (zero impact for Layer-1-only apps). Wired + // HERE — the single point every entry path funnels through (DI Action overload, AspNetCore, manual). + ServiceBackedConfigurationCoordinator.WireActivation(services, configManager); + + return services; + } + + /// + /// Adds Cocoar configuration to the service collection using the builder API. + /// Provides access to the full ConfigManagerBuilder for configuring rules, setup, secrets, logging, etc. + /// Test configuration overrides are automatically applied via ConfigManager when CocoarTestConfiguration is active. + /// + public static IServiceCollection AddCocoarConfiguration( + this IServiceCollection services, + Action configure) + { + services.ThrowIfAlreadyRegistered(); + + var configManager = ConfigManager.Create(configure); + services.AddCocoarConfiguration(configManager); // WireActivation runs inside the instance overload + return services; + } + + private static void ThrowIfAlreadyRegistered(this IServiceCollection services) + { + if (services.Any(s => s.ServiceType == typeof(ConfigManager))) + { + throw new InvalidOperationException("Cocoar Configuration has already been registered. AddCocoarConfiguration should only be called once."); + } + } +} diff --git a/src/Cocoar.Configuration.DI/ExposedTypeSetup.cs b/src/Cocoar.Configuration.DI/ExposedTypeSetup.cs index 2649ede..b9f9ae4 100644 --- a/src/Cocoar.Configuration.DI/ExposedTypeSetup.cs +++ b/src/Cocoar.Configuration.DI/ExposedTypeSetup.cs @@ -1,31 +1,31 @@ -using Cocoar.Capabilities; -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Core; - -namespace Cocoar.Configuration.DI; - -public sealed record ExposedTypePrimary(Type Concrete) : IPrimaryTypeCapability -{ - public Type SelectedType => Concrete; -} - -public sealed class ExposedTypeSetup : SetupDefinition where T : class -{ - internal ExposedTypeSetup(ConfigManagerCapabilityScope capabilityScope): base(capabilityScope) - { - capabilityScope.Compose(this).WithPrimary( - new ExposedTypePrimary(typeof(T))); - } - - internal override SetupDefinition Build() - { - GetComposer(this).Build(); - return this; - } -} - -public static class SetupBuilderExtensions -{ - public static ExposedTypeSetup ExposedType(this SetupBuilder builder) where T : class - => new(SetupBuilder.GetCapabilityScopeFor(builder)); -} +using Cocoar.Capabilities; +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.DI; + +public sealed record ExposedTypePrimary(Type Concrete) : IPrimaryTypeCapability +{ + public Type SelectedType => Concrete; +} + +public sealed class ExposedTypeSetup : SetupDefinition where T : class +{ + internal ExposedTypeSetup(ConfigManagerCapabilityScope capabilityScope): base(capabilityScope) + { + capabilityScope.Compose(this).WithPrimary( + new ExposedTypePrimary(typeof(T))); + } + + internal override SetupDefinition Build() + { + GetComposer(this).Build(); + return this; + } +} + +public static class SetupBuilderExtensions +{ + public static ExposedTypeSetup ExposedType(this SetupBuilder builder) where T : class + => new(SetupBuilder.GetCapabilityScopeFor(builder)); +} diff --git a/src/Cocoar.Configuration.DI/Extensions/ConcreteTypeSetupExtensions.cs b/src/Cocoar.Configuration.DI/Extensions/ConcreteTypeSetupExtensions.cs index 4fe27e6..9e9a9a7 100644 --- a/src/Cocoar.Configuration.DI/Extensions/ConcreteTypeSetupExtensions.cs +++ b/src/Cocoar.Configuration.DI/Extensions/ConcreteTypeSetupExtensions.cs @@ -1,39 +1,39 @@ -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.DI.Capabilities; -using Microsoft.Extensions.DependencyInjection; - -namespace Cocoar.Configuration.DI.Extensions; - -public static class ConcreteTypeSetupExtensions -{ - - public static ConcreteTypeSetup RegisterAs(this ConcreteTypeSetup builder, - ServiceLifetime serviceLifetime, object? key = null) - where T : class - { - SetupDefinition.GetComposer(builder) - .Add(new ServiceLifetimeCapability(serviceLifetime, key)); - return builder; - } - - public static ConcreteTypeSetup AsSingleton(this ConcreteTypeSetup builder, object? key = null) - where T : class => - builder.RegisterAs(ServiceLifetime.Singleton, key); - - public static ConcreteTypeSetup AsScoped(this ConcreteTypeSetup builder, object? key = null) - where T : class => - builder.RegisterAs(ServiceLifetime.Scoped, key); - - public static ConcreteTypeSetup AsTransient(this ConcreteTypeSetup builder, object? key = null) - where T : class => - builder.RegisterAs(ServiceLifetime.Transient, key); - - public static ConcreteTypeSetup DisableAutoRegistration(this ConcreteTypeSetup builder) - where T : class - { - SetupDefinition.GetComposer(builder) - .Add(new DisableAutoRegistrationCapability()); - return builder; - } - -} +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.DI.Capabilities; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.DI.Extensions; + +public static class ConcreteTypeSetupExtensions +{ + + public static ConcreteTypeSetup RegisterAs(this ConcreteTypeSetup builder, + ServiceLifetime serviceLifetime, object? key = null) + where T : class + { + SetupDefinition.GetComposer(builder) + .Add(new ServiceLifetimeCapability(serviceLifetime, key)); + return builder; + } + + public static ConcreteTypeSetup AsSingleton(this ConcreteTypeSetup builder, object? key = null) + where T : class => + builder.RegisterAs(ServiceLifetime.Singleton, key); + + public static ConcreteTypeSetup AsScoped(this ConcreteTypeSetup builder, object? key = null) + where T : class => + builder.RegisterAs(ServiceLifetime.Scoped, key); + + public static ConcreteTypeSetup AsTransient(this ConcreteTypeSetup builder, object? key = null) + where T : class => + builder.RegisterAs(ServiceLifetime.Transient, key); + + public static ConcreteTypeSetup DisableAutoRegistration(this ConcreteTypeSetup builder) + where T : class + { + SetupDefinition.GetComposer(builder) + .Add(new DisableAutoRegistrationCapability()); + return builder; + } + +} diff --git a/src/Cocoar.Configuration.DI/Extensions/ExposedTypeSetupExtensions.cs b/src/Cocoar.Configuration.DI/Extensions/ExposedTypeSetupExtensions.cs index 3daa204..59f18de 100644 --- a/src/Cocoar.Configuration.DI/Extensions/ExposedTypeSetupExtensions.cs +++ b/src/Cocoar.Configuration.DI/Extensions/ExposedTypeSetupExtensions.cs @@ -1,38 +1,38 @@ -using System.Runtime.CompilerServices; -using Cocoar.Capabilities; -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.DI.Capabilities; -using Microsoft.Extensions.DependencyInjection; - -namespace Cocoar.Configuration.DI.Extensions; - -public static class ExposedTypeSetupExtensions -{ - - public static ExposedTypeSetup RegisterAs(this ExposedTypeSetup builder, ServiceLifetime serviceLifetime, object? key = null) - where T : class - { - SetupDefinition.GetComposer(builder) - .Add(new ServiceLifetimeCapability(serviceLifetime, key)); - return builder; - } - - public static ExposedTypeSetup AsSingleton(this ExposedTypeSetup builder, object? key = null) - where T : class => - builder.RegisterAs(ServiceLifetime.Singleton, key); - - public static ExposedTypeSetup AsScoped(this ExposedTypeSetup builder, object? key = null) - where T : class => - builder.RegisterAs(ServiceLifetime.Scoped, key); - - public static ExposedTypeSetup AsTransient(this ExposedTypeSetup builder, object? key = null) - where T : class => - builder.RegisterAs(ServiceLifetime.Transient, key); - - public static ExposedTypeSetup DisableAutoRegistration(this ExposedTypeSetup builder) - where T : class { - SetupDefinition.GetComposer(builder) - .Add(new DisableAutoRegistrationCapability()); - return builder; - } -} +using System.Runtime.CompilerServices; +using Cocoar.Capabilities; +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.DI.Capabilities; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.DI.Extensions; + +public static class ExposedTypeSetupExtensions +{ + + public static ExposedTypeSetup RegisterAs(this ExposedTypeSetup builder, ServiceLifetime serviceLifetime, object? key = null) + where T : class + { + SetupDefinition.GetComposer(builder) + .Add(new ServiceLifetimeCapability(serviceLifetime, key)); + return builder; + } + + public static ExposedTypeSetup AsSingleton(this ExposedTypeSetup builder, object? key = null) + where T : class => + builder.RegisterAs(ServiceLifetime.Singleton, key); + + public static ExposedTypeSetup AsScoped(this ExposedTypeSetup builder, object? key = null) + where T : class => + builder.RegisterAs(ServiceLifetime.Scoped, key); + + public static ExposedTypeSetup AsTransient(this ExposedTypeSetup builder, object? key = null) + where T : class => + builder.RegisterAs(ServiceLifetime.Transient, key); + + public static ExposedTypeSetup DisableAutoRegistration(this ExposedTypeSetup builder) + where T : class { + SetupDefinition.GetComposer(builder) + .Add(new DisableAutoRegistrationCapability()); + return builder; + } +} diff --git a/src/Cocoar.Configuration.DI/ServiceRegistrationInfo.cs b/src/Cocoar.Configuration.DI/ServiceRegistrationInfo.cs index 95919c3..b402b2f 100644 --- a/src/Cocoar.Configuration.DI/ServiceRegistrationInfo.cs +++ b/src/Cocoar.Configuration.DI/ServiceRegistrationInfo.cs @@ -1,17 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; - -namespace Cocoar.Configuration.DI; -public class ServiceRegistrationInfo -{ - public required Type Type { get; set; } - public bool DisableDefault { get; set; } - - - public Dictionary ServiceLifetimes { get; } = new(); - public bool OverwriteDefault => ServiceLifetimes.ContainsKey(""); -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.DI; +public class ServiceRegistrationInfo +{ + public required Type Type { get; set; } + public bool DisableDefault { get; set; } + + + public Dictionary ServiceLifetimes { get; } = new(); + public bool OverwriteDefault => ServiceLifetimes.ContainsKey(""); +} diff --git a/src/Cocoar.Configuration.DI/ServiceRegistrationPlanner.cs b/src/Cocoar.Configuration.DI/ServiceRegistrationPlanner.cs index fe7a446..0447079 100644 --- a/src/Cocoar.Configuration.DI/ServiceRegistrationPlanner.cs +++ b/src/Cocoar.Configuration.DI/ServiceRegistrationPlanner.cs @@ -1,166 +1,166 @@ -using Cocoar.Capabilities; -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.DI.Capabilities; -using Microsoft.Extensions.DependencyInjection; - -namespace Cocoar.Configuration.DI; - -/// -/// Builds an in-memory service registration plan from ConfigManager setup. -/// Separates interpretation logic from IServiceCollection manipulation. -/// -internal static class ServiceRegistrationPlanner -{ - /// - /// Creates a registration plan from the ConfigManager's rules and setup definitions. - /// - public static Dictionary CreatePlan(ConfigManager configManager) - { - var serviceRegistrationInfos = new Dictionary(); - - // 1. Collect all types from rules — EXCEPT types whose every rule is .TenantScoped(). - // Such a type has no value in the global pipeline; injecting it would be a captive-dependency bug - // (one tenant frozen into a long-lived consumer). Tenant-scoped values are obtained explicitly via - // GetConfigForTenant(id) / GetFeatureFlagsForTenant(id) instead (ADR-005 §5). A type that ALSO - // has a non-tenant-scoped (global base) rule stays injectable — that base value is a valid global config. - var tenantOnlyTypes = configManager.Rules - .GroupBy(rule => rule.ConcreteType) - .Where(group => group.All(rule => rule.Options?.TenantScoped == true)) - .Select(group => group.Key) - .ToHashSet(); - - var typesFromRules = new HashSet(); - foreach (var rule in configManager.Rules) - { - if (tenantOnlyTypes.Contains(rule.ConcreteType)) - { - continue; - } - - typesFromRules.Add(rule.ConcreteType); - } - - // 2. Process explicit SetupDefinitions for customization - var configSpecs = configManager.SetupDefinitions; - if (configSpecs.Count > 0) - { - foreach (var spec in configSpecs) - { - if (!configManager.CapabilityScope.Compositions.TryGet(spec, out var bag)) - { - continue; - } - - if (bag.TryGetPrimaryAs>(out var typeCapability)) - { - ProcessConcreteType(serviceRegistrationInfos, typeCapability!, bag); - } - - if (bag.TryGetPrimaryAs>(out var exposedCapability)) - { - ProcessExposedType(serviceRegistrationInfos, exposedCapability!, bag); - } - } - } - - // 3. Auto-register types from rules that don't have explicit setup definitions - foreach (var type in typesFromRules) - { - if (!serviceRegistrationInfos.ContainsKey(type)) - { - serviceRegistrationInfos[type] = new ServiceRegistrationInfo - { - Type = type, - DisableDefault = false - }; - } - } - - // Sort by type name for deterministic ordering - return serviceRegistrationInfos - .OrderBy(kvp => kvp.Key.FullName) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - - private static void ProcessConcreteType( - Dictionary serviceRegistrationInfos, - ConcreteTypePrimary primaryCapability, - IComposition bag) - { - if (!serviceRegistrationInfos.TryGetValue(primaryCapability.SelectedType, out var serviceRegistrationInfo)) - { - serviceRegistrationInfo = new ServiceRegistrationInfo - { - Type = primaryCapability.SelectedType, - }; - } - - if (bag.Has>()) - { - serviceRegistrationInfo.DisableDefault = true; - } - - var lifetimeCapabilities = bag.GetAll>(); - var keyedLifetimeMap = ResolveLifetimeSelections(lifetimeCapabilities); - foreach (var (key, value) in keyedLifetimeMap) - { - serviceRegistrationInfo.ServiceLifetimes[key] = value; - } - - serviceRegistrationInfos[primaryCapability.SelectedType] = serviceRegistrationInfo; - - var exposeAsCapabilities = bag.GetAll>(); - var exposedInterfaces = exposeAsCapabilities.Select(x => x.ContractType).Distinct().ToList(); - - foreach (var it in exposedInterfaces) - { - if (!serviceRegistrationInfos.ContainsKey(it)) - { - serviceRegistrationInfos[it] = new ServiceRegistrationInfo - { - Type = it, - }; - } - } - } - - private static void ProcessExposedType( - Dictionary serviceRegistrationInfos, - ExposedTypePrimary primaryCapability, - IComposition bag) - { - if (!serviceRegistrationInfos.TryGetValue(primaryCapability.SelectedType, out var serviceRegistrationInfo)) - { - serviceRegistrationInfo = new ServiceRegistrationInfo - { - Type = primaryCapability.SelectedType, - }; - } - - if (bag.Has>()) - { - serviceRegistrationInfo.DisableDefault = true; - } - - var lifetimeCapabilities = bag.GetAll>(); - var keyedLifetimeMap = ResolveLifetimeSelections(lifetimeCapabilities); - foreach (var (key, value) in keyedLifetimeMap) - { - serviceRegistrationInfo.ServiceLifetimes[key] = value; - } - - serviceRegistrationInfos[primaryCapability.SelectedType] = serviceRegistrationInfo; - } - - private static Dictionary ResolveLifetimeSelections( - IEnumerable> lifetimeCapabilities) - { - var keyed = new Dictionary(); - foreach (var cap in lifetimeCapabilities) - { - keyed[cap.Key ?? ""] = cap.Lifetime; - } - return keyed; - } -} +using Cocoar.Capabilities; +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI.Capabilities; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.DI; + +/// +/// Builds an in-memory service registration plan from ConfigManager setup. +/// Separates interpretation logic from IServiceCollection manipulation. +/// +internal static class ServiceRegistrationPlanner +{ + /// + /// Creates a registration plan from the ConfigManager's rules and setup definitions. + /// + public static Dictionary CreatePlan(ConfigManager configManager) + { + var serviceRegistrationInfos = new Dictionary(); + + // 1. Collect all types from rules — EXCEPT types whose every rule is .TenantScoped(). + // Such a type has no value in the global pipeline; injecting it would be a captive-dependency bug + // (one tenant frozen into a long-lived consumer). Tenant-scoped values are obtained explicitly via + // GetConfigForTenant(id) / GetFeatureFlagsForTenant(id) instead (ADR-005 §5). A type that ALSO + // has a non-tenant-scoped (global base) rule stays injectable — that base value is a valid global config. + var tenantOnlyTypes = configManager.Rules + .GroupBy(rule => rule.ConcreteType) + .Where(group => group.All(rule => rule.Options?.TenantScoped == true)) + .Select(group => group.Key) + .ToHashSet(); + + var typesFromRules = new HashSet(); + foreach (var rule in configManager.Rules) + { + if (tenantOnlyTypes.Contains(rule.ConcreteType)) + { + continue; + } + + typesFromRules.Add(rule.ConcreteType); + } + + // 2. Process explicit SetupDefinitions for customization + var configSpecs = configManager.SetupDefinitions; + if (configSpecs.Count > 0) + { + foreach (var spec in configSpecs) + { + if (!configManager.CapabilityScope.Compositions.TryGet(spec, out var bag)) + { + continue; + } + + if (bag.TryGetPrimaryAs>(out var typeCapability)) + { + ProcessConcreteType(serviceRegistrationInfos, typeCapability!, bag); + } + + if (bag.TryGetPrimaryAs>(out var exposedCapability)) + { + ProcessExposedType(serviceRegistrationInfos, exposedCapability!, bag); + } + } + } + + // 3. Auto-register types from rules that don't have explicit setup definitions + foreach (var type in typesFromRules) + { + if (!serviceRegistrationInfos.ContainsKey(type)) + { + serviceRegistrationInfos[type] = new ServiceRegistrationInfo + { + Type = type, + DisableDefault = false + }; + } + } + + // Sort by type name for deterministic ordering + return serviceRegistrationInfos + .OrderBy(kvp => kvp.Key.FullName) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + private static void ProcessConcreteType( + Dictionary serviceRegistrationInfos, + ConcreteTypePrimary primaryCapability, + IComposition bag) + { + if (!serviceRegistrationInfos.TryGetValue(primaryCapability.SelectedType, out var serviceRegistrationInfo)) + { + serviceRegistrationInfo = new ServiceRegistrationInfo + { + Type = primaryCapability.SelectedType, + }; + } + + if (bag.Has>()) + { + serviceRegistrationInfo.DisableDefault = true; + } + + var lifetimeCapabilities = bag.GetAll>(); + var keyedLifetimeMap = ResolveLifetimeSelections(lifetimeCapabilities); + foreach (var (key, value) in keyedLifetimeMap) + { + serviceRegistrationInfo.ServiceLifetimes[key] = value; + } + + serviceRegistrationInfos[primaryCapability.SelectedType] = serviceRegistrationInfo; + + var exposeAsCapabilities = bag.GetAll>(); + var exposedInterfaces = exposeAsCapabilities.Select(x => x.ContractType).Distinct().ToList(); + + foreach (var it in exposedInterfaces) + { + if (!serviceRegistrationInfos.ContainsKey(it)) + { + serviceRegistrationInfos[it] = new ServiceRegistrationInfo + { + Type = it, + }; + } + } + } + + private static void ProcessExposedType( + Dictionary serviceRegistrationInfos, + ExposedTypePrimary primaryCapability, + IComposition bag) + { + if (!serviceRegistrationInfos.TryGetValue(primaryCapability.SelectedType, out var serviceRegistrationInfo)) + { + serviceRegistrationInfo = new ServiceRegistrationInfo + { + Type = primaryCapability.SelectedType, + }; + } + + if (bag.Has>()) + { + serviceRegistrationInfo.DisableDefault = true; + } + + var lifetimeCapabilities = bag.GetAll>(); + var keyedLifetimeMap = ResolveLifetimeSelections(lifetimeCapabilities); + foreach (var (key, value) in keyedLifetimeMap) + { + serviceRegistrationInfo.ServiceLifetimes[key] = value; + } + + serviceRegistrationInfos[primaryCapability.SelectedType] = serviceRegistrationInfo; + } + + private static Dictionary ResolveLifetimeSelections( + IEnumerable> lifetimeCapabilities) + { + var keyed = new Dictionary(); + foreach (var cap in lifetimeCapabilities) + { + keyed[cap.Key ?? ""] = cap.Lifetime; + } + return keyed; + } +} diff --git a/src/Cocoar.Configuration.MicrosoftAdapter/Cocoar.Configuration.MicrosoftAdapter.csproj b/src/Cocoar.Configuration.MicrosoftAdapter/Cocoar.Configuration.MicrosoftAdapter.csproj index 8aea4e8..77b1035 100644 --- a/src/Cocoar.Configuration.MicrosoftAdapter/Cocoar.Configuration.MicrosoftAdapter.csproj +++ b/src/Cocoar.Configuration.MicrosoftAdapter/Cocoar.Configuration.MicrosoftAdapter.csproj @@ -1,17 +1,17 @@ - - - true - enable - Adapter for bridging Microsoft.Extensions.Configuration (IConfiguration) with Cocoar.Configuration, enabling gradual migration and coexistence with existing Microsoft configuration sources while leveraging reactive updates. - configuration;microsoft-extensions;iconfiguration;adapter;bridge;migration;compatibility;interop - - - - - - - - - - - + + + true + enable + Adapter for bridging Microsoft.Extensions.Configuration (IConfiguration) with Cocoar.Configuration, enabling gradual migration and coexistence with existing Microsoft configuration sources while leveraging reactive updates. + configuration;microsoft-extensions;iconfiguration;adapter;bridge;migration;compatibility;interop + + + + + + + + + + + diff --git a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProvider.cs b/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProvider.cs index d9bc614..ef03ebe 100644 --- a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProvider.cs +++ b/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProvider.cs @@ -1,180 +1,180 @@ -using System.Text.Json; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Providers.Abstractions; -using Microsoft.Extensions.Configuration; - -namespace Cocoar.Configuration.MicrosoftAdapter; - -public sealed class MicrosoftConfigurationSourceProvider( - MicrosoftConfigurationSourceProviderOptions options -) : ConfigurationProvider(options) -{ - private IConfigurationProvider BuildProvider() - { - var builder = new ConfigurationBuilder(); - if (!string.IsNullOrWhiteSpace(ProviderOptions.BasePath)) - { - builder.SetBasePath(ProviderOptions.BasePath); - } - - builder.Add(ProviderOptions.Source); - return builder.Build().Providers.Last(); - } - - public override Task FetchConfigurationBytesAsync(MicrosoftConfigurationSourceProviderQueryOptions query, - CancellationToken ct = default) - { - var provider = BuildProvider(); - var root = new ConfigurationRoot(new[] { provider }); - var dict = Flatten(root, query.ConfigurationPrefix); - var bytes = DictToJsonBytes(dict); - return Task.FromResult(bytes); - } - - public override IObservable ChangesAsBytes(MicrosoftConfigurationSourceProviderQueryOptions query) - { - return new ChangeTokenObservable(this, query); - } - - private static Dictionary Flatten(IConfigurationRoot root, string? ConfigurationPrefix) - { - var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var kv in root.AsEnumerable(makePathsRelative: false)) - { - if (kv.Value is null || string.IsNullOrWhiteSpace(kv.Key)) - { - continue; - } - - if (!string.IsNullOrWhiteSpace(ConfigurationPrefix)) - { - if (!kv.Key.StartsWith(ConfigurationPrefix + ":", StringComparison.OrdinalIgnoreCase) - && !kv.Key.Equals(ConfigurationPrefix, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var rel = kv.Key.Equals(ConfigurationPrefix, StringComparison.OrdinalIgnoreCase) - ? string.Empty - : kv.Key.Substring(ConfigurationPrefix.Length + 1); - if (rel.Length == 0) - { - continue; - } - - dict[rel] = kv.Value; - } - else - { - dict[kv.Key] = kv.Value; - } - } - - return dict; - } - - private static byte[] DictToJsonBytes(Dictionary flat) - { - var root = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (k, v) in flat) - { - var parts = k.Split(':'); - var cur = root; - for (var i = 0; i < parts.Length - 1; i++) - { - if (!cur.TryGetValue(parts[i], out var next) || next is not Dictionary nextDict) - { - nextDict = new(StringComparer.OrdinalIgnoreCase); - cur[parts[i]] = nextDict; - } - - cur = nextDict; - } - - cur[parts[^1]] = v; - } - - return JsonSerializer.SerializeToUtf8Bytes(root); - } - - /// - /// Helper method to create a Microsoft configuration source rule for testing purposes. - /// - public static Cocoar.Configuration.Rules.ConfigRule CreateRule( - Func optionsFactory, - bool required = false) - { - return Cocoar.Configuration.Rules.ConfigRule.Create( - cm => optionsFactory(cm).ToProviderOptions(), - cm => optionsFactory(cm).ToQueryOptions(), - typeof(T), - new Cocoar.Configuration.Rules.ConfigRuleOptions(Required: required, UseWhen: null) - ); - } - - /// - /// Wraps IChangeToken from a Microsoft configuration provider as an IObservable. - /// Re-registers the change token after each callback (IChangeToken is single-fire). - /// - private sealed class ChangeTokenObservable( - MicrosoftConfigurationSourceProvider owner, - MicrosoftConfigurationSourceProviderQueryOptions query) : IObservable - { - public IDisposable Subscribe(IObserver observer) - { - var state = new ChangeTokenState(owner, query, observer); - state.Register(); - return state; - } - - private sealed class ChangeTokenState : IDisposable - { - private readonly MicrosoftConfigurationSourceProvider _owner; - private readonly MicrosoftConfigurationSourceProviderQueryOptions _query; - private readonly IObserver _observer; - private readonly IConfigurationProvider _provider; - private IDisposable? _registration; - private int _disposed; - - public ChangeTokenState( - MicrosoftConfigurationSourceProvider owner, - MicrosoftConfigurationSourceProviderQueryOptions query, - IObserver observer) - { - _owner = owner; - _query = query; - _observer = observer; - _provider = owner.BuildProvider(); - } - - public void Register() - { - if (Volatile.Read(ref _disposed) != 0) return; - var token = _provider.GetReloadToken(); - _registration = token.RegisterChangeCallback(_ => OnChange(), null); - } - - private void OnChange() - { - if (Volatile.Read(ref _disposed) != 0) return; - - var root = new ConfigurationRoot(new[] { _provider }); - var dict = Flatten(root, _query.ConfigurationPrefix); - var bytes = DictToJsonBytes(dict); - try { _observer.OnNext(bytes); } catch { /* observer fault must not break re-registration */ } - - // IChangeToken is single-fire — re-register for the next change - Register(); - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) return; - _registration?.Dispose(); - } - } - } -} +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers.Abstractions; +using Microsoft.Extensions.Configuration; + +namespace Cocoar.Configuration.MicrosoftAdapter; + +public sealed class MicrosoftConfigurationSourceProvider( + MicrosoftConfigurationSourceProviderOptions options +) : ConfigurationProvider(options) +{ + private IConfigurationProvider BuildProvider() + { + var builder = new ConfigurationBuilder(); + if (!string.IsNullOrWhiteSpace(ProviderOptions.BasePath)) + { + builder.SetBasePath(ProviderOptions.BasePath); + } + + builder.Add(ProviderOptions.Source); + return builder.Build().Providers.Last(); + } + + public override Task FetchConfigurationBytesAsync(MicrosoftConfigurationSourceProviderQueryOptions query, + CancellationToken ct = default) + { + var provider = BuildProvider(); + var root = new ConfigurationRoot(new[] { provider }); + var dict = Flatten(root, query.ConfigurationPrefix); + var bytes = DictToJsonBytes(dict); + return Task.FromResult(bytes); + } + + public override IObservable ChangesAsBytes(MicrosoftConfigurationSourceProviderQueryOptions query) + { + return new ChangeTokenObservable(this, query); + } + + private static Dictionary Flatten(IConfigurationRoot root, string? ConfigurationPrefix) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in root.AsEnumerable(makePathsRelative: false)) + { + if (kv.Value is null || string.IsNullOrWhiteSpace(kv.Key)) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(ConfigurationPrefix)) + { + if (!kv.Key.StartsWith(ConfigurationPrefix + ":", StringComparison.OrdinalIgnoreCase) + && !kv.Key.Equals(ConfigurationPrefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var rel = kv.Key.Equals(ConfigurationPrefix, StringComparison.OrdinalIgnoreCase) + ? string.Empty + : kv.Key.Substring(ConfigurationPrefix.Length + 1); + if (rel.Length == 0) + { + continue; + } + + dict[rel] = kv.Value; + } + else + { + dict[kv.Key] = kv.Value; + } + } + + return dict; + } + + private static byte[] DictToJsonBytes(Dictionary flat) + { + var root = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (k, v) in flat) + { + var parts = k.Split(':'); + var cur = root; + for (var i = 0; i < parts.Length - 1; i++) + { + if (!cur.TryGetValue(parts[i], out var next) || next is not Dictionary nextDict) + { + nextDict = new(StringComparer.OrdinalIgnoreCase); + cur[parts[i]] = nextDict; + } + + cur = nextDict; + } + + cur[parts[^1]] = v; + } + + return JsonSerializer.SerializeToUtf8Bytes(root); + } + + /// + /// Helper method to create a Microsoft configuration source rule for testing purposes. + /// + public static Cocoar.Configuration.Rules.ConfigRule CreateRule( + Func optionsFactory, + bool required = false) + { + return Cocoar.Configuration.Rules.ConfigRule.Create( + cm => optionsFactory(cm).ToProviderOptions(), + cm => optionsFactory(cm).ToQueryOptions(), + typeof(T), + new Cocoar.Configuration.Rules.ConfigRuleOptions(Required: required, UseWhen: null) + ); + } + + /// + /// Wraps IChangeToken from a Microsoft configuration provider as an IObservable. + /// Re-registers the change token after each callback (IChangeToken is single-fire). + /// + private sealed class ChangeTokenObservable( + MicrosoftConfigurationSourceProvider owner, + MicrosoftConfigurationSourceProviderQueryOptions query) : IObservable + { + public IDisposable Subscribe(IObserver observer) + { + var state = new ChangeTokenState(owner, query, observer); + state.Register(); + return state; + } + + private sealed class ChangeTokenState : IDisposable + { + private readonly MicrosoftConfigurationSourceProvider _owner; + private readonly MicrosoftConfigurationSourceProviderQueryOptions _query; + private readonly IObserver _observer; + private readonly IConfigurationProvider _provider; + private IDisposable? _registration; + private int _disposed; + + public ChangeTokenState( + MicrosoftConfigurationSourceProvider owner, + MicrosoftConfigurationSourceProviderQueryOptions query, + IObserver observer) + { + _owner = owner; + _query = query; + _observer = observer; + _provider = owner.BuildProvider(); + } + + public void Register() + { + if (Volatile.Read(ref _disposed) != 0) return; + var token = _provider.GetReloadToken(); + _registration = token.RegisterChangeCallback(_ => OnChange(), null); + } + + private void OnChange() + { + if (Volatile.Read(ref _disposed) != 0) return; + + var root = new ConfigurationRoot(new[] { _provider }); + var dict = Flatten(root, _query.ConfigurationPrefix); + var bytes = DictToJsonBytes(dict); + try { _observer.OnNext(bytes); } catch { /* observer fault must not break re-registration */ } + + // IChangeToken is single-fire — re-register for the next change + Register(); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + _registration?.Dispose(); + } + } + } +} diff --git a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderOptions.cs b/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderOptions.cs index c43cae1..719d765 100644 --- a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderOptions.cs +++ b/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderOptions.cs @@ -1,22 +1,22 @@ -using Cocoar.Configuration.Providers.Abstractions; -using Microsoft.Extensions.Configuration; - -namespace Cocoar.Configuration.MicrosoftAdapter; - -public sealed class MicrosoftConfigurationSourceProviderOptions : IProviderConfiguration -{ - public IConfigurationSource Source { get; } - public string? BasePath { get; } - public string? Identity { get; } - - public MicrosoftConfigurationSourceProviderOptions(IConfigurationSource source, string? basePath = null, - string? identity = null) - { - Source = source; - BasePath = basePath; - Identity = identity; - } - - string IProviderConfiguration.GenerateProviderKey() - => $"{Source.GetType().FullName}|{BasePath}|{Identity}"; -} +using Cocoar.Configuration.Providers.Abstractions; +using Microsoft.Extensions.Configuration; + +namespace Cocoar.Configuration.MicrosoftAdapter; + +public sealed class MicrosoftConfigurationSourceProviderOptions : IProviderConfiguration +{ + public IConfigurationSource Source { get; } + public string? BasePath { get; } + public string? Identity { get; } + + public MicrosoftConfigurationSourceProviderOptions(IConfigurationSource source, string? basePath = null, + string? identity = null) + { + Source = source; + BasePath = basePath; + Identity = identity; + } + + string IProviderConfiguration.GenerateProviderKey() + => $"{Source.GetType().FullName}|{BasePath}|{Identity}"; +} diff --git a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderQueryOptions.cs b/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderQueryOptions.cs index d1268c6..8a82892 100644 --- a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderQueryOptions.cs +++ b/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderQueryOptions.cs @@ -1,10 +1,10 @@ -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.MicrosoftAdapter; - -public sealed class MicrosoftConfigurationSourceProviderQueryOptions( - string? configurationPrefix = null -) : IProviderQuery -{ - public string? ConfigurationPrefix { get; } = configurationPrefix; -} +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.MicrosoftAdapter; + +public sealed class MicrosoftConfigurationSourceProviderQueryOptions( + string? configurationPrefix = null +) : IProviderQuery +{ + public string? ConfigurationPrefix { get; } = configurationPrefix; +} diff --git a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceRuleOptions.cs b/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceRuleOptions.cs index 3ea54f7..43ca35d 100644 --- a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceRuleOptions.cs +++ b/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceRuleOptions.cs @@ -1,30 +1,30 @@ -using Microsoft.Extensions.Configuration; - -namespace Cocoar.Configuration.MicrosoftAdapter; - -// Combined options for the Microsoft IConfigurationSource adapter (instance + query) -public sealed class MicrosoftConfigurationSourceRuleOptions -{ - public IConfigurationSource Source { get; } - public string? BasePath { get; } - public string? Identity { get; } - public string? ConfigurationPrefix { get; } - - public MicrosoftConfigurationSourceRuleOptions( - IConfigurationSource source, - string? basePath = null, - string? identity = null, - string? configurationPrefix = null) - { - Source = source ?? throw new ArgumentNullException(nameof(source)); - BasePath = basePath; - Identity = identity; - ConfigurationPrefix = configurationPrefix; - } - - public MicrosoftConfigurationSourceProviderOptions ToProviderOptions() - => new(Source, BasePath, Identity); - - public MicrosoftConfigurationSourceProviderQueryOptions ToQueryOptions() - => new(ConfigurationPrefix); -} +using Microsoft.Extensions.Configuration; + +namespace Cocoar.Configuration.MicrosoftAdapter; + +// Combined options for the Microsoft IConfigurationSource adapter (instance + query) +public sealed class MicrosoftConfigurationSourceRuleOptions +{ + public IConfigurationSource Source { get; } + public string? BasePath { get; } + public string? Identity { get; } + public string? ConfigurationPrefix { get; } + + public MicrosoftConfigurationSourceRuleOptions( + IConfigurationSource source, + string? basePath = null, + string? identity = null, + string? configurationPrefix = null) + { + Source = source ?? throw new ArgumentNullException(nameof(source)); + BasePath = basePath; + Identity = identity; + ConfigurationPrefix = configurationPrefix; + } + + public MicrosoftConfigurationSourceProviderOptions ToProviderOptions() + => new(Source, BasePath, Identity); + + public MicrosoftConfigurationSourceProviderQueryOptions ToQueryOptions() + => new(ConfigurationPrefix); +} diff --git a/src/Cocoar.Configuration.Secrets.Cli/Cocoar.Configuration.Secrets.Cli.csproj b/src/Cocoar.Configuration.Secrets.Cli/Cocoar.Configuration.Secrets.Cli.csproj index 9bdc859..2302d50 100644 --- a/src/Cocoar.Configuration.Secrets.Cli/Cocoar.Configuration.Secrets.Cli.csproj +++ b/src/Cocoar.Configuration.Secrets.Cli/Cocoar.Configuration.Secrets.Cli.csproj @@ -1,28 +1,28 @@ - - - - Exe - net9.0 - enable - enable - - - true - - - true - cocoar-secrets - Cocoar.Configuration.Secrets.Cli - CLI tool for encrypting secrets in JSON configuration files using hybrid RSA+AES encryption. - configuration;secrets;encryption;cli;tool;dotnet-tool - - - - - - - - - - - + + + + Exe + net9.0 + enable + enable + + + true + + + true + cocoar-secrets + Cocoar.Configuration.Secrets.Cli + CLI tool for encrypting secrets in JSON configuration files using hybrid RSA+AES encryption. + configuration;secrets;encryption;cli;tool;dotnet-tool + + + + + + + + + + + diff --git a/src/Cocoar.Configuration.Secrets.Cli/Commands/CertInfoCommand.cs b/src/Cocoar.Configuration.Secrets.Cli/Commands/CertInfoCommand.cs index 95c5439..f5ef167 100644 --- a/src/Cocoar.Configuration.Secrets.Cli/Commands/CertInfoCommand.cs +++ b/src/Cocoar.Configuration.Secrets.Cli/Commands/CertInfoCommand.cs @@ -1,253 +1,253 @@ -using System.CommandLine; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; - -namespace Cocoar.Configuration.Secrets.Cli.Commands; - -internal static class CertInfoCommand -{ - public static Command Create() - { - var command = new Command("cert-info", "Display detailed information about a certificate"); - - var inputOption = new Option("--input") - { - Description = "Certificate file path (PFX or PEM)", - Required = true - }; - inputOption.Aliases.Add("-i"); - - var passwordOption = new Option("--password") - { - Description = "Certificate password (if password-protected)", - Required = false - }; - passwordOption.Aliases.Add("-pwd"); - - command.Options.Add(inputOption); - command.Options.Add(passwordOption); - - command.SetAction(parseResult => - { - var input = parseResult.GetValue(inputOption); - var password = parseResult.GetValue(passwordOption); - return ExecuteAsync(input!, password).GetAwaiter().GetResult(); - }); - - return command; - } - - private static Task ExecuteAsync(string input, string? password) - { - try - { - if (!File.Exists(input)) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"✗ Certificate file not found: {input}"); - Console.ResetColor(); - return Task.FromResult(1); - } - - Console.WriteLine($"Analyzing certificate: {input}"); - Console.WriteLine(); - - // Try with password first (if provided), then without - X509Certificate2? cert = null; - bool isPasswordProtected = false; - - if (!string.IsNullOrEmpty(password)) - { - try - { - cert = X509CertificateLoader.LoadPkcs12FromFile(input, password, X509KeyStorageFlags.Exportable); - isPasswordProtected = true; - } - catch - { - cert = X509CertificateLoader.LoadPkcs12FromFile(input, null, X509KeyStorageFlags.Exportable); - } - } - else - { - try - { - cert = X509CertificateLoader.LoadPkcs12FromFile(input, null, X509KeyStorageFlags.Exportable); - } - catch (CryptographicException ex) - { - if (ex.Message.Contains("password") || ex.HResult == unchecked((int)0x80070056)) // ERROR_INVALID_PASSWORD - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("⚠️ Certificate appears to be password-protected."); - Console.WriteLine(" Use --password option to provide the password."); - Console.ResetColor(); - return Task.FromResult(1); - } - throw; - } - } - - using (cert) - { - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("📜 Certificate Information"); - Console.ResetColor(); - Console.WriteLine($" Subject: {cert.Subject}"); - Console.WriteLine($" Issuer: {cert.Issuer}"); - Console.WriteLine($" Serial Number: {cert.SerialNumber}"); - Console.WriteLine($" Thumbprint: {cert.Thumbprint}"); - Console.WriteLine(); - - // Validity - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("📅 Validity"); - Console.ResetColor(); - Console.WriteLine($" Not Before: {cert.NotBefore:yyyy-MM-dd HH:mm:ss}"); - Console.WriteLine($" Not After: {cert.NotAfter:yyyy-MM-dd HH:mm:ss}"); - - var now = DateTime.Now; - if (now < cert.NotBefore) - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($" Status: ⚠️ Not yet valid (starts in {(cert.NotBefore - now).Days} days)"); - Console.ResetColor(); - } - else if (now > cert.NotAfter) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($" Status: ✗ Expired ({(now - cert.NotAfter).Days} days ago)"); - Console.ResetColor(); - } - else - { - var daysUntilExpiry = (cert.NotAfter - now).Days; - Console.ForegroundColor = daysUntilExpiry < 30 ? ConsoleColor.Yellow : ConsoleColor.Green; - Console.WriteLine($" Status: ✓ Valid ({daysUntilExpiry} days remaining)"); - Console.ResetColor(); - } - Console.WriteLine(); - - // Key Information - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("🔑 Key Information"); - Console.ResetColor(); - - var rsa = cert.GetRSAPublicKey(); - if (rsa is not null) - { - Console.WriteLine($" Algorithm: RSA"); - Console.WriteLine($" Key Size: {rsa.KeySize} bits"); - } - else - { - var ecdsa = cert.GetECDsaPublicKey(); - if (ecdsa is not null) - { - Console.WriteLine($" Algorithm: ECDSA"); - Console.WriteLine($" Key Size: {ecdsa.KeySize} bits"); - } - else - { - Console.WriteLine($" Algorithm: {cert.PublicKey.Oid.FriendlyName}"); - } - } - - Console.WriteLine($" Has Private Key: {(cert.HasPrivateKey ? "✓ Yes" : "✗ No")}"); - - if (cert.HasPrivateKey) - { - Console.ForegroundColor = isPasswordProtected ? ConsoleColor.Yellow : ConsoleColor.Green; - Console.WriteLine($" Password: {(isPasswordProtected ? "🔒 Protected" : "🔓 None (password-less)")}"); - Console.ResetColor(); - } - Console.WriteLine(); - - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("🎯 Key Usage"); - Console.ResetColor(); - - var keyUsageExt = cert.Extensions.OfType().FirstOrDefault(); - if (keyUsageExt != null) - { - Console.WriteLine($" {keyUsageExt.KeyUsages}"); - } - else - { - Console.WriteLine($" (No key usage extension)"); - } - Console.WriteLine(); - - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("📁 File Information"); - Console.ResetColor(); - var fileInfo = new FileInfo(input); - Console.WriteLine($" File Size: {fileInfo.Length:N0} bytes"); - Console.WriteLine($" Format: {(input.EndsWith(".pfx", StringComparison.OrdinalIgnoreCase) || input.EndsWith(".p12", StringComparison.OrdinalIgnoreCase) ? "PFX (PKCS#12)" : "PEM")}"); - Console.WriteLine($" Created: {fileInfo.CreationTime:yyyy-MM-dd HH:mm:ss}"); - Console.WriteLine($" Modified: {fileInfo.LastWriteTime:yyyy-MM-dd HH:mm:ss}"); - Console.WriteLine(); - - if (isPasswordProtected) - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("💡 Recommendation:"); - Console.WriteLine(" Consider converting to password-less format for production use:"); - Console.WriteLine($" cocoar-secrets remove-password -i \"{input}\" -pwd \"YourPassword\" -o passwordless.pfx"); - Console.ResetColor(); - } - else if (!cert.HasPrivateKey) - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("⚠️ Warning:"); - Console.WriteLine(" This certificate has no private key - cannot be used for decryption."); - Console.ResetColor(); - } - else - { - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine("✓ Certificate is ready for use (password-less with private key)"); - Console.ResetColor(); - } - } - - return Task.FromResult(0); - } - catch (ArgumentException ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"✗ Error: {ex.Message}"); - Console.ResetColor(); - return Task.FromResult(1); - } - catch (FileNotFoundException ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"✗ Error: {ex.Message}"); - Console.ResetColor(); - return Task.FromResult(2); - } - catch (IOException ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"✗ Error: {ex.Message}"); - Console.ResetColor(); - return Task.FromResult(2); - } - catch (System.Security.Cryptography.CryptographicException ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"✗ Error: Failed to read certificate. Check password and file format."); - Console.WriteLine($" Details: {ex.Message}"); - Console.ResetColor(); - return Task.FromResult(3); - } - catch (Exception ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"✗ Error: {ex.Message}"); - Console.ResetColor(); - return Task.FromResult(4); - } - } -} +using System.CommandLine; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Cocoar.Configuration.Secrets.Cli.Commands; + +internal static class CertInfoCommand +{ + public static Command Create() + { + var command = new Command("cert-info", "Display detailed information about a certificate"); + + var inputOption = new Option("--input") + { + Description = "Certificate file path (PFX or PEM)", + Required = true + }; + inputOption.Aliases.Add("-i"); + + var passwordOption = new Option("--password") + { + Description = "Certificate password (if password-protected)", + Required = false + }; + passwordOption.Aliases.Add("-pwd"); + + command.Options.Add(inputOption); + command.Options.Add(passwordOption); + + command.SetAction(parseResult => + { + var input = parseResult.GetValue(inputOption); + var password = parseResult.GetValue(passwordOption); + return ExecuteAsync(input!, password).GetAwaiter().GetResult(); + }); + + return command; + } + + private static Task ExecuteAsync(string input, string? password) + { + try + { + if (!File.Exists(input)) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"✗ Certificate file not found: {input}"); + Console.ResetColor(); + return Task.FromResult(1); + } + + Console.WriteLine($"Analyzing certificate: {input}"); + Console.WriteLine(); + + // Try with password first (if provided), then without + X509Certificate2? cert = null; + bool isPasswordProtected = false; + + if (!string.IsNullOrEmpty(password)) + { + try + { + cert = X509CertificateLoader.LoadPkcs12FromFile(input, password, X509KeyStorageFlags.Exportable); + isPasswordProtected = true; + } + catch + { + cert = X509CertificateLoader.LoadPkcs12FromFile(input, null, X509KeyStorageFlags.Exportable); + } + } + else + { + try + { + cert = X509CertificateLoader.LoadPkcs12FromFile(input, null, X509KeyStorageFlags.Exportable); + } + catch (CryptographicException ex) + { + if (ex.Message.Contains("password") || ex.HResult == unchecked((int)0x80070056)) // ERROR_INVALID_PASSWORD + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("⚠️ Certificate appears to be password-protected."); + Console.WriteLine(" Use --password option to provide the password."); + Console.ResetColor(); + return Task.FromResult(1); + } + throw; + } + } + + using (cert) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("📜 Certificate Information"); + Console.ResetColor(); + Console.WriteLine($" Subject: {cert.Subject}"); + Console.WriteLine($" Issuer: {cert.Issuer}"); + Console.WriteLine($" Serial Number: {cert.SerialNumber}"); + Console.WriteLine($" Thumbprint: {cert.Thumbprint}"); + Console.WriteLine(); + + // Validity + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("📅 Validity"); + Console.ResetColor(); + Console.WriteLine($" Not Before: {cert.NotBefore:yyyy-MM-dd HH:mm:ss}"); + Console.WriteLine($" Not After: {cert.NotAfter:yyyy-MM-dd HH:mm:ss}"); + + var now = DateTime.Now; + if (now < cert.NotBefore) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" Status: ⚠️ Not yet valid (starts in {(cert.NotBefore - now).Days} days)"); + Console.ResetColor(); + } + else if (now > cert.NotAfter) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($" Status: ✗ Expired ({(now - cert.NotAfter).Days} days ago)"); + Console.ResetColor(); + } + else + { + var daysUntilExpiry = (cert.NotAfter - now).Days; + Console.ForegroundColor = daysUntilExpiry < 30 ? ConsoleColor.Yellow : ConsoleColor.Green; + Console.WriteLine($" Status: ✓ Valid ({daysUntilExpiry} days remaining)"); + Console.ResetColor(); + } + Console.WriteLine(); + + // Key Information + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("🔑 Key Information"); + Console.ResetColor(); + + var rsa = cert.GetRSAPublicKey(); + if (rsa is not null) + { + Console.WriteLine($" Algorithm: RSA"); + Console.WriteLine($" Key Size: {rsa.KeySize} bits"); + } + else + { + var ecdsa = cert.GetECDsaPublicKey(); + if (ecdsa is not null) + { + Console.WriteLine($" Algorithm: ECDSA"); + Console.WriteLine($" Key Size: {ecdsa.KeySize} bits"); + } + else + { + Console.WriteLine($" Algorithm: {cert.PublicKey.Oid.FriendlyName}"); + } + } + + Console.WriteLine($" Has Private Key: {(cert.HasPrivateKey ? "✓ Yes" : "✗ No")}"); + + if (cert.HasPrivateKey) + { + Console.ForegroundColor = isPasswordProtected ? ConsoleColor.Yellow : ConsoleColor.Green; + Console.WriteLine($" Password: {(isPasswordProtected ? "🔒 Protected" : "🔓 None (password-less)")}"); + Console.ResetColor(); + } + Console.WriteLine(); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("🎯 Key Usage"); + Console.ResetColor(); + + var keyUsageExt = cert.Extensions.OfType().FirstOrDefault(); + if (keyUsageExt != null) + { + Console.WriteLine($" {keyUsageExt.KeyUsages}"); + } + else + { + Console.WriteLine($" (No key usage extension)"); + } + Console.WriteLine(); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("📁 File Information"); + Console.ResetColor(); + var fileInfo = new FileInfo(input); + Console.WriteLine($" File Size: {fileInfo.Length:N0} bytes"); + Console.WriteLine($" Format: {(input.EndsWith(".pfx", StringComparison.OrdinalIgnoreCase) || input.EndsWith(".p12", StringComparison.OrdinalIgnoreCase) ? "PFX (PKCS#12)" : "PEM")}"); + Console.WriteLine($" Created: {fileInfo.CreationTime:yyyy-MM-dd HH:mm:ss}"); + Console.WriteLine($" Modified: {fileInfo.LastWriteTime:yyyy-MM-dd HH:mm:ss}"); + Console.WriteLine(); + + if (isPasswordProtected) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("💡 Recommendation:"); + Console.WriteLine(" Consider converting to password-less format for production use:"); + Console.WriteLine($" cocoar-secrets remove-password -i \"{input}\" -pwd \"YourPassword\" -o passwordless.pfx"); + Console.ResetColor(); + } + else if (!cert.HasPrivateKey) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("⚠️ Warning:"); + Console.WriteLine(" This certificate has no private key - cannot be used for decryption."); + Console.ResetColor(); + } + else + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("✓ Certificate is ready for use (password-less with private key)"); + Console.ResetColor(); + } + } + + return Task.FromResult(0); + } + catch (ArgumentException ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"✗ Error: {ex.Message}"); + Console.ResetColor(); + return Task.FromResult(1); + } + catch (FileNotFoundException ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"✗ Error: {ex.Message}"); + Console.ResetColor(); + return Task.FromResult(2); + } + catch (IOException ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"✗ Error: {ex.Message}"); + Console.ResetColor(); + return Task.FromResult(2); + } + catch (System.Security.Cryptography.CryptographicException ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"✗ Error: Failed to read certificate. Check password and file format."); + Console.WriteLine($" Details: {ex.Message}"); + Console.ResetColor(); + return Task.FromResult(3); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"✗ Error: {ex.Message}"); + Console.ResetColor(); + return Task.FromResult(4); + } + } +} diff --git a/src/Cocoar.Configuration.Secrets.Cli/Commands/ConvertCertCommand.cs b/src/Cocoar.Configuration.Secrets.Cli/Commands/ConvertCertCommand.cs index 44a6fb1..2246b56 100644 --- a/src/Cocoar.Configuration.Secrets.Cli/Commands/ConvertCertCommand.cs +++ b/src/Cocoar.Configuration.Secrets.Cli/Commands/ConvertCertCommand.cs @@ -1,217 +1,217 @@ -using System.CommandLine; -using System.Security.Cryptography.X509Certificates; -using Cocoar.Configuration.X509Encryption; - -namespace Cocoar.Configuration.Secrets.Cli.Commands; - -internal static class ConvertCertCommand -{ - public static Command Create() - { - var command = new Command("convert-cert", "Convert certificate between PFX and PEM formats"); - - var inputOption = new Option("--input") - { - Description = "Input certificate file (.pfx, .p12, .crt, .pem, .cer)", - Required = true - }; - inputOption.Aliases.Add("-i"); - - var outputOption = new Option("--output") - { - Description = "Output certificate file", - Required = true - }; - outputOption.Aliases.Add("-o"); - - var inputPasswordOption = new Option("--input-password") - { - Description = "Password for input PFX file (required when converting from PFX)" - }; - inputPasswordOption.Aliases.Add("--ipass"); - - var outputPasswordOption = new Option("--output-password") - { - Description = "Password for output PFX file (optional; omit for password-less PFX)" - }; - outputPasswordOption.Aliases.Add("--opass"); - - var formatOption = new Option("--format") - { - Description = "Output format: pfx, pem, or auto (detect from extension)", - DefaultValueFactory = _ => "auto" - }; - formatOption.Aliases.Add("-f"); - - var overwriteOption = new Option("--overwrite") - { - Description = "Overwrite existing output file(s) without prompt", - DefaultValueFactory = _ => false - }; - - command.Options.Add(inputOption); - command.Options.Add(outputOption); - command.Options.Add(inputPasswordOption); - command.Options.Add(outputPasswordOption); - command.Options.Add(formatOption); - command.Options.Add(overwriteOption); - - command.SetAction(parseResult => - { - var input = parseResult.GetValue(inputOption); - var output = parseResult.GetValue(outputOption); - var inputPassword = parseResult.GetValue(inputPasswordOption); - var outputPassword = parseResult.GetValue(outputPasswordOption); - var format = parseResult.GetValue(formatOption); - var overwrite = parseResult.GetValue(overwriteOption); - // inputOption and outputOption have Required = true; formatOption has DefaultValueFactory - return ExecuteAsync(input!, output!, inputPassword, outputPassword, format!, overwrite).GetAwaiter().GetResult(); - }); - - return command; - } - - private static Task ExecuteAsync( - string input, - string output, - string? inputPassword, - string? outputPassword, - string format, - bool overwrite) - { - try - { - var inputFormat = DetectFormat(input); - var outputFormat = format.ToLowerInvariant() switch - { - "pfx" => CertificateFormat.Pfx, - "pem" => CertificateFormat.Pem, - "auto" => DetectFormat(output), - _ => throw new ArgumentException($"Invalid format '{format}'. Use 'pfx', 'pem', or 'auto'.") - }; - - // Input password optional - will try loading without password if not provided - // Output password optional - password-less by default - var useOutputPassword = !string.IsNullOrWhiteSpace(outputPassword); - - if (!overwrite) - { - if (outputFormat == CertificateFormat.Pfx && File.Exists(output)) - { - Console.Error.WriteLine($"❌ Error: Output file already exists: {output}. Use --overwrite to replace."); - return Task.FromResult(2); - } - else if (outputFormat == CertificateFormat.Pem) - { - var keyPath = Path.ChangeExtension(output, ".key"); - if (File.Exists(output) || File.Exists(keyPath)) - { - Console.Error.WriteLine($"❌ Error: Output files already exist: {output} or {keyPath}. Use --overwrite to replace."); - return Task.FromResult(2); - } - } - } - - X509Certificate2 cert; - if (inputFormat == CertificateFormat.Pfx) - { - Console.WriteLine($"Loading PFX: {input}"); - if (outputFormat == CertificateFormat.Pem) - { - var keyPath = Path.ChangeExtension(output, ".key"); - cert = X509CertificateGenerator.ConvertPfxToPem(input, inputPassword!, output, keyPath, overwrite); - } - else - { - // PFX → PFX: password change or removal - cert = X509CertificateLoader.LoadPkcs12FromFile(input, inputPassword, X509KeyStorageFlags.Exportable); - var exportBytes = cert.Export(X509ContentType.Pfx, outputPassword); - File.WriteAllBytes(output, exportBytes); - } - } - else - { - Console.WriteLine($"Loading PEM: {input} + {Path.ChangeExtension(input, ".key")}"); - cert = outputFormat == CertificateFormat.Pfx - ? X509CertificateGenerator.ConvertPemToPfx(input, null, output, outputPassword!, overwrite) - : throw new InvalidOperationException("Cannot convert PEM to PEM. Use same format."); - } - - try - { - if (outputFormat == CertificateFormat.Pfx) - { - var passwordStatus = useOutputPassword ? "password-protected" : "password-less"; - Console.WriteLine($"✓ Certificate converted to PFX ({passwordStatus}): {output}"); - if (!useOutputPassword) - { - Console.WriteLine(" ⚠️ Protect with file permissions!"); - if (OperatingSystem.IsWindows()) - Console.WriteLine(" Windows: icacls cert.pfx /inheritance:r /grant:r \"YourUser:(R)\""); - else - Console.WriteLine(" Linux/macOS: chmod 600 cert.pfx && chown app-user cert.pfx"); - } - } - else - { - var keyPath = Path.ChangeExtension(output, ".key"); - Console.WriteLine($"✓ Certificate converted to PEM:"); - Console.WriteLine($" Certificate: {output}"); - Console.WriteLine($" Private Key: {keyPath}"); - Console.WriteLine(" ⚠️ Protect private key with file permissions!"); - if (OperatingSystem.IsWindows()) - Console.WriteLine(" Windows: icacls {keyPath} /inheritance:r /grant:r \"YourUser:(R)\""); - else - Console.WriteLine(" Linux/macOS: chmod 600 {keyPath} && chown app-user {keyPath}"); - } - - Console.WriteLine($" Subject: {cert.Subject}"); - Console.WriteLine($" Valid: {cert.NotBefore:yyyy-MM-dd} to {cert.NotAfter:yyyy-MM-dd}"); - Console.WriteLine($" Thumbprint: {cert.Thumbprint}"); - - return Task.FromResult(0); - } - finally - { - cert.Dispose(); - } - } - catch (ArgumentException ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return Task.FromResult(1); - } - catch (FileNotFoundException ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return Task.FromResult(2); - } - catch (IOException ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return Task.FromResult(2); - } - catch (System.Security.Cryptography.CryptographicException ex) - { - Console.Error.WriteLine($"❌ Error: Failed to load certificate. Check password and file format."); - Console.Error.WriteLine($" Details: {ex.Message}"); - return Task.FromResult(3); - } - catch (Exception ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return Task.FromResult(4); - } - } - - private static CertificateFormat DetectFormat(string path) - { - var ext = Path.GetExtension(path).ToLowerInvariant(); - return ext switch - { - ".pfx" or ".p12" => CertificateFormat.Pfx, - ".pem" or ".crt" or ".cer" => CertificateFormat.Pem, - _ => CertificateFormat.Pfx // Default - }; - } -} +using System.CommandLine; +using System.Security.Cryptography.X509Certificates; +using Cocoar.Configuration.X509Encryption; + +namespace Cocoar.Configuration.Secrets.Cli.Commands; + +internal static class ConvertCertCommand +{ + public static Command Create() + { + var command = new Command("convert-cert", "Convert certificate between PFX and PEM formats"); + + var inputOption = new Option("--input") + { + Description = "Input certificate file (.pfx, .p12, .crt, .pem, .cer)", + Required = true + }; + inputOption.Aliases.Add("-i"); + + var outputOption = new Option("--output") + { + Description = "Output certificate file", + Required = true + }; + outputOption.Aliases.Add("-o"); + + var inputPasswordOption = new Option("--input-password") + { + Description = "Password for input PFX file (required when converting from PFX)" + }; + inputPasswordOption.Aliases.Add("--ipass"); + + var outputPasswordOption = new Option("--output-password") + { + Description = "Password for output PFX file (optional; omit for password-less PFX)" + }; + outputPasswordOption.Aliases.Add("--opass"); + + var formatOption = new Option("--format") + { + Description = "Output format: pfx, pem, or auto (detect from extension)", + DefaultValueFactory = _ => "auto" + }; + formatOption.Aliases.Add("-f"); + + var overwriteOption = new Option("--overwrite") + { + Description = "Overwrite existing output file(s) without prompt", + DefaultValueFactory = _ => false + }; + + command.Options.Add(inputOption); + command.Options.Add(outputOption); + command.Options.Add(inputPasswordOption); + command.Options.Add(outputPasswordOption); + command.Options.Add(formatOption); + command.Options.Add(overwriteOption); + + command.SetAction(parseResult => + { + var input = parseResult.GetValue(inputOption); + var output = parseResult.GetValue(outputOption); + var inputPassword = parseResult.GetValue(inputPasswordOption); + var outputPassword = parseResult.GetValue(outputPasswordOption); + var format = parseResult.GetValue(formatOption); + var overwrite = parseResult.GetValue(overwriteOption); + // inputOption and outputOption have Required = true; formatOption has DefaultValueFactory + return ExecuteAsync(input!, output!, inputPassword, outputPassword, format!, overwrite).GetAwaiter().GetResult(); + }); + + return command; + } + + private static Task ExecuteAsync( + string input, + string output, + string? inputPassword, + string? outputPassword, + string format, + bool overwrite) + { + try + { + var inputFormat = DetectFormat(input); + var outputFormat = format.ToLowerInvariant() switch + { + "pfx" => CertificateFormat.Pfx, + "pem" => CertificateFormat.Pem, + "auto" => DetectFormat(output), + _ => throw new ArgumentException($"Invalid format '{format}'. Use 'pfx', 'pem', or 'auto'.") + }; + + // Input password optional - will try loading without password if not provided + // Output password optional - password-less by default + var useOutputPassword = !string.IsNullOrWhiteSpace(outputPassword); + + if (!overwrite) + { + if (outputFormat == CertificateFormat.Pfx && File.Exists(output)) + { + Console.Error.WriteLine($"❌ Error: Output file already exists: {output}. Use --overwrite to replace."); + return Task.FromResult(2); + } + else if (outputFormat == CertificateFormat.Pem) + { + var keyPath = Path.ChangeExtension(output, ".key"); + if (File.Exists(output) || File.Exists(keyPath)) + { + Console.Error.WriteLine($"❌ Error: Output files already exist: {output} or {keyPath}. Use --overwrite to replace."); + return Task.FromResult(2); + } + } + } + + X509Certificate2 cert; + if (inputFormat == CertificateFormat.Pfx) + { + Console.WriteLine($"Loading PFX: {input}"); + if (outputFormat == CertificateFormat.Pem) + { + var keyPath = Path.ChangeExtension(output, ".key"); + cert = X509CertificateGenerator.ConvertPfxToPem(input, inputPassword!, output, keyPath, overwrite); + } + else + { + // PFX → PFX: password change or removal + cert = X509CertificateLoader.LoadPkcs12FromFile(input, inputPassword, X509KeyStorageFlags.Exportable); + var exportBytes = cert.Export(X509ContentType.Pfx, outputPassword); + File.WriteAllBytes(output, exportBytes); + } + } + else + { + Console.WriteLine($"Loading PEM: {input} + {Path.ChangeExtension(input, ".key")}"); + cert = outputFormat == CertificateFormat.Pfx + ? X509CertificateGenerator.ConvertPemToPfx(input, null, output, outputPassword!, overwrite) + : throw new InvalidOperationException("Cannot convert PEM to PEM. Use same format."); + } + + try + { + if (outputFormat == CertificateFormat.Pfx) + { + var passwordStatus = useOutputPassword ? "password-protected" : "password-less"; + Console.WriteLine($"✓ Certificate converted to PFX ({passwordStatus}): {output}"); + if (!useOutputPassword) + { + Console.WriteLine(" ⚠️ Protect with file permissions!"); + if (OperatingSystem.IsWindows()) + Console.WriteLine(" Windows: icacls cert.pfx /inheritance:r /grant:r \"YourUser:(R)\""); + else + Console.WriteLine(" Linux/macOS: chmod 600 cert.pfx && chown app-user cert.pfx"); + } + } + else + { + var keyPath = Path.ChangeExtension(output, ".key"); + Console.WriteLine($"✓ Certificate converted to PEM:"); + Console.WriteLine($" Certificate: {output}"); + Console.WriteLine($" Private Key: {keyPath}"); + Console.WriteLine(" ⚠️ Protect private key with file permissions!"); + if (OperatingSystem.IsWindows()) + Console.WriteLine(" Windows: icacls {keyPath} /inheritance:r /grant:r \"YourUser:(R)\""); + else + Console.WriteLine(" Linux/macOS: chmod 600 {keyPath} && chown app-user {keyPath}"); + } + + Console.WriteLine($" Subject: {cert.Subject}"); + Console.WriteLine($" Valid: {cert.NotBefore:yyyy-MM-dd} to {cert.NotAfter:yyyy-MM-dd}"); + Console.WriteLine($" Thumbprint: {cert.Thumbprint}"); + + return Task.FromResult(0); + } + finally + { + cert.Dispose(); + } + } + catch (ArgumentException ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return Task.FromResult(1); + } + catch (FileNotFoundException ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return Task.FromResult(2); + } + catch (IOException ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return Task.FromResult(2); + } + catch (System.Security.Cryptography.CryptographicException ex) + { + Console.Error.WriteLine($"❌ Error: Failed to load certificate. Check password and file format."); + Console.Error.WriteLine($" Details: {ex.Message}"); + return Task.FromResult(3); + } + catch (Exception ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return Task.FromResult(4); + } + } + + private static CertificateFormat DetectFormat(string path) + { + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext switch + { + ".pfx" or ".p12" => CertificateFormat.Pfx, + ".pem" or ".crt" or ".cer" => CertificateFormat.Pem, + _ => CertificateFormat.Pfx // Default + }; + } +} diff --git a/src/Cocoar.Configuration.Secrets.Cli/Commands/DecryptCommand.cs b/src/Cocoar.Configuration.Secrets.Cli/Commands/DecryptCommand.cs index daf1f3d..cb849ce 100644 --- a/src/Cocoar.Configuration.Secrets.Cli/Commands/DecryptCommand.cs +++ b/src/Cocoar.Configuration.Secrets.Cli/Commands/DecryptCommand.cs @@ -1,269 +1,269 @@ -using System.CommandLine; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using Cocoar.Configuration.X509Encryption; - -namespace Cocoar.Configuration.Secrets.Cli.Commands; - -internal static class DecryptCommand -{ - private static readonly JsonSerializerOptions IndentedJsonOptions = new() - { - WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - - public static Command Create() - { - var command = new Command("decrypt", "Decrypt an encrypted value from a JSON file"); - - var fileOption = new Option("--file") - { - Description = "Path to the JSON configuration file", - Required = true - }; - fileOption.Aliases.Add("-f"); - - var pathOption = new Option("--path") - { - Description = "Property path of the encrypted value (e.g. 'Database:ConnectionString')", - Required = true - }; - pathOption.Aliases.Add("-p"); - - var certOption = new Option("--cert") - { - Description = "Path to the PFX certificate file for decryption", - Required = true - }; - certOption.Aliases.Add("-c"); - - var passwordOption = new Option("--password") - { - Description = "Password for the certificate (will prompt if not provided)" - }; - passwordOption.Aliases.Add("-pwd"); - - var replaceOption = new Option("--replace") - { - Description = "Replace the encrypted value with plaintext in the JSON file (WARNING: modifies file)", - DefaultValueFactory = _ => false - }; - - command.Options.Add(fileOption); - command.Options.Add(pathOption); - command.Options.Add(certOption); - command.Options.Add(passwordOption); - command.Options.Add(replaceOption); - - command.SetAction(parseResult => - { - try - { - var file = parseResult.GetValue(fileOption); - var path = parseResult.GetValue(pathOption); - var cert = parseResult.GetValue(certOption); - var password = parseResult.GetValue(passwordOption); - var replace = parseResult.GetValue(replaceOption); - // fileOption, pathOption, certOption have Required = true - DecryptAsync(file!, path!, cert!, password, replace).GetAwaiter().GetResult(); - return 0; - } - catch (ArgumentException ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return 1; - } - catch (FileNotFoundException ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return 2; - } - catch (IOException ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return 2; - } - catch (System.Security.Cryptography.CryptographicException ex) - { - Console.Error.WriteLine($"❌ Error: Decryption failed. Check certificate and encrypted value."); - Console.Error.WriteLine($" Details: {ex.Message}"); - return 3; - } - catch (Exception ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return 4; - } - }); - - return command; - } - - private static async Task DecryptAsync( - FileInfo jsonFile, - string propertyPath, - FileInfo certFile, - string? password, - bool replace) - { - // Validate inputs - if (!jsonFile.Exists) - throw new FileNotFoundException($"JSON file not found: {jsonFile.FullName}"); - - if (!certFile.Exists) - throw new FileNotFoundException($"Certificate file not found: {certFile.FullName}"); - - // Prompt for certificate password if not provided - if (string.IsNullOrEmpty(password)) - { - Console.Write("Enter certificate password: "); - password = ReadPassword(); - Console.WriteLine(); - } - - // Load certificate and decrypt - var certificate = X509HybridCrypto.LoadCertificate(certFile.FullName, password); - var crypto = new X509HybridCrypto(certificate); - - // Read JSON file - var jsonText = await File.ReadAllTextAsync(jsonFile.FullName); - var jsonNode = JsonNode.Parse(jsonText); - - if (jsonNode is not JsonObject rootObject) - throw new InvalidOperationException("JSON file must contain a root object"); - - // Get the encrypted envelope from the path - var envelope = GetEnvelopeAtPath(rootObject, propertyPath); - - // Decrypt the value - var plaintext = crypto.DecryptToString(envelope); - - if (replace) - { - // Replace encrypted value with plaintext in JSON file - SetPlaintextAtPath(rootObject, propertyPath, plaintext); - - var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); - await File.WriteAllTextAsync(jsonFile.FullName, updatedJson, Encoding.UTF8); - - Console.WriteLine($"✓ Successfully decrypted value at '{propertyPath}' and replaced in file"); - } - else - { - // Default: just show the decrypted value, don't modify file - Console.WriteLine($"✓ Successfully decrypted value at '{propertyPath}'"); - Console.WriteLine($"\nDecrypted value:\n{plaintext}"); - } - } - - private static HybridSecretEnvelope GetEnvelopeAtPath(JsonObject root, string path) - { - var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) - throw new ArgumentException("Property path cannot be empty", nameof(path)); - - JsonNode? current = root; - - // Navigate to the property - foreach (var segment in segments) - { - if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next)) - { - current = next; - } - else - { - throw new InvalidOperationException($"Property path '{path}' not found in JSON"); - } - } - - if (current == null) - throw new InvalidOperationException($"Property at '{path}' is null"); - - // Deserialize the envelope - try - { - var envelope = JsonSerializer.Deserialize(current.ToJsonString()); - if (envelope == null) - throw new InvalidOperationException("Failed to deserialize envelope"); - - return envelope; - } - catch (JsonException ex) - { - throw new InvalidOperationException($"Property at '{path}' is not a valid HybridSecretEnvelope", ex); - } - } - - private static void SetPlaintextAtPath(JsonObject root, string path, string plaintext) - { - var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) - throw new ArgumentException("Property path cannot be empty", nameof(path)); - - JsonObject current = root; - - // Navigate to parent - for (int i = 0; i < segments.Length - 1; i++) - { - var segment = segments[i]; - - if (current[segment] is JsonObject existingObject) - { - current = existingObject; - } - else - { - throw new InvalidOperationException($"Cannot navigate to '{path}': parent object not found"); - } - } - - // Set the final property to the plaintext JSON value - var finalSegment = segments[^1]; - - // Parse plaintext as JSON to preserve type (string, number, boolean, etc.) - try - { - using var doc = JsonDocument.Parse(plaintext); - current[finalSegment] = JsonNode.Parse(plaintext); - } - catch (JsonException) - { - // If not valid JSON, treat as string literal - current[finalSegment] = JsonValue.Create(plaintext); - } - } - - private static string ReadPassword() - { - var password = new StringBuilder(); - ConsoleKeyInfo key; - - do - { - key = Console.ReadKey(intercept: true); - - if (key.Key == ConsoleKey.Backspace && password.Length > 0) - { - password.Remove(password.Length - 1, 1); - Console.Write("\b \b"); - } - else if (!char.IsControl(key.KeyChar)) - { - password.Append(key.KeyChar); - Console.Write("*"); - } - } while (key.Key != ConsoleKey.Enter); - - var result = password.ToString(); - - // Zero the StringBuilder buffer so the password doesn't linger in heap memory - for (var i = 0; i < password.Length; i++) - password[i] = '\0'; - password.Clear(); - - return result; - } -} +using System.CommandLine; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Cocoar.Configuration.X509Encryption; + +namespace Cocoar.Configuration.Secrets.Cli.Commands; + +internal static class DecryptCommand +{ + private static readonly JsonSerializerOptions IndentedJsonOptions = new() + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + public static Command Create() + { + var command = new Command("decrypt", "Decrypt an encrypted value from a JSON file"); + + var fileOption = new Option("--file") + { + Description = "Path to the JSON configuration file", + Required = true + }; + fileOption.Aliases.Add("-f"); + + var pathOption = new Option("--path") + { + Description = "Property path of the encrypted value (e.g. 'Database:ConnectionString')", + Required = true + }; + pathOption.Aliases.Add("-p"); + + var certOption = new Option("--cert") + { + Description = "Path to the PFX certificate file for decryption", + Required = true + }; + certOption.Aliases.Add("-c"); + + var passwordOption = new Option("--password") + { + Description = "Password for the certificate (will prompt if not provided)" + }; + passwordOption.Aliases.Add("-pwd"); + + var replaceOption = new Option("--replace") + { + Description = "Replace the encrypted value with plaintext in the JSON file (WARNING: modifies file)", + DefaultValueFactory = _ => false + }; + + command.Options.Add(fileOption); + command.Options.Add(pathOption); + command.Options.Add(certOption); + command.Options.Add(passwordOption); + command.Options.Add(replaceOption); + + command.SetAction(parseResult => + { + try + { + var file = parseResult.GetValue(fileOption); + var path = parseResult.GetValue(pathOption); + var cert = parseResult.GetValue(certOption); + var password = parseResult.GetValue(passwordOption); + var replace = parseResult.GetValue(replaceOption); + // fileOption, pathOption, certOption have Required = true + DecryptAsync(file!, path!, cert!, password, replace).GetAwaiter().GetResult(); + return 0; + } + catch (ArgumentException ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return 1; + } + catch (FileNotFoundException ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return 2; + } + catch (IOException ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return 2; + } + catch (System.Security.Cryptography.CryptographicException ex) + { + Console.Error.WriteLine($"❌ Error: Decryption failed. Check certificate and encrypted value."); + Console.Error.WriteLine($" Details: {ex.Message}"); + return 3; + } + catch (Exception ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return 4; + } + }); + + return command; + } + + private static async Task DecryptAsync( + FileInfo jsonFile, + string propertyPath, + FileInfo certFile, + string? password, + bool replace) + { + // Validate inputs + if (!jsonFile.Exists) + throw new FileNotFoundException($"JSON file not found: {jsonFile.FullName}"); + + if (!certFile.Exists) + throw new FileNotFoundException($"Certificate file not found: {certFile.FullName}"); + + // Prompt for certificate password if not provided + if (string.IsNullOrEmpty(password)) + { + Console.Write("Enter certificate password: "); + password = ReadPassword(); + Console.WriteLine(); + } + + // Load certificate and decrypt + var certificate = X509HybridCrypto.LoadCertificate(certFile.FullName, password); + var crypto = new X509HybridCrypto(certificate); + + // Read JSON file + var jsonText = await File.ReadAllTextAsync(jsonFile.FullName); + var jsonNode = JsonNode.Parse(jsonText); + + if (jsonNode is not JsonObject rootObject) + throw new InvalidOperationException("JSON file must contain a root object"); + + // Get the encrypted envelope from the path + var envelope = GetEnvelopeAtPath(rootObject, propertyPath); + + // Decrypt the value + var plaintext = crypto.DecryptToString(envelope); + + if (replace) + { + // Replace encrypted value with plaintext in JSON file + SetPlaintextAtPath(rootObject, propertyPath, plaintext); + + var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); + await File.WriteAllTextAsync(jsonFile.FullName, updatedJson, Encoding.UTF8); + + Console.WriteLine($"✓ Successfully decrypted value at '{propertyPath}' and replaced in file"); + } + else + { + // Default: just show the decrypted value, don't modify file + Console.WriteLine($"✓ Successfully decrypted value at '{propertyPath}'"); + Console.WriteLine($"\nDecrypted value:\n{plaintext}"); + } + } + + private static HybridSecretEnvelope GetEnvelopeAtPath(JsonObject root, string path) + { + var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + throw new ArgumentException("Property path cannot be empty", nameof(path)); + + JsonNode? current = root; + + // Navigate to the property + foreach (var segment in segments) + { + if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next)) + { + current = next; + } + else + { + throw new InvalidOperationException($"Property path '{path}' not found in JSON"); + } + } + + if (current == null) + throw new InvalidOperationException($"Property at '{path}' is null"); + + // Deserialize the envelope + try + { + var envelope = JsonSerializer.Deserialize(current.ToJsonString()); + if (envelope == null) + throw new InvalidOperationException("Failed to deserialize envelope"); + + return envelope; + } + catch (JsonException ex) + { + throw new InvalidOperationException($"Property at '{path}' is not a valid HybridSecretEnvelope", ex); + } + } + + private static void SetPlaintextAtPath(JsonObject root, string path, string plaintext) + { + var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + throw new ArgumentException("Property path cannot be empty", nameof(path)); + + JsonObject current = root; + + // Navigate to parent + for (int i = 0; i < segments.Length - 1; i++) + { + var segment = segments[i]; + + if (current[segment] is JsonObject existingObject) + { + current = existingObject; + } + else + { + throw new InvalidOperationException($"Cannot navigate to '{path}': parent object not found"); + } + } + + // Set the final property to the plaintext JSON value + var finalSegment = segments[^1]; + + // Parse plaintext as JSON to preserve type (string, number, boolean, etc.) + try + { + using var doc = JsonDocument.Parse(plaintext); + current[finalSegment] = JsonNode.Parse(plaintext); + } + catch (JsonException) + { + // If not valid JSON, treat as string literal + current[finalSegment] = JsonValue.Create(plaintext); + } + } + + private static string ReadPassword() + { + var password = new StringBuilder(); + ConsoleKeyInfo key; + + do + { + key = Console.ReadKey(intercept: true); + + if (key.Key == ConsoleKey.Backspace && password.Length > 0) + { + password.Remove(password.Length - 1, 1); + Console.Write("\b \b"); + } + else if (!char.IsControl(key.KeyChar)) + { + password.Append(key.KeyChar); + Console.Write("*"); + } + } while (key.Key != ConsoleKey.Enter); + + var result = password.ToString(); + + // Zero the StringBuilder buffer so the password doesn't linger in heap memory + for (var i = 0; i < password.Length; i++) + password[i] = '\0'; + password.Clear(); + + return result; + } +} diff --git a/src/Cocoar.Configuration.Secrets.Cli/Commands/EncryptCommand.cs b/src/Cocoar.Configuration.Secrets.Cli/Commands/EncryptCommand.cs index edef562..4f96ed8 100644 --- a/src/Cocoar.Configuration.Secrets.Cli/Commands/EncryptCommand.cs +++ b/src/Cocoar.Configuration.Secrets.Cli/Commands/EncryptCommand.cs @@ -1,217 +1,217 @@ -using System.CommandLine; -using System.Text; -using Cocoar.Configuration.X509Encryption; - -namespace Cocoar.Configuration.Secrets.Cli.Commands; - -internal static class EncryptCommand -{ - public static Command Create() - { - var command = new Command("encrypt", "Encrypt a value and set it at a property path in a JSON file"); - - var fileOption = new Option("--file") - { - Description = "Path to the JSON configuration file", - Required = true - }; - fileOption.Aliases.Add("-f"); - - var pathOption = new Option("--path") - { - Description = "Property path where to set the encrypted value (e.g. 'Database:ConnectionString' or 'ApiKeys:Stripe')", - Required = true - }; - pathOption.Aliases.Add("-p"); - - var valueOption = new Option("--value") - { - Description = "The plaintext value to encrypt. If omitted, encrypts the existing value at the specified path. " + - "For enum values, pass the name (e.g. 'Active') rather than the number — names survive enum reordering.", - Required = false - }; - valueOption.Aliases.Add("-v"); - - var certOption = new Option("--cert") - { - Description = "Path to the PFX certificate file for encryption", - Required = true - }; - certOption.Aliases.Add("-c"); - - var passwordOption = new Option("--password") - { - Description = "Password for the PFX certificate (will prompt if not provided)" - }; - passwordOption.Aliases.Add("-pwd"); - - var kidOption = new Option("--kid") - { - Description = "Key identifier (kid) for the certificate", - DefaultValueFactory = _ => "default" - }; - - var createOption = new Option("--create") - { - Description = "Create the JSON file if it doesn't exist (prevents accidental file creation from typos)", - DefaultValueFactory = _ => false - }; - - command.Options.Add(fileOption); - command.Options.Add(pathOption); - command.Options.Add(valueOption); - command.Options.Add(certOption); - command.Options.Add(passwordOption); - command.Options.Add(kidOption); - command.Options.Add(createOption); - - command.SetAction(parseResult => - { - try - { - var file = parseResult.GetValue(fileOption); - var path = parseResult.GetValue(pathOption); - var value = parseResult.GetValue(valueOption); - var cert = parseResult.GetValue(certOption); - var password = parseResult.GetValue(passwordOption); - var kid = parseResult.GetValue(kidOption); - var create = parseResult.GetValue(createOption); - // fileOption, pathOption, certOption have Required = true; kidOption has DefaultValueFactory - EncryptValueAsync(file!, path!, value, cert!, password, kid!, create).GetAwaiter().GetResult(); - return 0; - } - catch (ArgumentException ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return 1; - } - catch (FileNotFoundException ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return 2; - } - catch (IOException ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return 2; - } - catch (System.Security.Cryptography.CryptographicException ex) - { - Console.Error.WriteLine($"❌ Error: Encryption failed. Check certificate and file format."); - Console.Error.WriteLine($" Details: {ex.Message}"); - return 3; - } - catch (Exception ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return 4; - } - }); - - return command; - } - - private static async Task EncryptValueAsync( - FileInfo jsonFile, - string propertyPath, - string? plaintext, - FileInfo certFile, - string? password, - string kid, - bool allowCreate) - { - // Validate JSON file exists or creation is allowed - if (!jsonFile.Exists && !allowCreate) - throw new FileNotFoundException($"JSON file not found: {jsonFile.FullName}. Use --create to create a new file."); - - // Validate certificate file exists - if (!certFile.Exists) - throw new FileNotFoundException($"Certificate file not found: {certFile.FullName}"); - - // Prompt for password if not provided - if (string.IsNullOrEmpty(password)) - { - Console.Write("Enter certificate password: "); - password = ReadPassword(); - Console.WriteLine(); - } - - // Load certificate - var certificate = X509HybridCrypto.LoadCertificate(certFile.FullName, password); - - bool wasCreated; - - if (plaintext is not null) - { - // Encrypt the provided value - // Try to parse as JSON first (handles numbers, booleans, null, objects, arrays) - // If parsing fails, treat as a string and serialize it - string jsonValue; - try - { - // Attempt to parse as JSON - this validates JSON syntax - using var doc = System.Text.Json.JsonDocument.Parse(plaintext); - // If successful, use the original value as-is (it's valid JSON) - jsonValue = plaintext; - } - catch (System.Text.Json.JsonException) - { - // Not valid JSON, treat as a string and serialize it with quotes - jsonValue = System.Text.Json.JsonSerializer.Serialize(plaintext); - } - - wasCreated = await JsonSecretsEditor.EncryptValueInFileAsync( - jsonFile.FullName, - propertyPath, - jsonValue, - certificate, - kid, - allowCreate); - } - else - { - // Encrypt existing value at the path - wasCreated = await JsonSecretsEditor.EncryptExistingValueInFileAsync( - jsonFile.FullName, - propertyPath, - certificate, - kid); - } - - var action = wasCreated ? "created file and encrypted value at" : "encrypted value at"; - Console.WriteLine($"✓ Successfully {action} '{propertyPath}' in {jsonFile.Name}"); - Console.WriteLine($" Key ID (kid): {kid}"); - Console.WriteLine($" Wrapping algorithm: RSA-OAEP-256"); - } - - private static string ReadPassword() - { - var password = new StringBuilder(); - ConsoleKeyInfo key; - - do - { - key = Console.ReadKey(intercept: true); - - if (key.Key == ConsoleKey.Backspace && password.Length > 0) - { - password.Remove(password.Length - 1, 1); - Console.Write("\b \b"); - } - else if (!char.IsControl(key.KeyChar)) - { - password.Append(key.KeyChar); - Console.Write("*"); - } - } while (key.Key != ConsoleKey.Enter); - - var result = password.ToString(); - - // Zero the StringBuilder buffer so the password doesn't linger in heap memory - for (var i = 0; i < password.Length; i++) - password[i] = '\0'; - password.Clear(); - - return result; - } -} +using System.CommandLine; +using System.Text; +using Cocoar.Configuration.X509Encryption; + +namespace Cocoar.Configuration.Secrets.Cli.Commands; + +internal static class EncryptCommand +{ + public static Command Create() + { + var command = new Command("encrypt", "Encrypt a value and set it at a property path in a JSON file"); + + var fileOption = new Option("--file") + { + Description = "Path to the JSON configuration file", + Required = true + }; + fileOption.Aliases.Add("-f"); + + var pathOption = new Option("--path") + { + Description = "Property path where to set the encrypted value (e.g. 'Database:ConnectionString' or 'ApiKeys:Stripe')", + Required = true + }; + pathOption.Aliases.Add("-p"); + + var valueOption = new Option("--value") + { + Description = "The plaintext value to encrypt. If omitted, encrypts the existing value at the specified path. " + + "For enum values, pass the name (e.g. 'Active') rather than the number — names survive enum reordering.", + Required = false + }; + valueOption.Aliases.Add("-v"); + + var certOption = new Option("--cert") + { + Description = "Path to the PFX certificate file for encryption", + Required = true + }; + certOption.Aliases.Add("-c"); + + var passwordOption = new Option("--password") + { + Description = "Password for the PFX certificate (will prompt if not provided)" + }; + passwordOption.Aliases.Add("-pwd"); + + var kidOption = new Option("--kid") + { + Description = "Key identifier (kid) for the certificate", + DefaultValueFactory = _ => "default" + }; + + var createOption = new Option("--create") + { + Description = "Create the JSON file if it doesn't exist (prevents accidental file creation from typos)", + DefaultValueFactory = _ => false + }; + + command.Options.Add(fileOption); + command.Options.Add(pathOption); + command.Options.Add(valueOption); + command.Options.Add(certOption); + command.Options.Add(passwordOption); + command.Options.Add(kidOption); + command.Options.Add(createOption); + + command.SetAction(parseResult => + { + try + { + var file = parseResult.GetValue(fileOption); + var path = parseResult.GetValue(pathOption); + var value = parseResult.GetValue(valueOption); + var cert = parseResult.GetValue(certOption); + var password = parseResult.GetValue(passwordOption); + var kid = parseResult.GetValue(kidOption); + var create = parseResult.GetValue(createOption); + // fileOption, pathOption, certOption have Required = true; kidOption has DefaultValueFactory + EncryptValueAsync(file!, path!, value, cert!, password, kid!, create).GetAwaiter().GetResult(); + return 0; + } + catch (ArgumentException ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return 1; + } + catch (FileNotFoundException ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return 2; + } + catch (IOException ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return 2; + } + catch (System.Security.Cryptography.CryptographicException ex) + { + Console.Error.WriteLine($"❌ Error: Encryption failed. Check certificate and file format."); + Console.Error.WriteLine($" Details: {ex.Message}"); + return 3; + } + catch (Exception ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return 4; + } + }); + + return command; + } + + private static async Task EncryptValueAsync( + FileInfo jsonFile, + string propertyPath, + string? plaintext, + FileInfo certFile, + string? password, + string kid, + bool allowCreate) + { + // Validate JSON file exists or creation is allowed + if (!jsonFile.Exists && !allowCreate) + throw new FileNotFoundException($"JSON file not found: {jsonFile.FullName}. Use --create to create a new file."); + + // Validate certificate file exists + if (!certFile.Exists) + throw new FileNotFoundException($"Certificate file not found: {certFile.FullName}"); + + // Prompt for password if not provided + if (string.IsNullOrEmpty(password)) + { + Console.Write("Enter certificate password: "); + password = ReadPassword(); + Console.WriteLine(); + } + + // Load certificate + var certificate = X509HybridCrypto.LoadCertificate(certFile.FullName, password); + + bool wasCreated; + + if (plaintext is not null) + { + // Encrypt the provided value + // Try to parse as JSON first (handles numbers, booleans, null, objects, arrays) + // If parsing fails, treat as a string and serialize it + string jsonValue; + try + { + // Attempt to parse as JSON - this validates JSON syntax + using var doc = System.Text.Json.JsonDocument.Parse(plaintext); + // If successful, use the original value as-is (it's valid JSON) + jsonValue = plaintext; + } + catch (System.Text.Json.JsonException) + { + // Not valid JSON, treat as a string and serialize it with quotes + jsonValue = System.Text.Json.JsonSerializer.Serialize(plaintext); + } + + wasCreated = await JsonSecretsEditor.EncryptValueInFileAsync( + jsonFile.FullName, + propertyPath, + jsonValue, + certificate, + kid, + allowCreate); + } + else + { + // Encrypt existing value at the path + wasCreated = await JsonSecretsEditor.EncryptExistingValueInFileAsync( + jsonFile.FullName, + propertyPath, + certificate, + kid); + } + + var action = wasCreated ? "created file and encrypted value at" : "encrypted value at"; + Console.WriteLine($"✓ Successfully {action} '{propertyPath}' in {jsonFile.Name}"); + Console.WriteLine($" Key ID (kid): {kid}"); + Console.WriteLine($" Wrapping algorithm: RSA-OAEP-256"); + } + + private static string ReadPassword() + { + var password = new StringBuilder(); + ConsoleKeyInfo key; + + do + { + key = Console.ReadKey(intercept: true); + + if (key.Key == ConsoleKey.Backspace && password.Length > 0) + { + password.Remove(password.Length - 1, 1); + Console.Write("\b \b"); + } + else if (!char.IsControl(key.KeyChar)) + { + password.Append(key.KeyChar); + Console.Write("*"); + } + } while (key.Key != ConsoleKey.Enter); + + var result = password.ToString(); + + // Zero the StringBuilder buffer so the password doesn't linger in heap memory + for (var i = 0; i < password.Length; i++) + password[i] = '\0'; + password.Clear(); + + return result; + } +} diff --git a/src/Cocoar.Configuration.Secrets.Cli/Commands/GenerateCertCommand.cs b/src/Cocoar.Configuration.Secrets.Cli/Commands/GenerateCertCommand.cs index 6ed5d74..628a68a 100644 --- a/src/Cocoar.Configuration.Secrets.Cli/Commands/GenerateCertCommand.cs +++ b/src/Cocoar.Configuration.Secrets.Cli/Commands/GenerateCertCommand.cs @@ -1,181 +1,181 @@ -using System.CommandLine; -using Cocoar.Configuration.X509Encryption; - -namespace Cocoar.Configuration.Secrets.Cli.Commands; - -internal static class GenerateCertCommand -{ - public static Command Create() - { - var command = new Command("generate-cert", "Generate a self-signed certificate for encryption"); - - var outputOption = new Option("--output") - { - Description = "Output path for certificate file(s)", - Required = true - }; - outputOption.Aliases.Add("-o"); - - var passwordOption = new Option("--password") - { - Description = "Password for PFX file (optional; recommended to omit for password-less certificates protected by file permissions)" - }; - passwordOption.Aliases.Add("-pwd"); - - var formatOption = new Option("--format") - { - Description = "Output format: pfx, pem, or auto (infer from file extension, default)", - DefaultValueFactory = _ => "auto" - }; - formatOption.Aliases.Add("-fmt"); - - var subjectOption = new Option("--subject") - { - Description = "Certificate subject", - DefaultValueFactory = _ => "CN=Cocoar Secrets" - }; - subjectOption.Aliases.Add("-s"); - - var validYearsOption = new Option("--valid-years") - { - Description = "Validity period in years", - DefaultValueFactory = _ => 1 - }; - - var keySizeOption = new Option("--key-size") - { - Description = "RSA key size (2048, 3072, or 4096)", - DefaultValueFactory = _ => 2048 - }; - - var overwriteOption = new Option("--overwrite") - { - Description = "Overwrite existing file without prompt", - DefaultValueFactory = _ => false - }; - - command.Options.Add(outputOption); - command.Options.Add(passwordOption); - command.Options.Add(formatOption); - command.Options.Add(subjectOption); - command.Options.Add(validYearsOption); - command.Options.Add(keySizeOption); - command.Options.Add(overwriteOption); - - command.SetAction(parseResult => - { - var output = parseResult.GetValue(outputOption); - var password = parseResult.GetValue(passwordOption); - var format = parseResult.GetValue(formatOption); - var subject = parseResult.GetValue(subjectOption); - var validYears = parseResult.GetValue(validYearsOption); - var keySize = parseResult.GetValue(keySizeOption); - var overwrite = parseResult.GetValue(overwriteOption); - // outputOption has Required = true; formatOption, subjectOption have DefaultValueFactory - return ExecuteAsync(output!, password, format!, subject!, validYears, keySize, overwrite).GetAwaiter().GetResult(); - }); - - return command; - } - - private static Task ExecuteAsync( - string output, - string? password, - string format, - string subject, - int validYears, - int keySize, - bool overwrite) - { - try - { - var certFormat = format.ToLowerInvariant() switch - { - "pfx" => CertificateFormat.Pfx, - "pem" => CertificateFormat.Pem, - "auto" => DetectFormat(output), - _ => throw new ArgumentException($"Invalid format '{format}'. Use 'pfx', 'pem', or 'auto'.") - }; - - if (!string.Equals(format, "auto", StringComparison.OrdinalIgnoreCase)) - { - var inferredFormat = DetectFormat(output); - if (certFormat != inferredFormat) - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"⚠️ Warning: Output file extension suggests {inferredFormat} but --format specifies {certFormat}"); - Console.ResetColor(); - } - } - - // Password-less by default for security - using var cert = certFormat == CertificateFormat.Pfx - ? X509CertificateGenerator.GenerateAndSavePfx(output, password, subject, validYears, keySize, overwrite) - : X509CertificateGenerator.GenerateAndSavePem(output, null, subject, validYears, keySize, overwrite); - - if (certFormat == CertificateFormat.Pfx) - { - Console.WriteLine($"✓ Certificate generated (PFX): {output}"); - if (string.IsNullOrWhiteSpace(password)) - { - Console.WriteLine(" ⚠️ Password-less certificate - protect with file permissions!"); - if (OperatingSystem.IsWindows()) - Console.WriteLine(" Windows: icacls cert.pfx /inheritance:r /grant:r \"YourUser:(R)\""); - else - Console.WriteLine(" Linux/macOS: chmod 600 cert.pfx && chown app-user cert.pfx"); - } - } - else - { - var keyPath = Path.ChangeExtension(output, ".key"); - Console.WriteLine($"✓ Certificate generated (PEM):"); - Console.WriteLine($" Certificate: {output}"); - Console.WriteLine($" Private Key: {keyPath}"); - Console.WriteLine(" ⚠️ Protect private key with file permissions!"); - if (OperatingSystem.IsWindows()) - Console.WriteLine(" Windows: icacls {keyPath} /inheritance:r /grant:r \"YourUser:(R)\""); - else - Console.WriteLine(" Linux/macOS: chmod 600 {keyPath} && chown app-user {keyPath}"); - } - - Console.WriteLine($" Subject: {cert.Subject}"); - Console.WriteLine($" Valid: {cert.NotBefore:yyyy-MM-dd} to {cert.NotAfter:yyyy-MM-dd}"); - Console.WriteLine($" Key Size: {keySize} bits"); - Console.WriteLine($" Thumbprint: {cert.Thumbprint}"); - - return Task.FromResult(0); - } - catch (ArgumentException ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return Task.FromResult(1); - } - catch (IOException ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return Task.FromResult(2); - } - catch (System.Security.Cryptography.CryptographicException ex) - { - Console.Error.WriteLine($"❌ Error: Certificate generation failed."); - Console.Error.WriteLine($" Details: {ex.Message}"); - return Task.FromResult(3); - } - catch (Exception ex) - { - Console.Error.WriteLine($"❌ Error: {ex.Message}"); - return Task.FromResult(4); - } - } - - private static CertificateFormat DetectFormat(string path) - { - var ext = Path.GetExtension(path).ToLowerInvariant(); - return ext switch - { - ".pfx" or ".p12" => CertificateFormat.Pfx, - ".pem" or ".crt" or ".cer" => CertificateFormat.Pem, - _ => CertificateFormat.Pfx // Default to PFX - }; - } -} +using System.CommandLine; +using Cocoar.Configuration.X509Encryption; + +namespace Cocoar.Configuration.Secrets.Cli.Commands; + +internal static class GenerateCertCommand +{ + public static Command Create() + { + var command = new Command("generate-cert", "Generate a self-signed certificate for encryption"); + + var outputOption = new Option("--output") + { + Description = "Output path for certificate file(s)", + Required = true + }; + outputOption.Aliases.Add("-o"); + + var passwordOption = new Option("--password") + { + Description = "Password for PFX file (optional; recommended to omit for password-less certificates protected by file permissions)" + }; + passwordOption.Aliases.Add("-pwd"); + + var formatOption = new Option("--format") + { + Description = "Output format: pfx, pem, or auto (infer from file extension, default)", + DefaultValueFactory = _ => "auto" + }; + formatOption.Aliases.Add("-fmt"); + + var subjectOption = new Option("--subject") + { + Description = "Certificate subject", + DefaultValueFactory = _ => "CN=Cocoar Secrets" + }; + subjectOption.Aliases.Add("-s"); + + var validYearsOption = new Option("--valid-years") + { + Description = "Validity period in years", + DefaultValueFactory = _ => 1 + }; + + var keySizeOption = new Option("--key-size") + { + Description = "RSA key size (2048, 3072, or 4096)", + DefaultValueFactory = _ => 2048 + }; + + var overwriteOption = new Option("--overwrite") + { + Description = "Overwrite existing file without prompt", + DefaultValueFactory = _ => false + }; + + command.Options.Add(outputOption); + command.Options.Add(passwordOption); + command.Options.Add(formatOption); + command.Options.Add(subjectOption); + command.Options.Add(validYearsOption); + command.Options.Add(keySizeOption); + command.Options.Add(overwriteOption); + + command.SetAction(parseResult => + { + var output = parseResult.GetValue(outputOption); + var password = parseResult.GetValue(passwordOption); + var format = parseResult.GetValue(formatOption); + var subject = parseResult.GetValue(subjectOption); + var validYears = parseResult.GetValue(validYearsOption); + var keySize = parseResult.GetValue(keySizeOption); + var overwrite = parseResult.GetValue(overwriteOption); + // outputOption has Required = true; formatOption, subjectOption have DefaultValueFactory + return ExecuteAsync(output!, password, format!, subject!, validYears, keySize, overwrite).GetAwaiter().GetResult(); + }); + + return command; + } + + private static Task ExecuteAsync( + string output, + string? password, + string format, + string subject, + int validYears, + int keySize, + bool overwrite) + { + try + { + var certFormat = format.ToLowerInvariant() switch + { + "pfx" => CertificateFormat.Pfx, + "pem" => CertificateFormat.Pem, + "auto" => DetectFormat(output), + _ => throw new ArgumentException($"Invalid format '{format}'. Use 'pfx', 'pem', or 'auto'.") + }; + + if (!string.Equals(format, "auto", StringComparison.OrdinalIgnoreCase)) + { + var inferredFormat = DetectFormat(output); + if (certFormat != inferredFormat) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"⚠️ Warning: Output file extension suggests {inferredFormat} but --format specifies {certFormat}"); + Console.ResetColor(); + } + } + + // Password-less by default for security + using var cert = certFormat == CertificateFormat.Pfx + ? X509CertificateGenerator.GenerateAndSavePfx(output, password, subject, validYears, keySize, overwrite) + : X509CertificateGenerator.GenerateAndSavePem(output, null, subject, validYears, keySize, overwrite); + + if (certFormat == CertificateFormat.Pfx) + { + Console.WriteLine($"✓ Certificate generated (PFX): {output}"); + if (string.IsNullOrWhiteSpace(password)) + { + Console.WriteLine(" ⚠️ Password-less certificate - protect with file permissions!"); + if (OperatingSystem.IsWindows()) + Console.WriteLine(" Windows: icacls cert.pfx /inheritance:r /grant:r \"YourUser:(R)\""); + else + Console.WriteLine(" Linux/macOS: chmod 600 cert.pfx && chown app-user cert.pfx"); + } + } + else + { + var keyPath = Path.ChangeExtension(output, ".key"); + Console.WriteLine($"✓ Certificate generated (PEM):"); + Console.WriteLine($" Certificate: {output}"); + Console.WriteLine($" Private Key: {keyPath}"); + Console.WriteLine(" ⚠️ Protect private key with file permissions!"); + if (OperatingSystem.IsWindows()) + Console.WriteLine(" Windows: icacls {keyPath} /inheritance:r /grant:r \"YourUser:(R)\""); + else + Console.WriteLine(" Linux/macOS: chmod 600 {keyPath} && chown app-user {keyPath}"); + } + + Console.WriteLine($" Subject: {cert.Subject}"); + Console.WriteLine($" Valid: {cert.NotBefore:yyyy-MM-dd} to {cert.NotAfter:yyyy-MM-dd}"); + Console.WriteLine($" Key Size: {keySize} bits"); + Console.WriteLine($" Thumbprint: {cert.Thumbprint}"); + + return Task.FromResult(0); + } + catch (ArgumentException ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return Task.FromResult(1); + } + catch (IOException ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return Task.FromResult(2); + } + catch (System.Security.Cryptography.CryptographicException ex) + { + Console.Error.WriteLine($"❌ Error: Certificate generation failed."); + Console.Error.WriteLine($" Details: {ex.Message}"); + return Task.FromResult(3); + } + catch (Exception ex) + { + Console.Error.WriteLine($"❌ Error: {ex.Message}"); + return Task.FromResult(4); + } + } + + private static CertificateFormat DetectFormat(string path) + { + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext switch + { + ".pfx" or ".p12" => CertificateFormat.Pfx, + ".pem" or ".crt" or ".cer" => CertificateFormat.Pem, + _ => CertificateFormat.Pfx // Default to PFX + }; + } +} diff --git a/src/Cocoar.Configuration.Secrets.Cli/Program.cs b/src/Cocoar.Configuration.Secrets.Cli/Program.cs index dba6285..f88623a 100644 --- a/src/Cocoar.Configuration.Secrets.Cli/Program.cs +++ b/src/Cocoar.Configuration.Secrets.Cli/Program.cs @@ -1,21 +1,21 @@ -using System.CommandLine; -using Cocoar.Configuration.Secrets.Cli.Commands; - -namespace Cocoar.Configuration.Secrets.Cli; - -internal sealed class Program -{ - static int Main(string[] args) - { - var rootCommand = new RootCommand("Cocoar.Configuration.Secrets CLI - Encrypt secrets in JSON configuration files") - { - GenerateCertCommand.Create(), - ConvertCertCommand.Create(), - EncryptCommand.Create(), - DecryptCommand.Create(), - CertInfoCommand.Create() - }; - - return rootCommand.Parse(args).Invoke(); - } -} +using System.CommandLine; +using Cocoar.Configuration.Secrets.Cli.Commands; + +namespace Cocoar.Configuration.Secrets.Cli; + +internal sealed class Program +{ + static int Main(string[] args) + { + var rootCommand = new RootCommand("Cocoar.Configuration.Secrets CLI - Encrypt secrets in JSON configuration files") + { + GenerateCertCommand.Create(), + ConvertCertCommand.Create(), + EncryptCommand.Create(), + DecryptCommand.Create(), + CertInfoCommand.Create() + }; + + return rootCommand.Parse(args).Invoke(); + } +} diff --git a/src/Cocoar.Configuration.Secrets.Cli/README.md b/src/Cocoar.Configuration.Secrets.Cli/README.md index e15a7e3..1045fa3 100644 --- a/src/Cocoar.Configuration.Secrets.Cli/README.md +++ b/src/Cocoar.Configuration.Secrets.Cli/README.md @@ -1,199 +1,199 @@ -# Cocoar.Configuration.Secrets CLI - -Command-line tool for managing encrypted secrets in JSON configuration files using X.509 certificate-based hybrid encryption (RSA-OAEP + AES-256-GCM). - -## Why This Tool Exists - -The Secrets system expects **pre-encrypted envelopes** in configuration files. This CLI tool: -- **Generates** self-signed certificates for development and testing -- **Encrypts** secrets in JSON files before deployment -- **Decrypts** secrets for verification and troubleshooting -- **Converts** certificate formats and manages certificate operations - -**At runtime**, the Cocoar.Configuration.Secrets library automatically decrypts these pre-encrypted secrets on-demand. - -## Installation - -```bash -# Install globally -dotnet tool install --global Cocoar.Configuration.Secrets.Cli - -# Update -dotnet tool update --global Cocoar.Configuration.Secrets.Cli - -# Uninstall -dotnet tool uninstall --global Cocoar.Configuration.Secrets.Cli -``` - -**Requirements:** .NET 9.0 SDK or runtime - -## Quick Start - -```bash -# Generate a password-less certificate (industry standard) -cocoar-secrets generate-cert -o secrets.pfx - -# Encrypt a secret in a JSON file -cocoar-secrets encrypt -f appsettings.json -p Database:ConnectionString -v "Server=prod;Password=secret" -c secrets.pfx - -# View decrypted value (doesn't modify file) -cocoar-secrets decrypt -f appsettings.json -p Database:ConnectionString -c secrets.pfx - -# Get certificate information -cocoar-secrets cert-info -i secrets.pfx -``` - ---- - -## Available Commands - -Use `cocoar-secrets --help` for detailed options. - -### Certificate Management - -- **`generate-cert`** - Generate password-less self-signed certificates -- **`convert-cert`** - Convert formats (PFX ↔ PEM) and manage passwords -- **`cert-info`** - Display certificate details and status - -### Secret Operations - -- **`encrypt`** - Encrypt values in JSON files -- **`decrypt`** - Decrypt and view values (or replace with `--replace`) - ---- - -## Key Concepts - -### Password-less Certificates (Recommended) - -**Industry standard approach** used by nginx, PostgreSQL, Kubernetes, Docker: -- Generate: `cocoar-secrets generate-cert -o cert.pfx` (no password) -- Protect via **file permissions**: `chmod 600 cert.pfx` (Linux/macOS), NTFS permissions (Windows) -- Enable **full-disk encryption**: BitLocker (Windows), LUKS (Linux), FileVault (macOS) - -**Why password-less?** -- ✅ Simpler operations (no password management infrastructure) -- ✅ No bootstrapping problem (passwords are secrets too—where would you store them?) -- ✅ Same security level when combined with file permissions + disk encryption - -**Legacy systems:** Use `convert-cert` to add passwords if required by legacy infrastructure. - -### Certificate Formats - -- **PFX (.pfx, .p12)** - PKCS#12 format, contains certificate + private key in single file -- **PEM (.crt, .cer, .pem + .key)** - Separate certificate and private key files - -The CLI auto-detects format from file extension or use `--format` to override. - -### Encrypted Envelope Format - -Secrets are stored as `__cocoar_secret__` envelopes in JSON: - -```json -{ - "Database": { - "ConnectionString": { - "__cocoar_secret__": "v1", - "kid": "default", - "alg": "RSA-OAEP-AES256-GCM", - "type": "utf8", - "wk": "base64_wrapped_key...", - "walg": "RSA-OAEP-256", - "iv": "base64_iv...", - "ct": "base64_ciphertext...", - "tag": "base64_tag..." - } - } -} -``` - -**At runtime**, the Cocoar.Configuration.Secrets library recognizes these envelopes and decrypts on-demand when you call `Secret.Open()`. - - ---- - -## Security Best Practices - -### Certificate Protection - -✅ **DO:** -- Use password-less certificates (industry standard) -- Set file permissions: `chmod 600 *.pfx` (Linux/macOS) -- Enable full-disk encryption (BitLocker/LUKS/FileVault) -- Grant access only to application service account -- Store certificates in Key Vault or Secrets Manager -- Use different certificates per environment (dev, staging, production) -- Add `*.pfx`, `*.pem`, `*.key` to `.gitignore` - -❌ **DON'T:** -- Commit certificates to source control -- Use password-protected certificates unless required by legacy systems -- Share certificates across environments -- Grant unnecessary file permissions - -### Operations - -✅ **DO:** -- Keep encrypted config files in source control -- Use `--create` flag to prevent typos -- Test decrypt operations in non-production first -- Back up certificates before rotation - -❌ **DON'T:** -- Store plaintext secrets in source control -- Run `decrypt --replace` without backing up first -- Display decrypted values in CI/CD logs - - ---- - -## Troubleshooting - -**Certificate not found:** -``` -Error: Certificate file not found: mycert.pfx -``` -→ Verify file path is correct - -**Certificate appears password-protected:** -``` -⚠️ Certificate appears to be password-protected. -``` -→ Provide password with `-pwd` or use `convert-cert` to remove password - -**Property path not found:** -``` -Error: Property path 'Settings:ApiKey' not found in JSON -``` -→ For `encrypt`: verify parent object exists or use `--create` -→ For `decrypt`: verify path matches encrypted value location - -**Decryption fails:** -``` -Error: Failed to decrypt: MAC validation failed -``` -→ Verify using correct certificate (must match encryption cert) -→ Check encrypted envelope hasn't been manually modified - -Run any command with `--help` for detailed usage information. - ---- - -## Related Documentation - -- [Intelligent Certificate Caching](../Cocoar.Configuration.Secrets/intelligent-certificate-caching.md) - Advanced certificate management -- [Cocoar.Configuration.Secrets](../Cocoar.Configuration.Secrets/README.md) - Runtime library documentation -- [Examples](../../Examples/) - Working code examples - ---- - -## Support - -- **Issues**: [GitHub Issues](https://github.com/cocoar-dev/Cocoar.Configuration/issues) -- **Discussions**: [GitHub Discussions](https://github.com/cocoar-dev/Cocoar.Configuration/discussions) -- **Contributing**: [CONTRIBUTING.md](../../CONTRIBUTING.md) -- **Security**: [SECURITY.md](../../SECURITY.md) - ---- - -**License:** Apache 2.0 - See [LICENSE](../../LICENSE) +# Cocoar.Configuration.Secrets CLI + +Command-line tool for managing encrypted secrets in JSON configuration files using X.509 certificate-based hybrid encryption (RSA-OAEP + AES-256-GCM). + +## Why This Tool Exists + +The Secrets system expects **pre-encrypted envelopes** in configuration files. This CLI tool: +- **Generates** self-signed certificates for development and testing +- **Encrypts** secrets in JSON files before deployment +- **Decrypts** secrets for verification and troubleshooting +- **Converts** certificate formats and manages certificate operations + +**At runtime**, the Cocoar.Configuration.Secrets library automatically decrypts these pre-encrypted secrets on-demand. + +## Installation + +```bash +# Install globally +dotnet tool install --global Cocoar.Configuration.Secrets.Cli + +# Update +dotnet tool update --global Cocoar.Configuration.Secrets.Cli + +# Uninstall +dotnet tool uninstall --global Cocoar.Configuration.Secrets.Cli +``` + +**Requirements:** .NET 9.0 SDK or runtime + +## Quick Start + +```bash +# Generate a password-less certificate (industry standard) +cocoar-secrets generate-cert -o secrets.pfx + +# Encrypt a secret in a JSON file +cocoar-secrets encrypt -f appsettings.json -p Database:ConnectionString -v "Server=prod;Password=secret" -c secrets.pfx + +# View decrypted value (doesn't modify file) +cocoar-secrets decrypt -f appsettings.json -p Database:ConnectionString -c secrets.pfx + +# Get certificate information +cocoar-secrets cert-info -i secrets.pfx +``` + +--- + +## Available Commands + +Use `cocoar-secrets --help` for detailed options. + +### Certificate Management + +- **`generate-cert`** - Generate password-less self-signed certificates +- **`convert-cert`** - Convert formats (PFX ↔ PEM) and manage passwords +- **`cert-info`** - Display certificate details and status + +### Secret Operations + +- **`encrypt`** - Encrypt values in JSON files +- **`decrypt`** - Decrypt and view values (or replace with `--replace`) + +--- + +## Key Concepts + +### Password-less Certificates (Recommended) + +**Industry standard approach** used by nginx, PostgreSQL, Kubernetes, Docker: +- Generate: `cocoar-secrets generate-cert -o cert.pfx` (no password) +- Protect via **file permissions**: `chmod 600 cert.pfx` (Linux/macOS), NTFS permissions (Windows) +- Enable **full-disk encryption**: BitLocker (Windows), LUKS (Linux), FileVault (macOS) + +**Why password-less?** +- ✅ Simpler operations (no password management infrastructure) +- ✅ No bootstrapping problem (passwords are secrets too—where would you store them?) +- ✅ Same security level when combined with file permissions + disk encryption + +**Legacy systems:** Use `convert-cert` to add passwords if required by legacy infrastructure. + +### Certificate Formats + +- **PFX (.pfx, .p12)** - PKCS#12 format, contains certificate + private key in single file +- **PEM (.crt, .cer, .pem + .key)** - Separate certificate and private key files + +The CLI auto-detects format from file extension or use `--format` to override. + +### Encrypted Envelope Format + +Secrets are stored as `__cocoar_secret__` envelopes in JSON: + +```json +{ + "Database": { + "ConnectionString": { + "__cocoar_secret__": "v1", + "kid": "default", + "alg": "RSA-OAEP-AES256-GCM", + "type": "utf8", + "wk": "base64_wrapped_key...", + "walg": "RSA-OAEP-256", + "iv": "base64_iv...", + "ct": "base64_ciphertext...", + "tag": "base64_tag..." + } + } +} +``` + +**At runtime**, the Cocoar.Configuration.Secrets library recognizes these envelopes and decrypts on-demand when you call `Secret.Open()`. + + +--- + +## Security Best Practices + +### Certificate Protection + +✅ **DO:** +- Use password-less certificates (industry standard) +- Set file permissions: `chmod 600 *.pfx` (Linux/macOS) +- Enable full-disk encryption (BitLocker/LUKS/FileVault) +- Grant access only to application service account +- Store certificates in Key Vault or Secrets Manager +- Use different certificates per environment (dev, staging, production) +- Add `*.pfx`, `*.pem`, `*.key` to `.gitignore` + +❌ **DON'T:** +- Commit certificates to source control +- Use password-protected certificates unless required by legacy systems +- Share certificates across environments +- Grant unnecessary file permissions + +### Operations + +✅ **DO:** +- Keep encrypted config files in source control +- Use `--create` flag to prevent typos +- Test decrypt operations in non-production first +- Back up certificates before rotation + +❌ **DON'T:** +- Store plaintext secrets in source control +- Run `decrypt --replace` without backing up first +- Display decrypted values in CI/CD logs + + +--- + +## Troubleshooting + +**Certificate not found:** +``` +Error: Certificate file not found: mycert.pfx +``` +→ Verify file path is correct + +**Certificate appears password-protected:** +``` +⚠️ Certificate appears to be password-protected. +``` +→ Provide password with `-pwd` or use `convert-cert` to remove password + +**Property path not found:** +``` +Error: Property path 'Settings:ApiKey' not found in JSON +``` +→ For `encrypt`: verify parent object exists or use `--create` +→ For `decrypt`: verify path matches encrypted value location + +**Decryption fails:** +``` +Error: Failed to decrypt: MAC validation failed +``` +→ Verify using correct certificate (must match encryption cert) +→ Check encrypted envelope hasn't been manually modified + +Run any command with `--help` for detailed usage information. + +--- + +## Related Documentation + +- [Intelligent Certificate Caching](../Cocoar.Configuration.Secrets/intelligent-certificate-caching.md) - Advanced certificate management +- [Cocoar.Configuration.Secrets](../Cocoar.Configuration.Secrets/README.md) - Runtime library documentation +- [Examples](../../Examples/) - Working code examples + +--- + +## Support + +- **Issues**: [GitHub Issues](https://github.com/cocoar-dev/Cocoar.Configuration/issues) +- **Discussions**: [GitHub Discussions](https://github.com/cocoar-dev/Cocoar.Configuration/discussions) +- **Contributing**: [CONTRIBUTING.md](../../CONTRIBUTING.md) +- **Security**: [SECURITY.md](../../SECURITY.md) + +--- + +**License:** Apache 2.0 - See [LICENSE](../../LICENSE) diff --git a/src/Cocoar.Configuration.X509Encryption/Cocoar.Configuration.X509Encryption.csproj b/src/Cocoar.Configuration.X509Encryption/Cocoar.Configuration.X509Encryption.csproj index c382187..26f7fe7 100644 --- a/src/Cocoar.Configuration.X509Encryption/Cocoar.Configuration.X509Encryption.csproj +++ b/src/Cocoar.Configuration.X509Encryption/Cocoar.Configuration.X509Encryption.csproj @@ -1,15 +1,15 @@ - - - - net9.0 - enable - enable - - - - false - - - - - + + + + net9.0 + enable + enable + + + + false + + + + + diff --git a/src/Cocoar.Configuration.X509Encryption/HybridSecretEnvelope.cs b/src/Cocoar.Configuration.X509Encryption/HybridSecretEnvelope.cs index 8f349d0..6dcce85 100644 --- a/src/Cocoar.Configuration.X509Encryption/HybridSecretEnvelope.cs +++ b/src/Cocoar.Configuration.X509Encryption/HybridSecretEnvelope.cs @@ -1,41 +1,41 @@ -using System.Text.Json.Serialization; - -namespace Cocoar.Configuration.X509Encryption; - -/// -/// Hybrid RSA+AES encrypted envelope. -/// Contains encrypted data and the AES key wrapped with RSA. -/// Uses short property names to match Cocoar.Configuration.Secrets format. -/// -public sealed record HybridSecretEnvelope -{ - /// - /// The AES-256 key, encrypted with RSA-OAEP-SHA256 (base64-encoded). - /// - [JsonPropertyName("wk")] - public required string WrappedKey { get; init; } - - /// - /// Algorithm used to wrap the key (always "RSA-OAEP-256"). - /// - [JsonPropertyName("walg")] - public required string WrappingAlgorithm { get; init; } - - /// - /// AES-GCM initialization vector (12 bytes, base64-encoded). - /// - [JsonPropertyName("iv")] - public required string Iv { get; init; } - - /// - /// AES-GCM encrypted ciphertext (base64-encoded). - /// - [JsonPropertyName("ct")] - public required string Ciphertext { get; init; } - - /// - /// AES-GCM authentication tag (16 bytes, base64-encoded). - /// - [JsonPropertyName("tag")] - public required string Tag { get; init; } -} +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.X509Encryption; + +/// +/// Hybrid RSA+AES encrypted envelope. +/// Contains encrypted data and the AES key wrapped with RSA. +/// Uses short property names to match Cocoar.Configuration.Secrets format. +/// +public sealed record HybridSecretEnvelope +{ + /// + /// The AES-256 key, encrypted with RSA-OAEP-SHA256 (base64-encoded). + /// + [JsonPropertyName("wk")] + public required string WrappedKey { get; init; } + + /// + /// Algorithm used to wrap the key (always "RSA-OAEP-256"). + /// + [JsonPropertyName("walg")] + public required string WrappingAlgorithm { get; init; } + + /// + /// AES-GCM initialization vector (12 bytes, base64-encoded). + /// + [JsonPropertyName("iv")] + public required string Iv { get; init; } + + /// + /// AES-GCM encrypted ciphertext (base64-encoded). + /// + [JsonPropertyName("ct")] + public required string Ciphertext { get; init; } + + /// + /// AES-GCM authentication tag (16 bytes, base64-encoded). + /// + [JsonPropertyName("tag")] + public required string Tag { get; init; } +} diff --git a/src/Cocoar.Configuration.X509Encryption/JsonSecretsEditor.cs b/src/Cocoar.Configuration.X509Encryption/JsonSecretsEditor.cs index 952e467..aa8aa69 100644 --- a/src/Cocoar.Configuration.X509Encryption/JsonSecretsEditor.cs +++ b/src/Cocoar.Configuration.X509Encryption/JsonSecretsEditor.cs @@ -1,350 +1,350 @@ -using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace Cocoar.Configuration.X509Encryption; - -/// -/// Provides methods for encrypting and decrypting secrets directly in JSON configuration files. -/// Useful for CLI tools, PowerShell modules, and test scenarios. -/// -public static class JsonSecretsEditor -{ - private static readonly JsonSerializerOptions IndentedJsonOptions = new() - { - WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - - /// - /// Encrypts a value and sets it at the specified property path in a JSON file. - /// - /// Path to the JSON configuration file - /// Property path using colon separator (e.g., "Database:ConnectionString") - /// The plaintext value to encrypt - /// X.509 certificate with public key for encryption - /// Key identifier (kid) for the certificate - /// If true, creates the file and directories if they don't exist - /// True if file was created, false if it already existed - /// If file doesn't exist and createIfNotExists is false - /// If JSON file doesn't contain a root object - public static async Task EncryptValueInFileAsync( - string jsonFilePath, - string propertyPath, - string plaintext, - X509Certificate2 certificate, - string kid = "default", - bool createIfNotExists = false) - { - ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); - ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); - ArgumentNullException.ThrowIfNull(plaintext); - ArgumentNullException.ThrowIfNull(certificate); - ArgumentException.ThrowIfNullOrWhiteSpace(kid); - - var fileExists = File.Exists(jsonFilePath); - - // Validate file exists or creation is allowed - if (!fileExists && !createIfNotExists) - throw new FileNotFoundException($"JSON file not found: {jsonFilePath}. Enable createIfNotExists to create a new file."); - - // Encrypt the value - var crypto = new X509HybridCrypto(certificate); - var envelope = crypto.Encrypt(plaintext); - - // Read or create JSON file - JsonObject rootObject; - if (fileExists) - { - var jsonText = await File.ReadAllTextAsync(jsonFilePath); - var jsonNode = JsonNode.Parse(jsonText); - - if (jsonNode is not JsonObject obj) - throw new InvalidOperationException("JSON file must contain a root object"); - - rootObject = obj; - } - else - { - // Create directory if it doesn't exist - var directory = Path.GetDirectoryName(jsonFilePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - // Start with an empty JSON object - rootObject = new JsonObject(); - } - - // Set the encrypted value at the property path - SetValueAtPath(rootObject, propertyPath, envelope, kid); - - // Write back to file with nice formatting - var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); - await File.WriteAllTextAsync(jsonFilePath, updatedJson, Encoding.UTF8); - - return !fileExists; // Return true if we created the file - } - - /// - /// Encrypts an existing plaintext value in-place at the specified property path in a JSON file. - /// Reads the current value, encrypts it, and replaces it with the encrypted envelope. - /// - /// Path to the JSON configuration file - /// Property path using colon separator (e.g., "Database:ConnectionString") - /// X.509 certificate with public key for encryption - /// Key identifier (kid) for the certificate - /// Always returns false (file must exist) - /// If file doesn't exist - /// If JSON file doesn't contain a root object or property path doesn't exist - public static async Task EncryptExistingValueInFileAsync( - string jsonFilePath, - string propertyPath, - X509Certificate2 certificate, - string kid = "default") - { - ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); - ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); - ArgumentNullException.ThrowIfNull(certificate); - ArgumentException.ThrowIfNullOrWhiteSpace(kid); - - if (!File.Exists(jsonFilePath)) - throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); - - // Read JSON file - var jsonText = await File.ReadAllTextAsync(jsonFilePath); - var jsonNode = JsonNode.Parse(jsonText); - - if (jsonNode is not JsonObject rootObject) - throw new InvalidOperationException("JSON file must contain a root object"); - - // Get the existing value at the path - var existingValue = GetValueAtPath(rootObject, propertyPath); - if (existingValue is null) - throw new InvalidOperationException($"No value found at path: {propertyPath}"); - - // Serialize the existing JSON value to a string (preserving its JSON representation) - var jsonValueString = JsonSerializer.Serialize(existingValue); - - // Encrypt the JSON string - var crypto = new X509HybridCrypto(certificate); - var envelope = crypto.Encrypt(jsonValueString); - - // Replace the value with the encrypted envelope - SetValueAtPath(rootObject, propertyPath, envelope, kid); - - // Write back to file - var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); - await File.WriteAllTextAsync(jsonFilePath, updatedJson, Encoding.UTF8); - - return false; // File already existed - } - - /// - /// Decrypts a value from the specified property path in a JSON file. - /// - /// Path to the JSON configuration file - /// Property path using colon separator (e.g., "Database:ConnectionString") - /// X.509 certificate with private key for decryption - /// The decrypted plaintext value - /// If file doesn't exist - /// If JSON file doesn't contain a root object or property path is invalid - public static async Task DecryptValueFromFileAsync( - string jsonFilePath, - string propertyPath, - X509Certificate2 certificate) - { - ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); - ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); - ArgumentNullException.ThrowIfNull(certificate); - - if (!File.Exists(jsonFilePath)) - throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); - - // Read JSON file - var jsonText = await File.ReadAllTextAsync(jsonFilePath); - var jsonNode = JsonNode.Parse(jsonText); - - if (jsonNode is not JsonObject rootObject) - throw new InvalidOperationException("JSON file must contain a root object"); - - // Get the encrypted envelope from the path - var envelope = GetEnvelopeAtPath(rootObject, propertyPath); - - // Decrypt the value - var crypto = new X509HybridCrypto(certificate); - return crypto.DecryptToString(envelope); - } - - /// - /// Rotates the certificate for an encrypted value by decrypting with old certificate and re-encrypting with new certificate. - /// - /// Path to the JSON configuration file - /// Property path using colon separator (e.g., "Database:ConnectionString") - /// X.509 certificate with private key for decryption - /// X.509 certificate with public key for encryption - /// Optional new key identifier (if null, keeps the existing kid) - /// If file doesn't exist - /// If JSON file doesn't contain a root object or property path is invalid - public static async Task RotateCertificateInFileAsync( - string jsonFilePath, - string propertyPath, - X509Certificate2 oldCertificate, - X509Certificate2 newCertificate, - string? newKid = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); - ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); - ArgumentNullException.ThrowIfNull(oldCertificate); - ArgumentNullException.ThrowIfNull(newCertificate); - - if (!File.Exists(jsonFilePath)) - throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); - - // Read JSON file - var jsonText = await File.ReadAllTextAsync(jsonFilePath); - var jsonNode = JsonNode.Parse(jsonText); - - if (jsonNode is not JsonObject rootObject) - throw new InvalidOperationException("JSON file must contain a root object"); - - // Get the encrypted envelope and kid from the path - var (envelope, existingKid) = GetEnvelopeWithKidAtPath(rootObject, propertyPath); - - // Decrypt with old certificate - var oldCrypto = new X509HybridCrypto(oldCertificate); - var plaintext = oldCrypto.DecryptToString(envelope); - - // Re-encrypt with new certificate - var newCrypto = new X509HybridCrypto(newCertificate); - var newEnvelope = newCrypto.Encrypt(plaintext); - - // Use new kid if provided, otherwise keep existing - var kidToUse = newKid ?? existingKid; - - // Update the JSON file with the new envelope - SetValueAtPath(rootObject, propertyPath, newEnvelope, kidToUse); - - // Write back to file - var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); - await File.WriteAllTextAsync(jsonFilePath, updatedJson, Encoding.UTF8); - } - - private static void SetValueAtPath(JsonObject root, string path, HybridSecretEnvelope envelope, string kid) - { - var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) - throw new ArgumentException("Property path cannot be empty", nameof(path)); - - JsonObject current = root; - - // Navigate to parent, creating objects as needed - for (int i = 0; i < segments.Length - 1; i++) - { - var segment = segments[i]; - - if (current[segment] is JsonObject existingObject) - { - current = existingObject; - } - else - { - // Create new object if it doesn't exist - var newObject = new JsonObject(); - current[segment] = newObject; - current = newObject; - } - } - - // Wrap the envelope in the Cocoar secret structure - var wrappedEnvelope = new JsonObject - { - ["type"] = "cocoar.secret", - ["version"] = 1, - ["kid"] = kid, - ["alg"] = "RSA-OAEP-AES256-GCM" - }; - - // Merge the envelope properties into the wrapped structure - var envelopeNode = JsonSerializer.SerializeToNode(envelope); - if (envelopeNode is JsonObject envelopeObject) - { - foreach (var prop in envelopeObject) - { - wrappedEnvelope[prop.Key] = prop.Value?.DeepClone(); - } - } - - // Set the final property to the wrapped envelope - var finalSegment = segments[^1]; - current[finalSegment] = wrappedEnvelope; - } - - private static HybridSecretEnvelope GetEnvelopeAtPath(JsonObject root, string path) - { - var (envelope, _) = GetEnvelopeWithKidAtPath(root, path); - return envelope; - } - - private static (HybridSecretEnvelope Envelope, string Kid) GetEnvelopeWithKidAtPath(JsonObject root, string path) - { - var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) - throw new ArgumentException("Property path cannot be empty", nameof(path)); - - JsonNode? current = root; - - // Navigate to the property - foreach (var segment in segments) - { - if (current is not JsonObject obj) - throw new InvalidOperationException($"Path segment '{segment}' does not exist or is not an object"); - - current = obj[segment]; - if (current == null) - throw new InvalidOperationException($"Property '{segment}' not found in path '{path}'"); - } - - // Expect a typed secret envelope - if (current is not JsonObject envelopeObj) - throw new InvalidOperationException($"Value at '{path}' is not an encrypted envelope object"); - - var type = envelopeObj["type"]?.GetValue(); - var version = envelopeObj["version"]?.GetValue(); - - if (!string.Equals(type, "cocoar.secret", StringComparison.OrdinalIgnoreCase) || version is null or not 1) - throw new InvalidOperationException($"Invalid or missing Cocoar secret envelope at '{path}'"); - - var kid = envelopeObj["kid"]?.GetValue() ?? "default"; - - // Deserialize the envelope - var envelope = JsonSerializer.Deserialize(envelopeObj.ToJsonString()) - ?? throw new InvalidOperationException($"Failed to deserialize envelope at '{path}'"); - - return (envelope, kid); - } - - private static JsonNode? GetValueAtPath(JsonObject root, string path) - { - var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) - throw new ArgumentException("Property path cannot be empty", nameof(path)); - - JsonNode? current = root; - - // Navigate to the property - foreach (var segment in segments) - { - if (current is not JsonObject obj) - throw new InvalidOperationException($"Path segment '{segment}' does not exist or is not an object"); - - current = obj[segment]; - if (current == null) - throw new InvalidOperationException($"Property '{segment}' not found in path '{path}'"); - } - - return current; - } -} +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Cocoar.Configuration.X509Encryption; + +/// +/// Provides methods for encrypting and decrypting secrets directly in JSON configuration files. +/// Useful for CLI tools, PowerShell modules, and test scenarios. +/// +public static class JsonSecretsEditor +{ + private static readonly JsonSerializerOptions IndentedJsonOptions = new() + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + /// + /// Encrypts a value and sets it at the specified property path in a JSON file. + /// + /// Path to the JSON configuration file + /// Property path using colon separator (e.g., "Database:ConnectionString") + /// The plaintext value to encrypt + /// X.509 certificate with public key for encryption + /// Key identifier (kid) for the certificate + /// If true, creates the file and directories if they don't exist + /// True if file was created, false if it already existed + /// If file doesn't exist and createIfNotExists is false + /// If JSON file doesn't contain a root object + public static async Task EncryptValueInFileAsync( + string jsonFilePath, + string propertyPath, + string plaintext, + X509Certificate2 certificate, + string kid = "default", + bool createIfNotExists = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); + ArgumentNullException.ThrowIfNull(plaintext); + ArgumentNullException.ThrowIfNull(certificate); + ArgumentException.ThrowIfNullOrWhiteSpace(kid); + + var fileExists = File.Exists(jsonFilePath); + + // Validate file exists or creation is allowed + if (!fileExists && !createIfNotExists) + throw new FileNotFoundException($"JSON file not found: {jsonFilePath}. Enable createIfNotExists to create a new file."); + + // Encrypt the value + var crypto = new X509HybridCrypto(certificate); + var envelope = crypto.Encrypt(plaintext); + + // Read or create JSON file + JsonObject rootObject; + if (fileExists) + { + var jsonText = await File.ReadAllTextAsync(jsonFilePath); + var jsonNode = JsonNode.Parse(jsonText); + + if (jsonNode is not JsonObject obj) + throw new InvalidOperationException("JSON file must contain a root object"); + + rootObject = obj; + } + else + { + // Create directory if it doesn't exist + var directory = Path.GetDirectoryName(jsonFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // Start with an empty JSON object + rootObject = new JsonObject(); + } + + // Set the encrypted value at the property path + SetValueAtPath(rootObject, propertyPath, envelope, kid); + + // Write back to file with nice formatting + var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); + await File.WriteAllTextAsync(jsonFilePath, updatedJson, Encoding.UTF8); + + return !fileExists; // Return true if we created the file + } + + /// + /// Encrypts an existing plaintext value in-place at the specified property path in a JSON file. + /// Reads the current value, encrypts it, and replaces it with the encrypted envelope. + /// + /// Path to the JSON configuration file + /// Property path using colon separator (e.g., "Database:ConnectionString") + /// X.509 certificate with public key for encryption + /// Key identifier (kid) for the certificate + /// Always returns false (file must exist) + /// If file doesn't exist + /// If JSON file doesn't contain a root object or property path doesn't exist + public static async Task EncryptExistingValueInFileAsync( + string jsonFilePath, + string propertyPath, + X509Certificate2 certificate, + string kid = "default") + { + ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); + ArgumentNullException.ThrowIfNull(certificate); + ArgumentException.ThrowIfNullOrWhiteSpace(kid); + + if (!File.Exists(jsonFilePath)) + throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); + + // Read JSON file + var jsonText = await File.ReadAllTextAsync(jsonFilePath); + var jsonNode = JsonNode.Parse(jsonText); + + if (jsonNode is not JsonObject rootObject) + throw new InvalidOperationException("JSON file must contain a root object"); + + // Get the existing value at the path + var existingValue = GetValueAtPath(rootObject, propertyPath); + if (existingValue is null) + throw new InvalidOperationException($"No value found at path: {propertyPath}"); + + // Serialize the existing JSON value to a string (preserving its JSON representation) + var jsonValueString = JsonSerializer.Serialize(existingValue); + + // Encrypt the JSON string + var crypto = new X509HybridCrypto(certificate); + var envelope = crypto.Encrypt(jsonValueString); + + // Replace the value with the encrypted envelope + SetValueAtPath(rootObject, propertyPath, envelope, kid); + + // Write back to file + var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); + await File.WriteAllTextAsync(jsonFilePath, updatedJson, Encoding.UTF8); + + return false; // File already existed + } + + /// + /// Decrypts a value from the specified property path in a JSON file. + /// + /// Path to the JSON configuration file + /// Property path using colon separator (e.g., "Database:ConnectionString") + /// X.509 certificate with private key for decryption + /// The decrypted plaintext value + /// If file doesn't exist + /// If JSON file doesn't contain a root object or property path is invalid + public static async Task DecryptValueFromFileAsync( + string jsonFilePath, + string propertyPath, + X509Certificate2 certificate) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); + ArgumentNullException.ThrowIfNull(certificate); + + if (!File.Exists(jsonFilePath)) + throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); + + // Read JSON file + var jsonText = await File.ReadAllTextAsync(jsonFilePath); + var jsonNode = JsonNode.Parse(jsonText); + + if (jsonNode is not JsonObject rootObject) + throw new InvalidOperationException("JSON file must contain a root object"); + + // Get the encrypted envelope from the path + var envelope = GetEnvelopeAtPath(rootObject, propertyPath); + + // Decrypt the value + var crypto = new X509HybridCrypto(certificate); + return crypto.DecryptToString(envelope); + } + + /// + /// Rotates the certificate for an encrypted value by decrypting with old certificate and re-encrypting with new certificate. + /// + /// Path to the JSON configuration file + /// Property path using colon separator (e.g., "Database:ConnectionString") + /// X.509 certificate with private key for decryption + /// X.509 certificate with public key for encryption + /// Optional new key identifier (if null, keeps the existing kid) + /// If file doesn't exist + /// If JSON file doesn't contain a root object or property path is invalid + public static async Task RotateCertificateInFileAsync( + string jsonFilePath, + string propertyPath, + X509Certificate2 oldCertificate, + X509Certificate2 newCertificate, + string? newKid = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); + ArgumentNullException.ThrowIfNull(oldCertificate); + ArgumentNullException.ThrowIfNull(newCertificate); + + if (!File.Exists(jsonFilePath)) + throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); + + // Read JSON file + var jsonText = await File.ReadAllTextAsync(jsonFilePath); + var jsonNode = JsonNode.Parse(jsonText); + + if (jsonNode is not JsonObject rootObject) + throw new InvalidOperationException("JSON file must contain a root object"); + + // Get the encrypted envelope and kid from the path + var (envelope, existingKid) = GetEnvelopeWithKidAtPath(rootObject, propertyPath); + + // Decrypt with old certificate + var oldCrypto = new X509HybridCrypto(oldCertificate); + var plaintext = oldCrypto.DecryptToString(envelope); + + // Re-encrypt with new certificate + var newCrypto = new X509HybridCrypto(newCertificate); + var newEnvelope = newCrypto.Encrypt(plaintext); + + // Use new kid if provided, otherwise keep existing + var kidToUse = newKid ?? existingKid; + + // Update the JSON file with the new envelope + SetValueAtPath(rootObject, propertyPath, newEnvelope, kidToUse); + + // Write back to file + var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); + await File.WriteAllTextAsync(jsonFilePath, updatedJson, Encoding.UTF8); + } + + private static void SetValueAtPath(JsonObject root, string path, HybridSecretEnvelope envelope, string kid) + { + var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + throw new ArgumentException("Property path cannot be empty", nameof(path)); + + JsonObject current = root; + + // Navigate to parent, creating objects as needed + for (int i = 0; i < segments.Length - 1; i++) + { + var segment = segments[i]; + + if (current[segment] is JsonObject existingObject) + { + current = existingObject; + } + else + { + // Create new object if it doesn't exist + var newObject = new JsonObject(); + current[segment] = newObject; + current = newObject; + } + } + + // Wrap the envelope in the Cocoar secret structure + var wrappedEnvelope = new JsonObject + { + ["type"] = "cocoar.secret", + ["version"] = 1, + ["kid"] = kid, + ["alg"] = "RSA-OAEP-AES256-GCM" + }; + + // Merge the envelope properties into the wrapped structure + var envelopeNode = JsonSerializer.SerializeToNode(envelope); + if (envelopeNode is JsonObject envelopeObject) + { + foreach (var prop in envelopeObject) + { + wrappedEnvelope[prop.Key] = prop.Value?.DeepClone(); + } + } + + // Set the final property to the wrapped envelope + var finalSegment = segments[^1]; + current[finalSegment] = wrappedEnvelope; + } + + private static HybridSecretEnvelope GetEnvelopeAtPath(JsonObject root, string path) + { + var (envelope, _) = GetEnvelopeWithKidAtPath(root, path); + return envelope; + } + + private static (HybridSecretEnvelope Envelope, string Kid) GetEnvelopeWithKidAtPath(JsonObject root, string path) + { + var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + throw new ArgumentException("Property path cannot be empty", nameof(path)); + + JsonNode? current = root; + + // Navigate to the property + foreach (var segment in segments) + { + if (current is not JsonObject obj) + throw new InvalidOperationException($"Path segment '{segment}' does not exist or is not an object"); + + current = obj[segment]; + if (current == null) + throw new InvalidOperationException($"Property '{segment}' not found in path '{path}'"); + } + + // Expect a typed secret envelope + if (current is not JsonObject envelopeObj) + throw new InvalidOperationException($"Value at '{path}' is not an encrypted envelope object"); + + var type = envelopeObj["type"]?.GetValue(); + var version = envelopeObj["version"]?.GetValue(); + + if (!string.Equals(type, "cocoar.secret", StringComparison.OrdinalIgnoreCase) || version is null or not 1) + throw new InvalidOperationException($"Invalid or missing Cocoar secret envelope at '{path}'"); + + var kid = envelopeObj["kid"]?.GetValue() ?? "default"; + + // Deserialize the envelope + var envelope = JsonSerializer.Deserialize(envelopeObj.ToJsonString()) + ?? throw new InvalidOperationException($"Failed to deserialize envelope at '{path}'"); + + return (envelope, kid); + } + + private static JsonNode? GetValueAtPath(JsonObject root, string path) + { + var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + throw new ArgumentException("Property path cannot be empty", nameof(path)); + + JsonNode? current = root; + + // Navigate to the property + foreach (var segment in segments) + { + if (current is not JsonObject obj) + throw new InvalidOperationException($"Path segment '{segment}' does not exist or is not an object"); + + current = obj[segment]; + if (current == null) + throw new InvalidOperationException($"Property '{segment}' not found in path '{path}'"); + } + + return current; + } +} diff --git a/src/Cocoar.Configuration.X509Encryption/X509CertificateGenerator.cs b/src/Cocoar.Configuration.X509Encryption/X509CertificateGenerator.cs index 3b65783..bfc2a1b 100644 --- a/src/Cocoar.Configuration.X509Encryption/X509CertificateGenerator.cs +++ b/src/Cocoar.Configuration.X509Encryption/X509CertificateGenerator.cs @@ -1,316 +1,316 @@ -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; - -namespace Cocoar.Configuration.X509Encryption; - -/// -/// Generates self-signed X.509 certificates for encryption purposes. -/// -public static class X509CertificateGenerator -{ - /// - /// Generates a self-signed certificate. - /// - /// Certificate subject (e.g., "CN=MyApp"). - /// Validity period in years (default: 1). - /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). - /// A new self-signed X509Certificate2 with private key. - /// If keySize is not 2048, 3072, or 4096. - public static X509Certificate2 GenerateSelfSigned( - string subject, - int validYears = 1, - int keySize = 2048) - { - ArgumentException.ThrowIfNullOrWhiteSpace(subject); - - if (keySize != 2048 && keySize != 3072 && keySize != 4096) - throw new ArgumentException($"Key size must be 2048, 3072, or 4096, got {keySize}", nameof(keySize)); - - if (validYears < 1) - throw new ArgumentException("Validity period must be at least 1 year", nameof(validYears)); - - using var rsa = RSA.Create(keySize); - var request = new CertificateRequest( - subject, - rsa, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - - // Add basic constraints - request.CertificateExtensions.Add( - new X509BasicConstraintsExtension( - certificateAuthority: false, - hasPathLengthConstraint: false, - pathLengthConstraint: 0, - critical: false)); - - // Add key usage - request.CertificateExtensions.Add( - new X509KeyUsageExtension( - X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment, - critical: false)); - - var notBefore = DateTimeOffset.UtcNow; - var notAfter = notBefore.AddYears(validYears); - - return request.CreateSelfSigned(notBefore, notAfter); - } - - /// - /// Generates a self-signed certificate and saves it as a PFX file. - /// - /// Path where the PFX file will be saved. - /// Password to protect the PFX file. - /// Certificate subject (e.g., "CN=MyApp"). - /// Validity period in years (default: 1). - /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). - /// If true, overwrites existing file; otherwise throws if file exists. - /// The generated certificate. - /// If file exists and overwrite is false. - public static X509Certificate2 GenerateAndSavePfx( - string outputPath, - string? password, - string subject, - int validYears = 1, - int keySize = 2048, - bool overwrite = false) - { - ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); - - if (File.Exists(outputPath) && !overwrite) - throw new IOException($"File already exists: {outputPath}. Use overwrite=true to replace."); - - var cert = GenerateSelfSigned(subject, validYears, keySize); - - try - { - var pfxBytes = cert.Export(X509ContentType.Pfx, password); - File.WriteAllBytes(outputPath, pfxBytes); - return cert; - } - catch - { - cert.Dispose(); - throw; - } - } - - /// - /// Generates a self-signed certificate and saves it in PEM format (.crt + .key files). - /// - /// Path where the certificate file will be saved (.crt). - /// Path where the private key file will be saved (.key). If null, uses certPath with .key extension. - /// Certificate subject (e.g., "CN=MyApp"). - /// Validity period in years (default: 1). - /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). - /// If true, overwrites existing files; otherwise throws if files exist. - /// The generated certificate (without private key - use keyPath to load full cert). - /// If files exist and overwrite is false. - public static X509Certificate2 GenerateAndSavePem( - string certPath, - string? keyPath = null, - string subject = "CN=Cocoar Secrets", - int validYears = 1, - int keySize = 2048, - bool overwrite = false) - { - ArgumentException.ThrowIfNullOrWhiteSpace(certPath); - - keyPath ??= Path.ChangeExtension(certPath, ".key"); - - if (File.Exists(certPath) && !overwrite) - throw new IOException($"Certificate file already exists: {certPath}. Use overwrite=true to replace."); - - if (File.Exists(keyPath) && !overwrite) - throw new IOException($"Private key file already exists: {keyPath}. Use overwrite=true to replace."); - - var cert = GenerateSelfSigned(subject, validYears, keySize); - - try - { - // Export certificate (public key only) as PEM - var certPem = cert.ExportCertificatePem(); - File.WriteAllText(certPath, certPem); - - // Export private key as PEM - var key = cert.GetRSAPrivateKey(); - if (key == null) - throw new InvalidOperationException("Certificate does not contain an RSA private key."); - - var keyPem = key.ExportRSAPrivateKeyPem(); - File.WriteAllText(keyPath, keyPem); - - return cert; - } - catch - { - cert.Dispose(); - throw; - } - } - - /// - /// Generates a self-signed certificate and saves it as a PFX file. - /// - /// Path where the certificate will be saved. - /// Password for PFX file. - /// Certificate subject (e.g., "CN=MyApp"). - /// Validity period in years (default: 1). - /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). - /// If true, overwrites existing files; otherwise throws if files exist. - /// The generated certificate. - [Obsolete("Use GenerateAndSavePfx or GenerateAndSavePem instead.")] - public static X509Certificate2 GenerateAndSave( - string outputPath, - string password, - string subject, - int validYears = 1, - int keySize = 2048, - bool overwrite = false) - { - return GenerateAndSavePfx(outputPath, password, subject, validYears, keySize, overwrite); - } - - /// - /// Converts a certificate from PFX to PEM format. - /// - /// Path to input PFX file. - /// Password for PFX file. - /// Path where certificate will be saved (.crt). - /// Path where private key will be saved (.key). If null, uses certPath with .key extension. - /// If true, overwrites existing files; otherwise throws if files exist. - /// The certificate (without private key - use keyPath to load full cert). - /// If files exist and overwrite is false. - /// If PFX cannot be loaded or password is incorrect. - public static X509Certificate2 ConvertPfxToPem( - string pfxPath, - string pfxPassword, - string certPath, - string? keyPath = null, - bool overwrite = false) - { - ArgumentException.ThrowIfNullOrWhiteSpace(pfxPath); - ArgumentException.ThrowIfNullOrWhiteSpace(pfxPassword); - ArgumentException.ThrowIfNullOrWhiteSpace(certPath); - - keyPath ??= Path.ChangeExtension(certPath, ".key"); - - if (File.Exists(certPath) && !overwrite) - throw new IOException($"Certificate file already exists: {certPath}. Use overwrite=true to replace."); - - if (File.Exists(keyPath) && !overwrite) - throw new IOException($"Private key file already exists: {keyPath}. Use overwrite=true to replace."); - - // Load PFX with private key - var cert = X509CertificateLoader.LoadPkcs12FromFile(pfxPath, pfxPassword, X509KeyStorageFlags.Exportable); - - if (!cert.HasPrivateKey) - { - cert.Dispose(); - throw new InvalidOperationException("PFX file does not contain a private key."); - } - - try - { - // Export certificate (public key only) - var certPem = cert.ExportCertificatePem(); - File.WriteAllText(certPath, certPem); - - // Export private key - var key = cert.GetRSAPrivateKey(); - if (key == null) - { - cert.Dispose(); - throw new InvalidOperationException("Certificate does not contain an RSA private key."); - } - - var keyPem = key.ExportRSAPrivateKeyPem(); - File.WriteAllText(keyPath, keyPem); - - return cert; - } - catch - { - cert.Dispose(); - throw; - } - } - - /// - /// Converts a certificate from PEM to PFX format. - /// - /// Path to input certificate file (.crt, .pem, .cer). - /// Path to private key file (.key). If null, uses certPath with .key extension. - /// Path where PFX file will be saved. - /// Password for output PFX file. - /// If true, overwrites existing file; otherwise throws if file exists. - /// The certificate with private key. - /// If certificate or key file not found. - /// If output file exists and overwrite is false. - public static X509Certificate2 ConvertPemToPfx( - string certPath, - string? keyPath, - string pfxPath, - string pfxPassword, - bool overwrite = false) - { - ArgumentException.ThrowIfNullOrWhiteSpace(certPath); - ArgumentException.ThrowIfNullOrWhiteSpace(pfxPath); - ArgumentException.ThrowIfNullOrWhiteSpace(pfxPassword); - - keyPath ??= Path.ChangeExtension(certPath, ".key"); - - if (!File.Exists(certPath)) - throw new FileNotFoundException($"Certificate file not found: {certPath}"); - - if (!File.Exists(keyPath)) - throw new FileNotFoundException($"Private key file not found: {keyPath}"); - - if (File.Exists(pfxPath) && !overwrite) - throw new IOException($"PFX file already exists: {pfxPath}. Use overwrite=true to replace."); - - // Load PEM certificate with private key - var cert = X509Certificate2.CreateFromPemFile(certPath, keyPath); - - if (!cert.HasPrivateKey) - { - cert.Dispose(); - throw new InvalidOperationException("Certificate does not contain a private key."); - } - - try - { - // Export as PFX - var pfxBytes = cert.Export(X509ContentType.Pfx, pfxPassword); - File.WriteAllBytes(pfxPath, pfxBytes); - - return cert; - } - catch - { - cert.Dispose(); - throw; - } - } -} - -/// -/// Certificate output format. -/// -public enum CertificateFormat -{ - /// - /// PKCS#12 format (.pfx, .p12) - certificate + private key in single password-protected file. - /// - Pfx, - - /// - /// PEM format (.crt + .key) - certificate and private key in separate text files. - /// - Pem, - - /// - /// Auto-detect from file extension (.pfx/.p12 = Pfx, .crt/.pem/.cer = Pem). - /// - Auto -} +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Cocoar.Configuration.X509Encryption; + +/// +/// Generates self-signed X.509 certificates for encryption purposes. +/// +public static class X509CertificateGenerator +{ + /// + /// Generates a self-signed certificate. + /// + /// Certificate subject (e.g., "CN=MyApp"). + /// Validity period in years (default: 1). + /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). + /// A new self-signed X509Certificate2 with private key. + /// If keySize is not 2048, 3072, or 4096. + public static X509Certificate2 GenerateSelfSigned( + string subject, + int validYears = 1, + int keySize = 2048) + { + ArgumentException.ThrowIfNullOrWhiteSpace(subject); + + if (keySize != 2048 && keySize != 3072 && keySize != 4096) + throw new ArgumentException($"Key size must be 2048, 3072, or 4096, got {keySize}", nameof(keySize)); + + if (validYears < 1) + throw new ArgumentException("Validity period must be at least 1 year", nameof(validYears)); + + using var rsa = RSA.Create(keySize); + var request = new CertificateRequest( + subject, + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + // Add basic constraints + request.CertificateExtensions.Add( + new X509BasicConstraintsExtension( + certificateAuthority: false, + hasPathLengthConstraint: false, + pathLengthConstraint: 0, + critical: false)); + + // Add key usage + request.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment, + critical: false)); + + var notBefore = DateTimeOffset.UtcNow; + var notAfter = notBefore.AddYears(validYears); + + return request.CreateSelfSigned(notBefore, notAfter); + } + + /// + /// Generates a self-signed certificate and saves it as a PFX file. + /// + /// Path where the PFX file will be saved. + /// Password to protect the PFX file. + /// Certificate subject (e.g., "CN=MyApp"). + /// Validity period in years (default: 1). + /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). + /// If true, overwrites existing file; otherwise throws if file exists. + /// The generated certificate. + /// If file exists and overwrite is false. + public static X509Certificate2 GenerateAndSavePfx( + string outputPath, + string? password, + string subject, + int validYears = 1, + int keySize = 2048, + bool overwrite = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + + if (File.Exists(outputPath) && !overwrite) + throw new IOException($"File already exists: {outputPath}. Use overwrite=true to replace."); + + var cert = GenerateSelfSigned(subject, validYears, keySize); + + try + { + var pfxBytes = cert.Export(X509ContentType.Pfx, password); + File.WriteAllBytes(outputPath, pfxBytes); + return cert; + } + catch + { + cert.Dispose(); + throw; + } + } + + /// + /// Generates a self-signed certificate and saves it in PEM format (.crt + .key files). + /// + /// Path where the certificate file will be saved (.crt). + /// Path where the private key file will be saved (.key). If null, uses certPath with .key extension. + /// Certificate subject (e.g., "CN=MyApp"). + /// Validity period in years (default: 1). + /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). + /// If true, overwrites existing files; otherwise throws if files exist. + /// The generated certificate (without private key - use keyPath to load full cert). + /// If files exist and overwrite is false. + public static X509Certificate2 GenerateAndSavePem( + string certPath, + string? keyPath = null, + string subject = "CN=Cocoar Secrets", + int validYears = 1, + int keySize = 2048, + bool overwrite = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(certPath); + + keyPath ??= Path.ChangeExtension(certPath, ".key"); + + if (File.Exists(certPath) && !overwrite) + throw new IOException($"Certificate file already exists: {certPath}. Use overwrite=true to replace."); + + if (File.Exists(keyPath) && !overwrite) + throw new IOException($"Private key file already exists: {keyPath}. Use overwrite=true to replace."); + + var cert = GenerateSelfSigned(subject, validYears, keySize); + + try + { + // Export certificate (public key only) as PEM + var certPem = cert.ExportCertificatePem(); + File.WriteAllText(certPath, certPem); + + // Export private key as PEM + var key = cert.GetRSAPrivateKey(); + if (key == null) + throw new InvalidOperationException("Certificate does not contain an RSA private key."); + + var keyPem = key.ExportRSAPrivateKeyPem(); + File.WriteAllText(keyPath, keyPem); + + return cert; + } + catch + { + cert.Dispose(); + throw; + } + } + + /// + /// Generates a self-signed certificate and saves it as a PFX file. + /// + /// Path where the certificate will be saved. + /// Password for PFX file. + /// Certificate subject (e.g., "CN=MyApp"). + /// Validity period in years (default: 1). + /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). + /// If true, overwrites existing files; otherwise throws if files exist. + /// The generated certificate. + [Obsolete("Use GenerateAndSavePfx or GenerateAndSavePem instead.")] + public static X509Certificate2 GenerateAndSave( + string outputPath, + string password, + string subject, + int validYears = 1, + int keySize = 2048, + bool overwrite = false) + { + return GenerateAndSavePfx(outputPath, password, subject, validYears, keySize, overwrite); + } + + /// + /// Converts a certificate from PFX to PEM format. + /// + /// Path to input PFX file. + /// Password for PFX file. + /// Path where certificate will be saved (.crt). + /// Path where private key will be saved (.key). If null, uses certPath with .key extension. + /// If true, overwrites existing files; otherwise throws if files exist. + /// The certificate (without private key - use keyPath to load full cert). + /// If files exist and overwrite is false. + /// If PFX cannot be loaded or password is incorrect. + public static X509Certificate2 ConvertPfxToPem( + string pfxPath, + string pfxPassword, + string certPath, + string? keyPath = null, + bool overwrite = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pfxPath); + ArgumentException.ThrowIfNullOrWhiteSpace(pfxPassword); + ArgumentException.ThrowIfNullOrWhiteSpace(certPath); + + keyPath ??= Path.ChangeExtension(certPath, ".key"); + + if (File.Exists(certPath) && !overwrite) + throw new IOException($"Certificate file already exists: {certPath}. Use overwrite=true to replace."); + + if (File.Exists(keyPath) && !overwrite) + throw new IOException($"Private key file already exists: {keyPath}. Use overwrite=true to replace."); + + // Load PFX with private key + var cert = X509CertificateLoader.LoadPkcs12FromFile(pfxPath, pfxPassword, X509KeyStorageFlags.Exportable); + + if (!cert.HasPrivateKey) + { + cert.Dispose(); + throw new InvalidOperationException("PFX file does not contain a private key."); + } + + try + { + // Export certificate (public key only) + var certPem = cert.ExportCertificatePem(); + File.WriteAllText(certPath, certPem); + + // Export private key + var key = cert.GetRSAPrivateKey(); + if (key == null) + { + cert.Dispose(); + throw new InvalidOperationException("Certificate does not contain an RSA private key."); + } + + var keyPem = key.ExportRSAPrivateKeyPem(); + File.WriteAllText(keyPath, keyPem); + + return cert; + } + catch + { + cert.Dispose(); + throw; + } + } + + /// + /// Converts a certificate from PEM to PFX format. + /// + /// Path to input certificate file (.crt, .pem, .cer). + /// Path to private key file (.key). If null, uses certPath with .key extension. + /// Path where PFX file will be saved. + /// Password for output PFX file. + /// If true, overwrites existing file; otherwise throws if file exists. + /// The certificate with private key. + /// If certificate or key file not found. + /// If output file exists and overwrite is false. + public static X509Certificate2 ConvertPemToPfx( + string certPath, + string? keyPath, + string pfxPath, + string pfxPassword, + bool overwrite = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(certPath); + ArgumentException.ThrowIfNullOrWhiteSpace(pfxPath); + ArgumentException.ThrowIfNullOrWhiteSpace(pfxPassword); + + keyPath ??= Path.ChangeExtension(certPath, ".key"); + + if (!File.Exists(certPath)) + throw new FileNotFoundException($"Certificate file not found: {certPath}"); + + if (!File.Exists(keyPath)) + throw new FileNotFoundException($"Private key file not found: {keyPath}"); + + if (File.Exists(pfxPath) && !overwrite) + throw new IOException($"PFX file already exists: {pfxPath}. Use overwrite=true to replace."); + + // Load PEM certificate with private key + var cert = X509Certificate2.CreateFromPemFile(certPath, keyPath); + + if (!cert.HasPrivateKey) + { + cert.Dispose(); + throw new InvalidOperationException("Certificate does not contain a private key."); + } + + try + { + // Export as PFX + var pfxBytes = cert.Export(X509ContentType.Pfx, pfxPassword); + File.WriteAllBytes(pfxPath, pfxBytes); + + return cert; + } + catch + { + cert.Dispose(); + throw; + } + } +} + +/// +/// Certificate output format. +/// +public enum CertificateFormat +{ + /// + /// PKCS#12 format (.pfx, .p12) - certificate + private key in single password-protected file. + /// + Pfx, + + /// + /// PEM format (.crt + .key) - certificate and private key in separate text files. + /// + Pem, + + /// + /// Auto-detect from file extension (.pfx/.p12 = Pfx, .crt/.pem/.cer = Pem). + /// + Auto +} diff --git a/src/Cocoar.Configuration.X509Encryption/X509HybridCrypto.cs b/src/Cocoar.Configuration.X509Encryption/X509HybridCrypto.cs index b0720bb..a58b8fe 100644 --- a/src/Cocoar.Configuration.X509Encryption/X509HybridCrypto.cs +++ b/src/Cocoar.Configuration.X509Encryption/X509HybridCrypto.cs @@ -1,148 +1,148 @@ -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text; - -namespace Cocoar.Configuration.X509Encryption; - -/// -/// Hybrid RSA+AES encryption using X.509 certificates. -/// Uses RSA-OAEP-SHA256 for key wrapping and AES-256-GCM for data encryption. -/// -public sealed class X509HybridCrypto -{ - private readonly X509Certificate2 _certificate; - private readonly RSA _rsa; - - /// - /// Create a crypto instance from a certificate with private key. - /// - public X509HybridCrypto(X509Certificate2 certificate) - { - if (!certificate.HasPrivateKey) - throw new ArgumentException("Certificate must have a private key for encryption/decryption", nameof(certificate)); - - _certificate = certificate ?? throw new ArgumentNullException(nameof(certificate)); - _rsa = certificate.GetRSAPrivateKey() - ?? throw new InvalidOperationException("RSA private key not available on certificate"); - } - - /// - /// Encrypt plaintext bytes into a hybrid envelope. - /// - public HybridSecretEnvelope Encrypt(ReadOnlySpan plaintext) - { - // Generate random 256-bit AES key (DEK - Data Encryption Key) - Span dek = stackalloc byte[32]; - RandomNumberGenerator.Fill(dek); - - try - { - // Generate random 96-bit IV for AES-GCM - byte[] iv = new byte[12]; - RandomNumberGenerator.Fill(iv); - - // Encrypt with AES-256-GCM - byte[] ciphertext = new byte[plaintext.Length]; - byte[] tag = new byte[16]; - - using (var aes = new AesGcm(dek, tag.Length)) - { - aes.Encrypt(iv, plaintext, ciphertext, tag, associatedData: null); - } - - // Wrap DEK with RSA-OAEP-SHA256 - var wrappedKey = _rsa.Encrypt(dek.ToArray(), RSAEncryptionPadding.OaepSHA256); - - return new HybridSecretEnvelope - { - WrappedKey = Convert.ToBase64String(wrappedKey), - WrappingAlgorithm = "RSA-OAEP-256", - Iv = Convert.ToBase64String(iv), - Ciphertext = Convert.ToBase64String(ciphertext), - Tag = Convert.ToBase64String(tag) - }; - } - finally - { - // Zero out the DEK from memory - CryptographicOperations.ZeroMemory(dek); - } - } - - /// - /// Encrypt a UTF-8 string into a hybrid envelope. - /// - public HybridSecretEnvelope Encrypt(string plaintext) - { - var bytes = Encoding.UTF8.GetBytes(plaintext); - try - { - return Encrypt(bytes.AsSpan()); - } - finally - { - Array.Clear(bytes); - } - } - - /// - /// Decrypt a hybrid envelope back to plaintext bytes. - /// - public byte[] Decrypt(HybridSecretEnvelope envelope) - { - ArgumentNullException.ThrowIfNull(envelope); - - // Unwrap the AES key with RSA - var wrappedKey = Convert.FromBase64String(envelope.WrappedKey); - var dek = _rsa.Decrypt(wrappedKey, RSAEncryptionPadding.OaepSHA256); - - try - { - // Decrypt with AES-256-GCM - var iv = Convert.FromBase64String(envelope.Iv); - var ciphertext = Convert.FromBase64String(envelope.Ciphertext); - var tag = Convert.FromBase64String(envelope.Tag); - - var plaintext = new byte[ciphertext.Length]; - - using (var aes = new AesGcm(dek, tag.Length)) - { - aes.Decrypt(iv, ciphertext, tag, plaintext, associatedData: null); - } - - return plaintext; - } - finally - { - // Zero out the DEK from memory - Array.Clear(dek); - } - } - - /// - /// Decrypt a hybrid envelope to a UTF-8 string. - /// - public string DecryptToString(HybridSecretEnvelope envelope) - { - var bytes = Decrypt(envelope); - try - { - return Encoding.UTF8.GetString(bytes); - } - finally - { - Array.Clear(bytes); - } - } - - /// - /// Load a certificate from a PFX file. - /// - public static X509Certificate2 LoadCertificate(string pfxPath, string password) - { - if (!File.Exists(pfxPath)) - throw new FileNotFoundException($"Certificate file not found: {pfxPath}", pfxPath); - - return X509CertificateLoader.LoadPkcs12FromFile(pfxPath, password, X509KeyStorageFlags.Exportable); - } -} +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Cocoar.Configuration.X509Encryption; + +/// +/// Hybrid RSA+AES encryption using X.509 certificates. +/// Uses RSA-OAEP-SHA256 for key wrapping and AES-256-GCM for data encryption. +/// +public sealed class X509HybridCrypto +{ + private readonly X509Certificate2 _certificate; + private readonly RSA _rsa; + + /// + /// Create a crypto instance from a certificate with private key. + /// + public X509HybridCrypto(X509Certificate2 certificate) + { + if (!certificate.HasPrivateKey) + throw new ArgumentException("Certificate must have a private key for encryption/decryption", nameof(certificate)); + + _certificate = certificate ?? throw new ArgumentNullException(nameof(certificate)); + _rsa = certificate.GetRSAPrivateKey() + ?? throw new InvalidOperationException("RSA private key not available on certificate"); + } + + /// + /// Encrypt plaintext bytes into a hybrid envelope. + /// + public HybridSecretEnvelope Encrypt(ReadOnlySpan plaintext) + { + // Generate random 256-bit AES key (DEK - Data Encryption Key) + Span dek = stackalloc byte[32]; + RandomNumberGenerator.Fill(dek); + + try + { + // Generate random 96-bit IV for AES-GCM + byte[] iv = new byte[12]; + RandomNumberGenerator.Fill(iv); + + // Encrypt with AES-256-GCM + byte[] ciphertext = new byte[plaintext.Length]; + byte[] tag = new byte[16]; + + using (var aes = new AesGcm(dek, tag.Length)) + { + aes.Encrypt(iv, plaintext, ciphertext, tag, associatedData: null); + } + + // Wrap DEK with RSA-OAEP-SHA256 + var wrappedKey = _rsa.Encrypt(dek.ToArray(), RSAEncryptionPadding.OaepSHA256); + + return new HybridSecretEnvelope + { + WrappedKey = Convert.ToBase64String(wrappedKey), + WrappingAlgorithm = "RSA-OAEP-256", + Iv = Convert.ToBase64String(iv), + Ciphertext = Convert.ToBase64String(ciphertext), + Tag = Convert.ToBase64String(tag) + }; + } + finally + { + // Zero out the DEK from memory + CryptographicOperations.ZeroMemory(dek); + } + } + + /// + /// Encrypt a UTF-8 string into a hybrid envelope. + /// + public HybridSecretEnvelope Encrypt(string plaintext) + { + var bytes = Encoding.UTF8.GetBytes(plaintext); + try + { + return Encrypt(bytes.AsSpan()); + } + finally + { + Array.Clear(bytes); + } + } + + /// + /// Decrypt a hybrid envelope back to plaintext bytes. + /// + public byte[] Decrypt(HybridSecretEnvelope envelope) + { + ArgumentNullException.ThrowIfNull(envelope); + + // Unwrap the AES key with RSA + var wrappedKey = Convert.FromBase64String(envelope.WrappedKey); + var dek = _rsa.Decrypt(wrappedKey, RSAEncryptionPadding.OaepSHA256); + + try + { + // Decrypt with AES-256-GCM + var iv = Convert.FromBase64String(envelope.Iv); + var ciphertext = Convert.FromBase64String(envelope.Ciphertext); + var tag = Convert.FromBase64String(envelope.Tag); + + var plaintext = new byte[ciphertext.Length]; + + using (var aes = new AesGcm(dek, tag.Length)) + { + aes.Decrypt(iv, ciphertext, tag, plaintext, associatedData: null); + } + + return plaintext; + } + finally + { + // Zero out the DEK from memory + Array.Clear(dek); + } + } + + /// + /// Decrypt a hybrid envelope to a UTF-8 string. + /// + public string DecryptToString(HybridSecretEnvelope envelope) + { + var bytes = Decrypt(envelope); + try + { + return Encoding.UTF8.GetString(bytes); + } + finally + { + Array.Clear(bytes); + } + } + + /// + /// Load a certificate from a PFX file. + /// + public static X509Certificate2 LoadCertificate(string pfxPath, string password) + { + if (!File.Exists(pfxPath)) + throw new FileNotFoundException($"Certificate file not found: {pfxPath}", pfxPath); + + return X509CertificateLoader.LoadPkcs12FromFile(pfxPath, password, X509KeyStorageFlags.Exportable); + } +} diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx index 2a22ef8..03cdcd5 100644 --- a/src/Cocoar.Configuration.slnx +++ b/src/Cocoar.Configuration.slnx @@ -1,45 +1,45 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Cocoar.Configuration/Configure/ConcreteTypeSetup.cs b/src/Cocoar.Configuration/Configure/ConcreteTypeSetup.cs index 69e323e..68573fa 100644 --- a/src/Cocoar.Configuration/Configure/ConcreteTypeSetup.cs +++ b/src/Cocoar.Configuration/Configure/ConcreteTypeSetup.cs @@ -1,49 +1,49 @@ -using Cocoar.Capabilities; -using Cocoar.Configuration.Core; - -namespace Cocoar.Configuration.Configure; - -/// -/// Configures how a concrete configuration type is registered and optionally exposed via interfaces. -/// All types are registered as Scoped by default. -/// -public sealed class ConcreteTypeSetup : SetupDefinition where T : class -{ - public Guid Id { get; } = Guid.NewGuid(); - internal ConcreteTypeSetup(ConfigManagerCapabilityScope capabilityScope): base(capabilityScope) - { - capabilityScope.Compose(this).WithPrimary( - new ConcreteTypePrimary(typeof(T))); - } - - /// - /// Exposes this configuration type via an interface for dependency injection. - /// Useful when you want consumers to depend on abstractions rather than concrete types. - /// Both the concrete type and the interface will be registered as Scoped. - /// - public ConcreteTypeSetup ExposeAs() where TInterface : class - { - var interfaceType = typeof(TInterface); - if (!interfaceType.IsInterface) - { - throw new InvalidOperationException( - $"{interfaceType.Name} isn't an interface. ExposeAs() requires T to be an interface type."); - } - if (!interfaceType.IsAssignableFrom(typeof(T))) - { - throw new InvalidOperationException( - $"{typeof(T).Name} doesn't implement {interfaceType.Name}. " + - $"Check your class definition or remove this ExposeAs call."); - } - - GetComposer(this).Add(new ExposeAsCapability(interfaceType)); - - return this; - } - - internal override SetupDefinition Build() - { - GetComposer(this).Build(); - return this; - } -} +using Cocoar.Capabilities; +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.Configure; + +/// +/// Configures how a concrete configuration type is registered and optionally exposed via interfaces. +/// All types are registered as Scoped by default. +/// +public sealed class ConcreteTypeSetup : SetupDefinition where T : class +{ + public Guid Id { get; } = Guid.NewGuid(); + internal ConcreteTypeSetup(ConfigManagerCapabilityScope capabilityScope): base(capabilityScope) + { + capabilityScope.Compose(this).WithPrimary( + new ConcreteTypePrimary(typeof(T))); + } + + /// + /// Exposes this configuration type via an interface for dependency injection. + /// Useful when you want consumers to depend on abstractions rather than concrete types. + /// Both the concrete type and the interface will be registered as Scoped. + /// + public ConcreteTypeSetup ExposeAs() where TInterface : class + { + var interfaceType = typeof(TInterface); + if (!interfaceType.IsInterface) + { + throw new InvalidOperationException( + $"{interfaceType.Name} isn't an interface. ExposeAs() requires T to be an interface type."); + } + if (!interfaceType.IsAssignableFrom(typeof(T))) + { + throw new InvalidOperationException( + $"{typeof(T).Name} doesn't implement {interfaceType.Name}. " + + $"Check your class definition or remove this ExposeAs call."); + } + + GetComposer(this).Add(new ExposeAsCapability(interfaceType)); + + return this; + } + + internal override SetupDefinition Build() + { + GetComposer(this).Build(); + return this; + } +} diff --git a/src/Cocoar.Configuration/Configure/ConfigureSpec.cs b/src/Cocoar.Configuration/Configure/ConfigureSpec.cs index 530b70c..6b81c4f 100644 --- a/src/Cocoar.Configuration/Configure/ConfigureSpec.cs +++ b/src/Cocoar.Configuration/Configure/ConfigureSpec.cs @@ -1,15 +1,15 @@ -using Cocoar.Capabilities; - -namespace Cocoar.Configuration.Configure; - -public interface IPrimaryTypeCapability: IPrimaryCapability -{ - public Type SelectedType { get; } -} - -public sealed record ConcreteTypePrimary(Type Concrete) : IPrimaryTypeCapability -{ - public Type SelectedType => Concrete; -} - -public sealed record ExposeAsCapability(Type ContractType); +using Cocoar.Capabilities; + +namespace Cocoar.Configuration.Configure; + +public interface IPrimaryTypeCapability: IPrimaryCapability +{ + public Type SelectedType { get; } +} + +public sealed record ConcreteTypePrimary(Type Concrete) : IPrimaryTypeCapability +{ + public Type SelectedType => Concrete; +} + +public sealed record ExposeAsCapability(Type ContractType); diff --git a/src/Cocoar.Configuration/Configure/IDeferredConfiguration.cs b/src/Cocoar.Configuration/Configure/IDeferredConfiguration.cs index 4673d24..64d10a9 100644 --- a/src/Cocoar.Configuration/Configure/IDeferredConfiguration.cs +++ b/src/Cocoar.Configuration/Configure/IDeferredConfiguration.cs @@ -1,13 +1,13 @@ -namespace Cocoar.Configuration.Configure; - -/// -/// Marker interface for capabilities that require deferred execution after all setup is complete. -/// Implementations will have their Apply method called during ConfigManager.Initialize(). -/// -public interface IDeferredConfiguration -{ - /// - /// Apply the deferred configuration. Called once during ConfigManager.Initialize(). - /// - void Apply(); -} +namespace Cocoar.Configuration.Configure; + +/// +/// Marker interface for capabilities that require deferred execution after all setup is complete. +/// Implementations will have their Apply method called during ConfigManager.Initialize(). +/// +public interface IDeferredConfiguration +{ + /// + /// Apply the deferred configuration. Called once during ConfigManager.Initialize(). + /// + void Apply(); +} diff --git a/src/Cocoar.Configuration/Configure/InterfaceTypeSetup.cs b/src/Cocoar.Configuration/Configure/InterfaceTypeSetup.cs index c576897..52f858a 100644 --- a/src/Cocoar.Configuration/Configure/InterfaceTypeSetup.cs +++ b/src/Cocoar.Configuration/Configure/InterfaceTypeSetup.cs @@ -1,53 +1,53 @@ -using Cocoar.Capabilities; -using Cocoar.Configuration.Core; - -namespace Cocoar.Configuration.Configure; - -/// -/// Configures how an interface type should be deserialized from configuration sources. -/// When configuration JSON contains an interface-typed property, this mapping tells the deserializer -/// which concrete type to instantiate. -/// -public sealed class InterfaceTypeSetup : SetupDefinition where TInterface : class -{ - public Guid Id { get; } = Guid.NewGuid(); - - internal InterfaceTypeSetup(ConfigManagerCapabilityScope capabilityScope) : base(capabilityScope) - { - var interfaceType = typeof(TInterface); - if (!interfaceType.IsInterface) - { - throw new InvalidOperationException( - $"{interfaceType.Name} isn't an interface. Interface() requires T to be an interface type."); - } - - capabilityScope.Compose(this).WithPrimary( - new InterfaceTypePrimary(interfaceType)); - } - - /// - /// Specifies the concrete type to use when deserializing this interface from configuration sources. - /// The concrete type must implement the interface. - /// - public InterfaceTypeSetup DeserializeTo() where TConcrete : class, TInterface - { - var concreteType = typeof(TConcrete); - - GetComposer(this).Add(new DeserializeToCapability(concreteType)); - - return this; - } - - internal override SetupDefinition Build() - { - GetComposer(this).Build(); - return this; - } -} - -internal sealed record InterfaceTypePrimary(Type InterfaceType) : IPrimaryTypeCapability -{ - public Type SelectedType => InterfaceType; -} - -internal sealed record DeserializeToCapability(Type ConcreteType); +using Cocoar.Capabilities; +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.Configure; + +/// +/// Configures how an interface type should be deserialized from configuration sources. +/// When configuration JSON contains an interface-typed property, this mapping tells the deserializer +/// which concrete type to instantiate. +/// +public sealed class InterfaceTypeSetup : SetupDefinition where TInterface : class +{ + public Guid Id { get; } = Guid.NewGuid(); + + internal InterfaceTypeSetup(ConfigManagerCapabilityScope capabilityScope) : base(capabilityScope) + { + var interfaceType = typeof(TInterface); + if (!interfaceType.IsInterface) + { + throw new InvalidOperationException( + $"{interfaceType.Name} isn't an interface. Interface() requires T to be an interface type."); + } + + capabilityScope.Compose(this).WithPrimary( + new InterfaceTypePrimary(interfaceType)); + } + + /// + /// Specifies the concrete type to use when deserializing this interface from configuration sources. + /// The concrete type must implement the interface. + /// + public InterfaceTypeSetup DeserializeTo() where TConcrete : class, TInterface + { + var concreteType = typeof(TConcrete); + + GetComposer(this).Add(new DeserializeToCapability(concreteType)); + + return this; + } + + internal override SetupDefinition Build() + { + GetComposer(this).Build(); + return this; + } +} + +internal sealed record InterfaceTypePrimary(Type InterfaceType) : IPrimaryTypeCapability +{ + public Type SelectedType => InterfaceType; +} + +internal sealed record DeserializeToCapability(Type ConcreteType); diff --git a/src/Cocoar.Configuration/Configure/SetupBuilder.cs b/src/Cocoar.Configuration/Configure/SetupBuilder.cs index b8d2b0b..49adb29 100644 --- a/src/Cocoar.Configuration/Configure/SetupBuilder.cs +++ b/src/Cocoar.Configuration/Configure/SetupBuilder.cs @@ -1,43 +1,43 @@ -using Cocoar.Capabilities; -using Cocoar.Configuration.Core; - -namespace Cocoar.Configuration.Configure; - - - - -public abstract class SetupDefinition(ConfigManagerCapabilityScope capabilityScope) { - protected ConfigManagerCapabilityScope CapabilityScope { get; } = capabilityScope; - internal abstract SetupDefinition Build(); - - public static ConfigManagerCapabilityScope GetCapabilityScopeFor(SetupDefinition builder) => builder.CapabilityScope; - - public static Composer GetComposer(SetupDefinition builder) => - GetCapabilityScopeFor(builder).Composers.GetRequired(builder); -} - -/// -/// Configures how types are registered in the DI container. -/// All concrete types are Scoped by default. Use ConcreteType<T>().ExposeAs<IInterface>() to inject via interfaces. -/// -public sealed class SetupBuilder(ConfigManagerCapabilityScope capabilityScope) -{ - private readonly ConfigManagerCapabilityScope _capabilityScope = capabilityScope; - - public Guid Id { get; } = Guid.NewGuid(); - - /// - /// Configure registration behavior for a concrete configuration type. - /// By default, both the concrete type and any exposed interfaces are registered as Scoped. - /// - public ConcreteTypeSetup ConcreteType() where T : class => new(_capabilityScope); - - /// - /// Configure deserialization mapping for an interface type. - /// Use this when your configuration classes have interface-typed properties that need to be - /// deserialized from JSON/environment variables. Specify which concrete type to instantiate. - /// - public InterfaceTypeSetup Interface() where TInterface : class => new(_capabilityScope); - - public static ConfigManagerCapabilityScope GetCapabilityScopeFor(SetupBuilder builder) => builder._capabilityScope; -} +using Cocoar.Capabilities; +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.Configure; + + + + +public abstract class SetupDefinition(ConfigManagerCapabilityScope capabilityScope) { + protected ConfigManagerCapabilityScope CapabilityScope { get; } = capabilityScope; + internal abstract SetupDefinition Build(); + + public static ConfigManagerCapabilityScope GetCapabilityScopeFor(SetupDefinition builder) => builder.CapabilityScope; + + public static Composer GetComposer(SetupDefinition builder) => + GetCapabilityScopeFor(builder).Composers.GetRequired(builder); +} + +/// +/// Configures how types are registered in the DI container. +/// All concrete types are Scoped by default. Use ConcreteType<T>().ExposeAs<IInterface>() to inject via interfaces. +/// +public sealed class SetupBuilder(ConfigManagerCapabilityScope capabilityScope) +{ + private readonly ConfigManagerCapabilityScope _capabilityScope = capabilityScope; + + public Guid Id { get; } = Guid.NewGuid(); + + /// + /// Configure registration behavior for a concrete configuration type. + /// By default, both the concrete type and any exposed interfaces are registered as Scoped. + /// + public ConcreteTypeSetup ConcreteType() where T : class => new(_capabilityScope); + + /// + /// Configure deserialization mapping for an interface type. + /// Use this when your configuration classes have interface-typed properties that need to be + /// deserialized from JSON/environment variables. Specify which concrete type to instantiate. + /// + public InterfaceTypeSetup Interface() where TInterface : class => new(_capabilityScope); + + public static ConfigManagerCapabilityScope GetCapabilityScopeFor(SetupBuilder builder) => builder._capabilityScope; +} diff --git a/src/Cocoar.Configuration/Core/ConfigJsonRepository.cs b/src/Cocoar.Configuration/Core/ConfigJsonRepository.cs index 9e610f6..030d841 100644 --- a/src/Cocoar.Configuration/Core/ConfigJsonRepository.cs +++ b/src/Cocoar.Configuration/Core/ConfigJsonRepository.cs @@ -1,132 +1,132 @@ -using System.Text.Json; -using Cocoar.Json.Mutable; - -namespace Cocoar.Configuration.Core; - -/// -/// Pure storage for configuration JSON data. -/// Manages committed and pending configurations with transaction-like semantics. -/// -internal sealed class ConfigJsonRepository -{ - private volatile Dictionary _configs = new(); - private volatile Dictionary? _pendingConfigurations; - - /// - /// Gets the current configuration dictionary (pending if in transaction, otherwise committed). - /// Thread-safe via volatile field access. - /// - public Dictionary CurrentConfigurations - { - get - { - // Capture volatile field once to avoid torn reads - var pending = _pendingConfigurations; - return pending ?? _configs; - } - } - - /// - /// Gets the committed configurations (ignores any pending transaction). - /// - public Dictionary CommittedConfigurations => _configs; - - /// - /// Indicates whether a transaction is in progress. - /// - public bool HasPendingTransaction => _pendingConfigurations != null; - - /// - /// Begins a new update transaction. - /// - public void BeginUpdate() - { - _pendingConfigurations = new(); - } - - /// - /// Updates a configuration within the current transaction. - /// - public void UpdateConfiguration(Type type, MutableJsonObject value) - { - if (_pendingConfigurations == null) - { - throw new InvalidOperationException("Must call BeginUpdate() before updating configurations"); - } - - _pendingConfigurations[type] = value; - } - - /// - /// Commits the transaction with the given final configurations. - /// - public void CommitUpdate(Dictionary finalConfigurations) - { - _configs = finalConfigurations; - _pendingConfigurations = null; - } - - /// - /// Rolls back the current transaction, discarding pending changes. - /// - public void RollbackUpdate() - { - _pendingConfigurations = null; - } - - /// - /// Finds a configuration registration by type. - /// - public Type? FindRegistration() => FindRegistration(typeof(T)); - - /// - /// Finds a configuration registration by type. - /// Thread-safe to avoid race conditions. - /// - public Type? FindRegistration(Type type) - { - var currentConfigs = CurrentConfigurations; - return currentConfigs.ContainsKey(type) ? type : null; - } - - /// - /// Tries to get a configuration value by type. - /// - public bool TryGetConfiguration(out MutableJsonObject? value) => TryGetConfiguration(typeof(T), out value); - - /// - /// Tries to get a configuration value by type. - /// Captures once to avoid reading different dictionaries - /// if a commit occurs between the existence check and the value read. - /// - public bool TryGetConfiguration(Type type, out MutableJsonObject? value) - { - var currentConfigs = CurrentConfigurations; - if (currentConfigs.ContainsKey(type) && currentConfigs.TryGetValue(type, out value)) - { - return true; - } - - value = default; - return false; - } - - /// - /// Gets a configuration as a JsonElement. - /// - public JsonElement? GetConfigurationAsJson(Type type) - { - var currentConfigs = CurrentConfigurations; - if (currentConfigs.TryGetValue(type, out var value)) - { - byte[] bytes; - lock (value) - { - bytes = MutableJsonDocument.ToUtf8Bytes(value); - } - using var doc = JsonDocument.Parse(bytes); - return doc.RootElement.Clone(); - } - return null; - } -} +using System.Text.Json; +using Cocoar.Json.Mutable; + +namespace Cocoar.Configuration.Core; + +/// +/// Pure storage for configuration JSON data. +/// Manages committed and pending configurations with transaction-like semantics. +/// +internal sealed class ConfigJsonRepository +{ + private volatile Dictionary _configs = new(); + private volatile Dictionary? _pendingConfigurations; + + /// + /// Gets the current configuration dictionary (pending if in transaction, otherwise committed). + /// Thread-safe via volatile field access. + /// + public Dictionary CurrentConfigurations + { + get + { + // Capture volatile field once to avoid torn reads + var pending = _pendingConfigurations; + return pending ?? _configs; + } + } + + /// + /// Gets the committed configurations (ignores any pending transaction). + /// + public Dictionary CommittedConfigurations => _configs; + + /// + /// Indicates whether a transaction is in progress. + /// + public bool HasPendingTransaction => _pendingConfigurations != null; + + /// + /// Begins a new update transaction. + /// + public void BeginUpdate() + { + _pendingConfigurations = new(); + } + + /// + /// Updates a configuration within the current transaction. + /// + public void UpdateConfiguration(Type type, MutableJsonObject value) + { + if (_pendingConfigurations == null) + { + throw new InvalidOperationException("Must call BeginUpdate() before updating configurations"); + } + + _pendingConfigurations[type] = value; + } + + /// + /// Commits the transaction with the given final configurations. + /// + public void CommitUpdate(Dictionary finalConfigurations) + { + _configs = finalConfigurations; + _pendingConfigurations = null; + } + + /// + /// Rolls back the current transaction, discarding pending changes. + /// + public void RollbackUpdate() + { + _pendingConfigurations = null; + } + + /// + /// Finds a configuration registration by type. + /// + public Type? FindRegistration() => FindRegistration(typeof(T)); + + /// + /// Finds a configuration registration by type. + /// Thread-safe to avoid race conditions. + /// + public Type? FindRegistration(Type type) + { + var currentConfigs = CurrentConfigurations; + return currentConfigs.ContainsKey(type) ? type : null; + } + + /// + /// Tries to get a configuration value by type. + /// + public bool TryGetConfiguration(out MutableJsonObject? value) => TryGetConfiguration(typeof(T), out value); + + /// + /// Tries to get a configuration value by type. + /// Captures once to avoid reading different dictionaries + /// if a commit occurs between the existence check and the value read. + /// + public bool TryGetConfiguration(Type type, out MutableJsonObject? value) + { + var currentConfigs = CurrentConfigurations; + if (currentConfigs.ContainsKey(type) && currentConfigs.TryGetValue(type, out value)) + { + return true; + } + + value = default; + return false; + } + + /// + /// Gets a configuration as a JsonElement. + /// + public JsonElement? GetConfigurationAsJson(Type type) + { + var currentConfigs = CurrentConfigurations; + if (currentConfigs.TryGetValue(type, out var value)) + { + byte[] bytes; + lock (value) + { + bytes = MutableJsonDocument.ToUtf8Bytes(value); + } + using var doc = JsonDocument.Parse(bytes); + return doc.RootElement.Clone(); + } + return null; + } +} diff --git a/src/Cocoar.Configuration/Core/ConfigManager.cs b/src/Cocoar.Configuration/Core/ConfigManager.cs index 766334a..22ff400 100644 --- a/src/Cocoar.Configuration/Core/ConfigManager.cs +++ b/src/Cocoar.Configuration/Core/ConfigManager.cs @@ -1,653 +1,653 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using Cocoar.Capabilities; -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Flags.Internal; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Testing; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Cocoar.Configuration.Providers.Abstractions; -using Cocoar.Configuration.Health; -using Cocoar.Configuration.Infrastructure; -using Cocoar.Configuration.Rules; -using Cocoar.Configuration.Reactive; -using Cocoar.Configuration.Utilities; -using Cocoar.Json.Mutable; - -namespace Cocoar.Configuration.Core; - -public sealed class ConfigManager : IConfigurationAccessor, ITenantConfigurationAccessor, IWritableStoreHost, IDisposable, IAsyncDisposable -{ - private List _setupDefinitions = null!; - private readonly ConfigManagerCapabilityScope _capabilityScope; - private ExposureRegistry _bindingRegistry = null!; - private ILogger _logger = NullLogger.Instance; - private int _debounceMilliseconds = 300; - private IFlagsHealthSource? _flagsHealthSource; - private Func? _providerFactory; - - // The single global pipeline IS "the bundle without a tenant suffix" (ADR-005 §2). Each initialized tenant - // gets its own TenantPipeline alongside it, layered on the shared global base. The members below forward to - // the global bundle so existing method bodies are unchanged and the global path stays byte-identical. - private TenantPipeline _global = null!; - - // Per-tenant pipelines, materialized on demand (ADR-005 §4). The Lazy> gate guarantees a tenant - // is built exactly once even under concurrent InitializeTenantAsync/EnsureTenantInitializedAsync calls. - private readonly ConcurrentDictionary>> _tenants = new(); - - private List _rules => _global.Rules; - private List _ruleManagers => _global.RuleManagers; - private ConfigurationAccessor _accessor => _global.Accessor; - private ReactiveConfigurationFactory _reactiveFactory => _global.ReactiveFactory; - private ConfigurationEngine _engine => _global.Engine; - private ConfigurationState _state => _global.State; - - private int _initialized; - - /// - /// Creates and initializes a new using the provided configuration. - /// - /// An action to configure the . - /// A fully initialized ready for use. - /// - /// - /// var manager = ConfigManager.Create(builder => builder - /// .UseConfiguration(rules => [ - /// rules.For<AppSettings>().FromFile("appsettings.json") - /// ])); - /// var settings = manager.GetConfig<AppSettings>(); - /// - /// - public static ConfigManager Create(Action configure) - { - ArgumentNullException.ThrowIfNull(configure); - var manager = new ConfigManager(); - var builder = new ConfigManagerBuilder(manager); - configure(builder); - return builder.Build(); - } - - /// - /// Creates a bare ConfigManager with only the CapabilityScope initialized. - /// Must be followed by and to be fully operational. - /// - internal ConfigManager() - { - _capabilityScope = new ConfigManagerCapabilityScope(this); - _capabilityScope.Owner.Compose(); - } - - /// - /// Configures the ConfigManager with rules, setup, and infrastructure. - /// Called by after the user lambda - /// has had a chance to configure satellite capabilities on the scope. - /// - internal void Configure( - ConfigRule[] configuredRules, - Func? setup = null, - ILogger? logger = null, - Func? providerFactory = null, - int debounceMilliseconds = 300, - IFlagsHealthSource? flagsHealthSource = null) - { - // Apply test configuration overrides if present - var rules = ApplyTestConfigurationOverrides(configuredRules).ToList(); - - // Apply test setup overrides if present - var effectiveSetup = ApplyTestSetupOverrides(setup); - _setupDefinitions = effectiveSetup?.Invoke(new SetupBuilder(_capabilityScope)).Select(s => s.Build()).ToList() ?? new List(); - - _logger = logger ?? NullLogger.Instance; - _debounceMilliseconds = debounceMilliseconds; - - // Captured so tenant pipelines can be built later with the same health source / provider factory. - _flagsHealthSource = flagsHealthSource; - _providerFactory = providerFactory; - - // ExposureRegistry is SHARED (frozen) across the global and all tenant pipelines. - _bindingRegistry = new ExposureRegistry(_setupDefinitions, _logger, _capabilityScope); - - // Build the global pipeline (rule-suffix = none). It owns its own state/engine/accessor/reactive/rules - // and borrows the shared scope + binding registry. The global pipeline's recompute accessor is `this`. - _global = new TenantPipeline( - rules, - _capabilityScope, - _bindingRegistry, - _logger, - _debounceMilliseconds, - flagsHealthSource, - providerFactory, - reactiveOwner: this); - } - - internal ConfigManager(Func rules, Func? setup = null, ILogger? logger = null, Func? providerFactory = null, int debounceMilliseconds = 300) - : this() - { - var rulesBuilder = new RulesBuilder(); - var configuredRules = rules(rulesBuilder); - Configure(configuredRules, setup, logger, providerFactory, debounceMilliseconds); - } - - internal ConfigManager(IEnumerable rules, Func? setup = null, ILogger? logger = null, Func? providerFactory = null, int debounceMilliseconds = 300) - : this() - { - Configure(rules.ToArray(), setup, logger, providerFactory, debounceMilliseconds); - } - - public IReadOnlyList Rules => _rules.AsReadOnly(); - internal IReadOnlyList SetupDefinitions => _setupDefinitions.AsReadOnly(); - - internal ConfigManagerCapabilityScope CapabilityScope => _capabilityScope; - - /// - /// Set by UseFeatureFlags. Null when feature flags have not been configured. - /// - internal FlagsSetupData? FlagsSetup { get; set; } - - /// - /// Set by UseEntitlements. Null when entitlements have not been configured. - /// - internal EntitlementsSetupData? EntitlementsSetup { get; set; } - - /// - /// The index of the first service-backed (Layer-2, ADR-006) rule in the global rule list, or -1 when - /// no service-backed rules are configured. The DI activation recompute restores the prefix below this index - /// (Layer 1 stays stable) and re-runs the suffix once the container's is set. - /// - internal int ServiceBackedLayerStartIndex { get; set; } = -1; - - /// - /// Opaque carrier for the DI package's ServiceProviderHolder (kept as so the - /// No-DI core never names a DI type). Set by UseServiceBackedConfiguration; read back by the DI - /// package after build to register the holder singleton and wire the activation hosted service. - /// - internal object? ServiceBackedHolder { get; set; } - - internal ConfigManager Initialize() - { - if (_initialized != 0) - { - return this; - } - - if (Interlocked.CompareExchange(ref _initialized, 1, 0) == 0) - { - _capabilityScope.Owner.TryGetComposer(out var composer); - composer?.Build(); - _capabilityScope.Owner.GetComposition()?.UsingEach(c => c.Apply()); - - // The global pipeline's recompute accessor is `this` — byte-identical to before. - _global.Initialize(this, ScheduleRecompute); - } - return this; - } - - internal async Task InitializeAsync(CancellationToken cancellationToken = default) - { - if (_initialized != 0) - return this; - - if (Interlocked.CompareExchange(ref _initialized, 1, 0) == 0) - { - _capabilityScope.Owner.TryGetComposer(out var composer); - composer?.Build(); - _capabilityScope.Owner.GetComposition()?.UsingEach(c => c.Apply()); - - await _global.InitializeAsync(this, ScheduleRecompute, cancellationToken).ConfigureAwait(false); - } - return this; - } - - /// - /// Creates and initializes a new asynchronously. - /// Prefer this over in console apps or any context where - /// blocking the calling thread during provider I/O is undesirable. - /// - /// An action to configure the . - /// Token to cancel the initialization. - public static async Task CreateAsync( - Action configure, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(configure); - var manager = new ConfigManager(); - var builder = new ConfigManagerBuilder(manager); - configure(builder); - return await builder.BuildAsync(cancellationToken).ConfigureAwait(false); - } - - /// - /// Gets a configuration instance of the specified type from the current snapshot. - /// - /// The configuration type to retrieve. Must be a class. - /// The configuration instance, or null if not found. - /// Thrown if has not been called. - public T? GetConfig() where T : class - { - if (_initialized == 0) throw new InvalidOperationException("ConfigManager has not been initialized. Call Configure() first."); - return _accessor.GetConfig(); - } - - /// - /// Attempts to get a configuration instance without throwing. - /// - /// The configuration type to retrieve. Must be a class. - /// The configuration instance if found; otherwise null. - /// True if the configuration exists; false otherwise. - /// Thrown if has not been called. - public bool TryGetConfig(out T? value) where T : class - { - if (_initialized == 0) throw new InvalidOperationException("ConfigManager has not been initialized. Call Configure() first."); - return _accessor.TryGetConfig(out value); - } - -#pragma warning disable CS0618 // Type or member is obsolete - public T GetRequiredConfig() => _accessor.GetRequiredConfig(); -#pragma warning restore CS0618 - - /// - public object GetConfig(Type type) => _accessor.GetConfig(type); - - /// - public bool TryGetConfig(Type type, out object? value) => _accessor.TryGetConfig(type, out value); - -#pragma warning disable CS0618 // Type or member is obsolete - /// - public object GetRequiredConfig(Type type) => _accessor.GetRequiredConfig(type); -#pragma warning restore CS0618 - - /// - /// Gets the current configuration snapshot for the specified type serialized as a . - /// Returns null if no rule is registered for the type. - /// - /// The configuration type to retrieve. - public JsonElement? GetConfigAsJson(Type type) => _accessor.GetConfigAsJson(type); - - /// - /// Computes the merged "base" JSON for from all rule layers BELOW the - /// overlay layer identified by — i.e. the value the type would have - /// without that overlay. Used by WritableStore to align override key casing against lower layers and to - /// report base-vs-effective provenance. - /// - /// Thread-safety: this reads each manager's LastJsonContribution without taking the recompute - /// semaphore — deliberately, because reactive notifications are published inside that semaphore, - /// so gating here would deadlock a subscriber that writes back. It is safe because a contribution is - /// written once per recompute and then replaced wholesale (never mutated in place), and the reference - /// read is atomic: a concurrent recompute can at worst make this observe a one-generation-stale but - /// internally-consistent contribution, which self-heals on the next read. - /// - /// - internal MutableJsonObject BuildBaseJson(Type configType, Func isExcludedLayer) - { - ArgumentNullException.ThrowIfNull(configType); - ArgumentNullException.ThrowIfNull(isExcludedLayer); - - var merged = new MutableJsonObject(); - foreach (var manager in _ruleManagers) - { - if (isExcludedLayer(manager)) - { - break; // the base is everything strictly below the overlay layer - } - - if (manager.TypeDefinition != configType) - { - continue; - } - - if (manager.LastJsonContribution is { } contribution) - { - MutableJsonMerge.Merge(merged, contribution, ConfigMergeOptions.CaseInsensitive); - } - } - - return merged; - } - - // IWritableStoreHost — lets the WritableStore adapter compute base/effective JSON against the global pipeline. - MutableJsonObject IWritableStoreHost.BuildBaseJson(Type configType, Func isExcludedLayer) - => BuildBaseJson(configType, isExcludedLayer); - - JsonElement? IWritableStoreHost.GetConfigAsJson(Type type) => GetConfigAsJson(type); - - /// - /// Gets a reactive wrapper for the specified configuration type. - /// The returned emits the current value immediately on subscribe - /// and then on every subsequent configuration change (replay-1 / BehaviorSubject semantics). - /// - /// The configuration type. Must be a class, interface (with ExposeAs), or ValueTuple. - /// A reactive configuration wrapper. - /// Thrown if has not been called. - public IReactiveConfig GetReactiveConfig() - { - if (_initialized == 0) throw new InvalidOperationException("ConfigManager has not been initialized. Call Configure() first."); - return _reactiveFactory.GetReactiveConfig(() => (T)GetConfig(typeof(T))); - } - - /// Current overall health status of the configuration system. - public HealthStatus HealthStatus - { - get - { - if (_initialized == 0) throw new InvalidOperationException("ConfigManager has not been initialized. Call Configure() first."); - return _state.HealthStatus; - } - } - - /// true when is . - public bool IsHealthy - { - get - { - if (_initialized == 0) throw new InvalidOperationException("ConfigManager has not been initialized. Call Configure() first."); - return _state.IsHealthy; - } - } - - /// Human-readable description of the current health state (e.g. "1 required rule(s) failed"). - internal string HealthDescription => _state.HealthDescription; - - internal void ScheduleRecompute(int startIndex) => - _engine.ScheduleRecompute(_ruleManagers, this, startIndex); - - internal Task? CurrentRecomputeTask => _engine.CurrentRecomputeTask; - - /// - /// Runs a recompute of the GLOBAL pipeline from directly to completion (under the - /// recompute semaphore), NOT via the cancel-on-reschedule scheduler. Used by the DI Layer-2 activation so a - /// concurrent provider-change signal cannot cancel activation before Layer 2 has committed (ADR-006 §7 readiness). - /// - internal Task RecomputeNowAsync(int startIndex, CancellationToken cancellationToken = default) => - _engine.RecomputeAndUpdateHealthAsync(_ruleManagers, this, startIndex, cancellationToken); - - /// - /// Runs the same direct recompute on every already-initialized tenant pipeline from . - /// Used on Layer-2 activation so tenants built before the container was published (their sp-gated rules were - /// skipped at init) pick up their service-backed values. Each tenant degrades independently. - /// - internal async Task RecomputeInitializedTenantsNowAsync(int startIndex, CancellationToken cancellationToken = default) - { - foreach (var lazy in _tenants.Values) - { - if (!lazy.IsValueCreated) - { - continue; - } - - var task = lazy.Value; - if (!task.IsCompletedSuccessfully) - { - continue; - } - - var pipeline = task.Result; - if (!pipeline.IsInitialized) - { - continue; - } - - try - { - // RecomputeAndUpdateHealthAsync swallows recompute failures; the per-tenant guard here additionally - // isolates the narrow dispose-race (a tenant removed mid-fan-out can surface ObjectDisposed / - // index races from its health update) so one removed/faulting tenant never blocks the others. - await pipeline.Engine.RecomputeAndUpdateHealthAsync( - pipeline.RuleManagers, pipeline.Accessor, startIndex, cancellationToken).ConfigureAwait(false); - } - catch - { - // Isolated: this tenant self-heals on its next provider change. - } - } - } - - // ===== Tenant lifecycle (ADR-005 §4/§5, ITenantConfigurationAccessor) ===== - - /// - public Task InitializeTenantAsync(string tenantId, CancellationToken cancellationToken = default) - => GetOrBuildTenantAsync(tenantId, cancellationToken); - - /// - public Task EnsureTenantInitializedAsync(string tenantId, CancellationToken cancellationToken = default) - => GetOrBuildTenantAsync(tenantId, cancellationToken); - - /// - public bool IsTenantInitialized(string tenantId) - { - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - return _tenants.TryGetValue(tenantId, out var lazy) - && lazy.IsValueCreated - && lazy.Value.IsCompletedSuccessfully - && lazy.Value.Result.IsInitialized; - } - - /// - public T? GetConfigForTenant(string tenantId) where T : class - => GetInitializedTenantOrThrow(tenantId).Accessor.GetConfig(); - - /// - public IReactiveConfig GetReactiveConfigForTenant(string tenantId) - { - var pipeline = GetInitializedTenantOrThrow(tenantId); - // Mirror the global GetReactiveConfig: the factory + ReactiveConfigManager are the tenant pipeline's - // own, and the value source reads the tenant accessor — so the reactive tracks THIS tenant's value. - return pipeline.ReactiveFactory.GetReactiveConfig(() => (T)pipeline.Accessor.GetConfig(typeof(T))); - } - - /// - public async Task RemoveTenantAsync(string tenantId, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - if (!_tenants.TryRemove(tenantId, out var lazy)) - { - return; - } - - TenantPipeline pipeline; - try - { - // Always resolve the removed entry before disposing — even if its build had not been triggered yet. - // Accessing lazy.Value forces the (single) build to run if a concurrent initializer hadn't started it, - // so a pipeline that gets built after this removal cannot be left orphaned and never disposed. - pipeline = await lazy.Value.ConfigureAwait(false); - } - catch - { - return; // init faulted/cancelled — nothing was published, nothing to dispose - } - - await pipeline.DisposeAsync().ConfigureAwait(false); - } - - private Task GetOrBuildTenantAsync(string tenantId, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - if (_initialized == 0) - { - throw new InvalidOperationException( - "ConfigManager has not been initialized. Tenants can only be initialized after the global pipeline is ready."); - } - - var lazy = _tenants.GetOrAdd( - tenantId, - id => new Lazy>(() => BuildTenantAsync(id, cancellationToken))); - var task = lazy.Value; - - // If a PREVIOUS build for this tenant completed-faulted, evict that exact entry (identity-checked, so we - // never drop a healthy retry inserted under the same key) and rebuild once. An in-flight build is returned - // as-is. Bounded to a single rebuild so an always-failing build can't spin. - if (task.IsCompleted && (task.IsFaulted || task.IsCanceled)) - { - _tenants.TryRemove(new KeyValuePair>>(tenantId, lazy)); - lazy = _tenants.GetOrAdd( - tenantId, - id => new Lazy>(() => BuildTenantAsync(id, cancellationToken))); - task = lazy.Value; - } - - return task; - } - - private async Task BuildTenantAsync(string tenantId, CancellationToken cancellationToken) - { - // Same flat rule list as the global pipeline; the tenant pipeline owns its own state/engine/accessor/ - // reactive/rule-managers and borrows the shared (frozen) capability scope + binding registry. - var pipeline = new TenantPipeline( - _global.Rules, - _capabilityScope, - _bindingRegistry, - _logger, - _debounceMilliseconds, - _flagsHealthSource, - _providerFactory, - reactiveOwner: this, - tenantId: tenantId); - - try - { - // Recompute uses the pipeline's OWN accessor (Tenant = id): .TenantScoped() rules run and tenant-varying - // factories interpolate the id. Each tenant runs the FULL flat rule list with its own provider instances - // and own change subscriptions. This is the deliberate v1 model (ADR-005 §6): it is correct AND gives - // automatic fan-out — a live global base source (file/observable/http) propagates to every initialized - // tenant through that tenant's own subscription, with no cross-pipeline coordinator and none of the - // lock-ordering hazards a shared seed-from-global path would carry. The trade-off is linear resource use - // (N tenants re-run the base); the seed-from-global sharing optimization is a documented, deferred TODO. - await pipeline.InitializeAsync( - pipeline.Accessor, - startIndex => pipeline.Engine.ScheduleRecompute(pipeline.RuleManagers, pipeline.Accessor, startIndex), - cancellationToken).ConfigureAwait(false); - - return pipeline; - } - catch - { - // Dispose the partially-built pipeline so a failed init leaks nothing. The faulted task stays cached - // until GetOrBuildTenantAsync evicts it (identity-checked) on the next call — no self-eviction here, - // which would risk an ABA race against a healthy retry inserted under the same key. - await pipeline.DisposeAsync().ConfigureAwait(false); - throw; - } - } - - private TenantPipeline GetInitializedTenantOrThrow(string tenantId) - { - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - if (_tenants.TryGetValue(tenantId, out var lazy) && lazy.IsValueCreated && lazy.Value.IsCompletedSuccessfully) - { - return lazy.Value.Result; - } - - throw new InvalidOperationException( - $"Tenant '{tenantId}' is not initialized. Call InitializeTenantAsync/EnsureTenantInitializedAsync first."); - } - - /// - /// The initialized tenant pipeline, for in-assembly facades (e.g. per-tenant WritableStore) that need the - /// tenant's own rule managers/host. Throws if the tenant is not initialized. - /// - internal TenantPipeline GetInitializedTenantPipeline(string tenantId) => GetInitializedTenantOrThrow(tenantId); - - /// - /// Disposes the configuration manager and all associated resources. - /// After disposal, configuration methods will throw . - /// - public void Dispose() - { - foreach (var lazy in _tenants.Values) - { - if (lazy.IsValueCreated && lazy.Value.IsCompletedSuccessfully) - { - Safety.DisposeQuietly(lazy.Value.Result); - } - } - _tenants.Clear(); - - _global?.Dispose(); - Interlocked.Exchange(ref _initialized, 0); - } - - /// - /// Asynchronously disposes the configuration manager, awaiting any in-flight recompute to finish. - /// Preferred over in ASP.NET Core and other async hosts, which call - /// on singletons at shutdown. - /// - public async ValueTask DisposeAsync() - { - foreach (var lazy in _tenants.Values) - { - if (!lazy.IsValueCreated) - { - continue; - } - - TenantPipeline? pipeline = null; - try - { - pipeline = await lazy.Value.ConfigureAwait(false); - } - catch - { - // faulted/cancelled init — nothing to dispose - } - - if (pipeline != null) - { - await pipeline.DisposeAsync().ConfigureAwait(false); - } - } - _tenants.Clear(); - - if (_global != null) await _global.DisposeAsync().ConfigureAwait(false); - Interlocked.Exchange(ref _initialized, 0); - } - - /// - /// Applies test configuration overrides from AsyncLocal context if present. - /// Supports both Replace (skip all configured rules) and Append (merge test rules at end) modes. - /// When is null no rules override is applied. - /// - private static ConfigRule[] ApplyTestConfigurationOverrides(ConfigRule[] configuredRules) - { - var testContext = CocoarTestConfiguration.Current; - if (testContext?.Rules == null || testContext.ConfigurationMode == null) - return configuredRules; - - var testRulesBuilder = new RulesBuilder(); - var testRules = testContext.Rules(testRulesBuilder); - - return testContext.ConfigurationMode switch - { - TestConfigurationMode.Replace => testRules, - TestConfigurationMode.Append => configuredRules.Concat(testRules).ToArray(), - _ => configuredRules - }; - } - - /// - /// Applies test setup overrides from AsyncLocal context if present. - /// Test setup is always merged (appended) to configured setup, allowing test-specific - /// setup options like AllowPlaintext() to override configured settings. - /// - private static Func? ApplyTestSetupOverrides( - Func? configuredSetup) - { - var testContext = CocoarTestConfiguration.Current; - if (testContext?.Setup == null) - { - return configuredSetup; - } - - // Merge: configured setup first, then test setup (last-write-wins for capabilities) - return builder => - { - var configuredDefs = configuredSetup?.Invoke(builder) ?? []; - var testDefs = testContext.Setup(builder); -#if NET9_0_OR_GREATER - return [.. configuredDefs, .. testDefs]; -#else - return configuredDefs.Concat(testDefs).ToArray(); -#endif - }; - } -} +using System.Collections.Concurrent; +using System.Text.Json; +using Cocoar.Capabilities; +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Flags.Internal; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Testing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.Configuration.Health; +using Cocoar.Configuration.Infrastructure; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Reactive; +using Cocoar.Configuration.Utilities; +using Cocoar.Json.Mutable; + +namespace Cocoar.Configuration.Core; + +public sealed class ConfigManager : IConfigurationAccessor, ITenantConfigurationAccessor, IWritableStoreHost, IDisposable, IAsyncDisposable +{ + private List _setupDefinitions = null!; + private readonly ConfigManagerCapabilityScope _capabilityScope; + private ExposureRegistry _bindingRegistry = null!; + private ILogger _logger = NullLogger.Instance; + private int _debounceMilliseconds = 300; + private IFlagsHealthSource? _flagsHealthSource; + private Func? _providerFactory; + + // The single global pipeline IS "the bundle without a tenant suffix" (ADR-005 §2). Each initialized tenant + // gets its own TenantPipeline alongside it, layered on the shared global base. The members below forward to + // the global bundle so existing method bodies are unchanged and the global path stays byte-identical. + private TenantPipeline _global = null!; + + // Per-tenant pipelines, materialized on demand (ADR-005 §4). The Lazy> gate guarantees a tenant + // is built exactly once even under concurrent InitializeTenantAsync/EnsureTenantInitializedAsync calls. + private readonly ConcurrentDictionary>> _tenants = new(); + + private List _rules => _global.Rules; + private List _ruleManagers => _global.RuleManagers; + private ConfigurationAccessor _accessor => _global.Accessor; + private ReactiveConfigurationFactory _reactiveFactory => _global.ReactiveFactory; + private ConfigurationEngine _engine => _global.Engine; + private ConfigurationState _state => _global.State; + + private int _initialized; + + /// + /// Creates and initializes a new using the provided configuration. + /// + /// An action to configure the . + /// A fully initialized ready for use. + /// + /// + /// var manager = ConfigManager.Create(builder => builder + /// .UseConfiguration(rules => [ + /// rules.For<AppSettings>().FromFile("appsettings.json") + /// ])); + /// var settings = manager.GetConfig<AppSettings>(); + /// + /// + public static ConfigManager Create(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + var manager = new ConfigManager(); + var builder = new ConfigManagerBuilder(manager); + configure(builder); + return builder.Build(); + } + + /// + /// Creates a bare ConfigManager with only the CapabilityScope initialized. + /// Must be followed by and to be fully operational. + /// + internal ConfigManager() + { + _capabilityScope = new ConfigManagerCapabilityScope(this); + _capabilityScope.Owner.Compose(); + } + + /// + /// Configures the ConfigManager with rules, setup, and infrastructure. + /// Called by after the user lambda + /// has had a chance to configure satellite capabilities on the scope. + /// + internal void Configure( + ConfigRule[] configuredRules, + Func? setup = null, + ILogger? logger = null, + Func? providerFactory = null, + int debounceMilliseconds = 300, + IFlagsHealthSource? flagsHealthSource = null) + { + // Apply test configuration overrides if present + var rules = ApplyTestConfigurationOverrides(configuredRules).ToList(); + + // Apply test setup overrides if present + var effectiveSetup = ApplyTestSetupOverrides(setup); + _setupDefinitions = effectiveSetup?.Invoke(new SetupBuilder(_capabilityScope)).Select(s => s.Build()).ToList() ?? new List(); + + _logger = logger ?? NullLogger.Instance; + _debounceMilliseconds = debounceMilliseconds; + + // Captured so tenant pipelines can be built later with the same health source / provider factory. + _flagsHealthSource = flagsHealthSource; + _providerFactory = providerFactory; + + // ExposureRegistry is SHARED (frozen) across the global and all tenant pipelines. + _bindingRegistry = new ExposureRegistry(_setupDefinitions, _logger, _capabilityScope); + + // Build the global pipeline (rule-suffix = none). It owns its own state/engine/accessor/reactive/rules + // and borrows the shared scope + binding registry. The global pipeline's recompute accessor is `this`. + _global = new TenantPipeline( + rules, + _capabilityScope, + _bindingRegistry, + _logger, + _debounceMilliseconds, + flagsHealthSource, + providerFactory, + reactiveOwner: this); + } + + internal ConfigManager(Func rules, Func? setup = null, ILogger? logger = null, Func? providerFactory = null, int debounceMilliseconds = 300) + : this() + { + var rulesBuilder = new RulesBuilder(); + var configuredRules = rules(rulesBuilder); + Configure(configuredRules, setup, logger, providerFactory, debounceMilliseconds); + } + + internal ConfigManager(IEnumerable rules, Func? setup = null, ILogger? logger = null, Func? providerFactory = null, int debounceMilliseconds = 300) + : this() + { + Configure(rules.ToArray(), setup, logger, providerFactory, debounceMilliseconds); + } + + public IReadOnlyList Rules => _rules.AsReadOnly(); + internal IReadOnlyList SetupDefinitions => _setupDefinitions.AsReadOnly(); + + internal ConfigManagerCapabilityScope CapabilityScope => _capabilityScope; + + /// + /// Set by UseFeatureFlags. Null when feature flags have not been configured. + /// + internal FlagsSetupData? FlagsSetup { get; set; } + + /// + /// Set by UseEntitlements. Null when entitlements have not been configured. + /// + internal EntitlementsSetupData? EntitlementsSetup { get; set; } + + /// + /// The index of the first service-backed (Layer-2, ADR-006) rule in the global rule list, or -1 when + /// no service-backed rules are configured. The DI activation recompute restores the prefix below this index + /// (Layer 1 stays stable) and re-runs the suffix once the container's is set. + /// + internal int ServiceBackedLayerStartIndex { get; set; } = -1; + + /// + /// Opaque carrier for the DI package's ServiceProviderHolder (kept as so the + /// No-DI core never names a DI type). Set by UseServiceBackedConfiguration; read back by the DI + /// package after build to register the holder singleton and wire the activation hosted service. + /// + internal object? ServiceBackedHolder { get; set; } + + internal ConfigManager Initialize() + { + if (_initialized != 0) + { + return this; + } + + if (Interlocked.CompareExchange(ref _initialized, 1, 0) == 0) + { + _capabilityScope.Owner.TryGetComposer(out var composer); + composer?.Build(); + _capabilityScope.Owner.GetComposition()?.UsingEach(c => c.Apply()); + + // The global pipeline's recompute accessor is `this` — byte-identical to before. + _global.Initialize(this, ScheduleRecompute); + } + return this; + } + + internal async Task InitializeAsync(CancellationToken cancellationToken = default) + { + if (_initialized != 0) + return this; + + if (Interlocked.CompareExchange(ref _initialized, 1, 0) == 0) + { + _capabilityScope.Owner.TryGetComposer(out var composer); + composer?.Build(); + _capabilityScope.Owner.GetComposition()?.UsingEach(c => c.Apply()); + + await _global.InitializeAsync(this, ScheduleRecompute, cancellationToken).ConfigureAwait(false); + } + return this; + } + + /// + /// Creates and initializes a new asynchronously. + /// Prefer this over in console apps or any context where + /// blocking the calling thread during provider I/O is undesirable. + /// + /// An action to configure the . + /// Token to cancel the initialization. + public static async Task CreateAsync( + Action configure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(configure); + var manager = new ConfigManager(); + var builder = new ConfigManagerBuilder(manager); + configure(builder); + return await builder.BuildAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets a configuration instance of the specified type from the current snapshot. + /// + /// The configuration type to retrieve. Must be a class. + /// The configuration instance, or null if not found. + /// Thrown if has not been called. + public T? GetConfig() where T : class + { + if (_initialized == 0) throw new InvalidOperationException("ConfigManager has not been initialized. Call Configure() first."); + return _accessor.GetConfig(); + } + + /// + /// Attempts to get a configuration instance without throwing. + /// + /// The configuration type to retrieve. Must be a class. + /// The configuration instance if found; otherwise null. + /// True if the configuration exists; false otherwise. + /// Thrown if has not been called. + public bool TryGetConfig(out T? value) where T : class + { + if (_initialized == 0) throw new InvalidOperationException("ConfigManager has not been initialized. Call Configure() first."); + return _accessor.TryGetConfig(out value); + } + +#pragma warning disable CS0618 // Type or member is obsolete + public T GetRequiredConfig() => _accessor.GetRequiredConfig(); +#pragma warning restore CS0618 + + /// + public object GetConfig(Type type) => _accessor.GetConfig(type); + + /// + public bool TryGetConfig(Type type, out object? value) => _accessor.TryGetConfig(type, out value); + +#pragma warning disable CS0618 // Type or member is obsolete + /// + public object GetRequiredConfig(Type type) => _accessor.GetRequiredConfig(type); +#pragma warning restore CS0618 + + /// + /// Gets the current configuration snapshot for the specified type serialized as a . + /// Returns null if no rule is registered for the type. + /// + /// The configuration type to retrieve. + public JsonElement? GetConfigAsJson(Type type) => _accessor.GetConfigAsJson(type); + + /// + /// Computes the merged "base" JSON for from all rule layers BELOW the + /// overlay layer identified by — i.e. the value the type would have + /// without that overlay. Used by WritableStore to align override key casing against lower layers and to + /// report base-vs-effective provenance. + /// + /// Thread-safety: this reads each manager's LastJsonContribution without taking the recompute + /// semaphore — deliberately, because reactive notifications are published inside that semaphore, + /// so gating here would deadlock a subscriber that writes back. It is safe because a contribution is + /// written once per recompute and then replaced wholesale (never mutated in place), and the reference + /// read is atomic: a concurrent recompute can at worst make this observe a one-generation-stale but + /// internally-consistent contribution, which self-heals on the next read. + /// + /// + internal MutableJsonObject BuildBaseJson(Type configType, Func isExcludedLayer) + { + ArgumentNullException.ThrowIfNull(configType); + ArgumentNullException.ThrowIfNull(isExcludedLayer); + + var merged = new MutableJsonObject(); + foreach (var manager in _ruleManagers) + { + if (isExcludedLayer(manager)) + { + break; // the base is everything strictly below the overlay layer + } + + if (manager.TypeDefinition != configType) + { + continue; + } + + if (manager.LastJsonContribution is { } contribution) + { + MutableJsonMerge.Merge(merged, contribution, ConfigMergeOptions.CaseInsensitive); + } + } + + return merged; + } + + // IWritableStoreHost — lets the WritableStore adapter compute base/effective JSON against the global pipeline. + MutableJsonObject IWritableStoreHost.BuildBaseJson(Type configType, Func isExcludedLayer) + => BuildBaseJson(configType, isExcludedLayer); + + JsonElement? IWritableStoreHost.GetConfigAsJson(Type type) => GetConfigAsJson(type); + + /// + /// Gets a reactive wrapper for the specified configuration type. + /// The returned emits the current value immediately on subscribe + /// and then on every subsequent configuration change (replay-1 / BehaviorSubject semantics). + /// + /// The configuration type. Must be a class, interface (with ExposeAs), or ValueTuple. + /// A reactive configuration wrapper. + /// Thrown if has not been called. + public IReactiveConfig GetReactiveConfig() + { + if (_initialized == 0) throw new InvalidOperationException("ConfigManager has not been initialized. Call Configure() first."); + return _reactiveFactory.GetReactiveConfig(() => (T)GetConfig(typeof(T))); + } + + /// Current overall health status of the configuration system. + public HealthStatus HealthStatus + { + get + { + if (_initialized == 0) throw new InvalidOperationException("ConfigManager has not been initialized. Call Configure() first."); + return _state.HealthStatus; + } + } + + /// true when is . + public bool IsHealthy + { + get + { + if (_initialized == 0) throw new InvalidOperationException("ConfigManager has not been initialized. Call Configure() first."); + return _state.IsHealthy; + } + } + + /// Human-readable description of the current health state (e.g. "1 required rule(s) failed"). + internal string HealthDescription => _state.HealthDescription; + + internal void ScheduleRecompute(int startIndex) => + _engine.ScheduleRecompute(_ruleManagers, this, startIndex); + + internal Task? CurrentRecomputeTask => _engine.CurrentRecomputeTask; + + /// + /// Runs a recompute of the GLOBAL pipeline from directly to completion (under the + /// recompute semaphore), NOT via the cancel-on-reschedule scheduler. Used by the DI Layer-2 activation so a + /// concurrent provider-change signal cannot cancel activation before Layer 2 has committed (ADR-006 §7 readiness). + /// + internal Task RecomputeNowAsync(int startIndex, CancellationToken cancellationToken = default) => + _engine.RecomputeAndUpdateHealthAsync(_ruleManagers, this, startIndex, cancellationToken); + + /// + /// Runs the same direct recompute on every already-initialized tenant pipeline from . + /// Used on Layer-2 activation so tenants built before the container was published (their sp-gated rules were + /// skipped at init) pick up their service-backed values. Each tenant degrades independently. + /// + internal async Task RecomputeInitializedTenantsNowAsync(int startIndex, CancellationToken cancellationToken = default) + { + foreach (var lazy in _tenants.Values) + { + if (!lazy.IsValueCreated) + { + continue; + } + + var task = lazy.Value; + if (!task.IsCompletedSuccessfully) + { + continue; + } + + var pipeline = task.Result; + if (!pipeline.IsInitialized) + { + continue; + } + + try + { + // RecomputeAndUpdateHealthAsync swallows recompute failures; the per-tenant guard here additionally + // isolates the narrow dispose-race (a tenant removed mid-fan-out can surface ObjectDisposed / + // index races from its health update) so one removed/faulting tenant never blocks the others. + await pipeline.Engine.RecomputeAndUpdateHealthAsync( + pipeline.RuleManagers, pipeline.Accessor, startIndex, cancellationToken).ConfigureAwait(false); + } + catch + { + // Isolated: this tenant self-heals on its next provider change. + } + } + } + + // ===== Tenant lifecycle (ADR-005 §4/§5, ITenantConfigurationAccessor) ===== + + /// + public Task InitializeTenantAsync(string tenantId, CancellationToken cancellationToken = default) + => GetOrBuildTenantAsync(tenantId, cancellationToken); + + /// + public Task EnsureTenantInitializedAsync(string tenantId, CancellationToken cancellationToken = default) + => GetOrBuildTenantAsync(tenantId, cancellationToken); + + /// + public bool IsTenantInitialized(string tenantId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + return _tenants.TryGetValue(tenantId, out var lazy) + && lazy.IsValueCreated + && lazy.Value.IsCompletedSuccessfully + && lazy.Value.Result.IsInitialized; + } + + /// + public T? GetConfigForTenant(string tenantId) where T : class + => GetInitializedTenantOrThrow(tenantId).Accessor.GetConfig(); + + /// + public IReactiveConfig GetReactiveConfigForTenant(string tenantId) + { + var pipeline = GetInitializedTenantOrThrow(tenantId); + // Mirror the global GetReactiveConfig: the factory + ReactiveConfigManager are the tenant pipeline's + // own, and the value source reads the tenant accessor — so the reactive tracks THIS tenant's value. + return pipeline.ReactiveFactory.GetReactiveConfig(() => (T)pipeline.Accessor.GetConfig(typeof(T))); + } + + /// + public async Task RemoveTenantAsync(string tenantId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + if (!_tenants.TryRemove(tenantId, out var lazy)) + { + return; + } + + TenantPipeline pipeline; + try + { + // Always resolve the removed entry before disposing — even if its build had not been triggered yet. + // Accessing lazy.Value forces the (single) build to run if a concurrent initializer hadn't started it, + // so a pipeline that gets built after this removal cannot be left orphaned and never disposed. + pipeline = await lazy.Value.ConfigureAwait(false); + } + catch + { + return; // init faulted/cancelled — nothing was published, nothing to dispose + } + + await pipeline.DisposeAsync().ConfigureAwait(false); + } + + private Task GetOrBuildTenantAsync(string tenantId, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + if (_initialized == 0) + { + throw new InvalidOperationException( + "ConfigManager has not been initialized. Tenants can only be initialized after the global pipeline is ready."); + } + + var lazy = _tenants.GetOrAdd( + tenantId, + id => new Lazy>(() => BuildTenantAsync(id, cancellationToken))); + var task = lazy.Value; + + // If a PREVIOUS build for this tenant completed-faulted, evict that exact entry (identity-checked, so we + // never drop a healthy retry inserted under the same key) and rebuild once. An in-flight build is returned + // as-is. Bounded to a single rebuild so an always-failing build can't spin. + if (task.IsCompleted && (task.IsFaulted || task.IsCanceled)) + { + _tenants.TryRemove(new KeyValuePair>>(tenantId, lazy)); + lazy = _tenants.GetOrAdd( + tenantId, + id => new Lazy>(() => BuildTenantAsync(id, cancellationToken))); + task = lazy.Value; + } + + return task; + } + + private async Task BuildTenantAsync(string tenantId, CancellationToken cancellationToken) + { + // Same flat rule list as the global pipeline; the tenant pipeline owns its own state/engine/accessor/ + // reactive/rule-managers and borrows the shared (frozen) capability scope + binding registry. + var pipeline = new TenantPipeline( + _global.Rules, + _capabilityScope, + _bindingRegistry, + _logger, + _debounceMilliseconds, + _flagsHealthSource, + _providerFactory, + reactiveOwner: this, + tenantId: tenantId); + + try + { + // Recompute uses the pipeline's OWN accessor (Tenant = id): .TenantScoped() rules run and tenant-varying + // factories interpolate the id. Each tenant runs the FULL flat rule list with its own provider instances + // and own change subscriptions. This is the deliberate v1 model (ADR-005 §6): it is correct AND gives + // automatic fan-out — a live global base source (file/observable/http) propagates to every initialized + // tenant through that tenant's own subscription, with no cross-pipeline coordinator and none of the + // lock-ordering hazards a shared seed-from-global path would carry. The trade-off is linear resource use + // (N tenants re-run the base); the seed-from-global sharing optimization is a documented, deferred TODO. + await pipeline.InitializeAsync( + pipeline.Accessor, + startIndex => pipeline.Engine.ScheduleRecompute(pipeline.RuleManagers, pipeline.Accessor, startIndex), + cancellationToken).ConfigureAwait(false); + + return pipeline; + } + catch + { + // Dispose the partially-built pipeline so a failed init leaks nothing. The faulted task stays cached + // until GetOrBuildTenantAsync evicts it (identity-checked) on the next call — no self-eviction here, + // which would risk an ABA race against a healthy retry inserted under the same key. + await pipeline.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private TenantPipeline GetInitializedTenantOrThrow(string tenantId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + if (_tenants.TryGetValue(tenantId, out var lazy) && lazy.IsValueCreated && lazy.Value.IsCompletedSuccessfully) + { + return lazy.Value.Result; + } + + throw new InvalidOperationException( + $"Tenant '{tenantId}' is not initialized. Call InitializeTenantAsync/EnsureTenantInitializedAsync first."); + } + + /// + /// The initialized tenant pipeline, for in-assembly facades (e.g. per-tenant WritableStore) that need the + /// tenant's own rule managers/host. Throws if the tenant is not initialized. + /// + internal TenantPipeline GetInitializedTenantPipeline(string tenantId) => GetInitializedTenantOrThrow(tenantId); + + /// + /// Disposes the configuration manager and all associated resources. + /// After disposal, configuration methods will throw . + /// + public void Dispose() + { + foreach (var lazy in _tenants.Values) + { + if (lazy.IsValueCreated && lazy.Value.IsCompletedSuccessfully) + { + Safety.DisposeQuietly(lazy.Value.Result); + } + } + _tenants.Clear(); + + _global?.Dispose(); + Interlocked.Exchange(ref _initialized, 0); + } + + /// + /// Asynchronously disposes the configuration manager, awaiting any in-flight recompute to finish. + /// Preferred over in ASP.NET Core and other async hosts, which call + /// on singletons at shutdown. + /// + public async ValueTask DisposeAsync() + { + foreach (var lazy in _tenants.Values) + { + if (!lazy.IsValueCreated) + { + continue; + } + + TenantPipeline? pipeline = null; + try + { + pipeline = await lazy.Value.ConfigureAwait(false); + } + catch + { + // faulted/cancelled init — nothing to dispose + } + + if (pipeline != null) + { + await pipeline.DisposeAsync().ConfigureAwait(false); + } + } + _tenants.Clear(); + + if (_global != null) await _global.DisposeAsync().ConfigureAwait(false); + Interlocked.Exchange(ref _initialized, 0); + } + + /// + /// Applies test configuration overrides from AsyncLocal context if present. + /// Supports both Replace (skip all configured rules) and Append (merge test rules at end) modes. + /// When is null no rules override is applied. + /// + private static ConfigRule[] ApplyTestConfigurationOverrides(ConfigRule[] configuredRules) + { + var testContext = CocoarTestConfiguration.Current; + if (testContext?.Rules == null || testContext.ConfigurationMode == null) + return configuredRules; + + var testRulesBuilder = new RulesBuilder(); + var testRules = testContext.Rules(testRulesBuilder); + + return testContext.ConfigurationMode switch + { + TestConfigurationMode.Replace => testRules, + TestConfigurationMode.Append => configuredRules.Concat(testRules).ToArray(), + _ => configuredRules + }; + } + + /// + /// Applies test setup overrides from AsyncLocal context if present. + /// Test setup is always merged (appended) to configured setup, allowing test-specific + /// setup options like AllowPlaintext() to override configured settings. + /// + private static Func? ApplyTestSetupOverrides( + Func? configuredSetup) + { + var testContext = CocoarTestConfiguration.Current; + if (testContext?.Setup == null) + { + return configuredSetup; + } + + // Merge: configured setup first, then test setup (last-write-wins for capabilities) + return builder => + { + var configuredDefs = configuredSetup?.Invoke(builder) ?? []; + var testDefs = testContext.Setup(builder); +#if NET9_0_OR_GREATER + return [.. configuredDefs, .. testDefs]; +#else + return configuredDefs.Concat(testDefs).ToArray(); +#endif + }; + } +} diff --git a/src/Cocoar.Configuration/Core/ConfigManagerBuilder.cs b/src/Cocoar.Configuration/Core/ConfigManagerBuilder.cs index db30097..8660ec5 100644 --- a/src/Cocoar.Configuration/Core/ConfigManagerBuilder.cs +++ b/src/Cocoar.Configuration/Core/ConfigManagerBuilder.cs @@ -1,251 +1,251 @@ -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Health; -using Cocoar.Configuration.Providers.Abstractions; -using Cocoar.Configuration.Rules; -using Microsoft.Extensions.Logging; - -namespace Cocoar.Configuration.Core; - -public sealed class ConfigManagerBuilder -{ - private readonly ConfigManager _manager; - - private Func? _rules; - private IEnumerable? _prebuiltRules; - private Func? _setup; - - private ILogger? _logger; - private int _debounceMilliseconds = 300; - private Func? _providerFactory; - private IFlagsHealthSource? _flagsHealthSource; - - private readonly List> _afterBuildActions = new(); - - // Service-backed (Layer-2, ADR-006) rules contributed by the DI package's UseServiceBackedConfiguration. - // They are appended AFTER the Layer-1 rules (later precedence) and, when sp-gated, stay dormant until the - // container is built. Kept separate from _rules so Layer 1 stays eager and byte-identical. - private readonly List _serviceBackedRules = new(); - - internal ConfigManagerBuilder(ConfigManager manager) - { - _manager = manager; - } - - /// - /// Gets the CapabilityScope of the ConfigManager being built. - /// Used by satellite libraries to configure capabilities directly on the scope. - /// - public static ConfigManagerCapabilityScope GetCapabilityScope(ConfigManagerBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - return builder._manager.CapabilityScope; - } - - /// - /// Gets the being built. - /// Used by same-assembly extensions (e.g. Flags, Secrets) to set data directly on the manager. - /// - internal static ConfigManager GetManager(ConfigManagerBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - return builder._manager; - } - - /// - /// Configures the rules that define how configuration is loaded and mapped to types. - /// - /// A function that builds the configuration rules using the fluent API. - /// An optional function to configure DI bindings and type exposure. - /// This builder for chaining. - /// - /// - /// builder.UseConfiguration( - /// rules => [ - /// rules.For<AppSettings>().FromFile("appsettings.json"), - /// rules.For<DbSettings>().FromFile("database.json") - /// ], - /// setup => [ - /// setup.ConcreteType<AppSettings>() - /// ]); - /// - /// - public ConfigManagerBuilder UseConfiguration( - Func rules, - Func? setup = null) - { - _rules = rules; - _prebuiltRules = null; - _setup = setup; - return this; - } - - /// - /// Configures the rules using a pre-built collection of instances. - /// Use this overload when rules are constructed programmatically rather than via the fluent builder. - /// - /// A pre-built collection of configuration rules. - /// An optional function to configure DI bindings and type exposure. - /// This builder for chaining. - public ConfigManagerBuilder UseConfiguration( - IEnumerable rules, - Func? setup = null) - { - _prebuiltRules = rules; - _rules = null; - _setup = setup; - return this; - } - - /// - /// Configures the logger used by the configuration system for diagnostics and error reporting. - /// - /// The logger instance to use. - /// This builder for chaining. - public ConfigManagerBuilder UseLogger(ILogger logger) - { - _logger = logger; - return this; - } - - /// - /// Sets the debounce interval for coalescing rapid configuration changes. - /// When multiple changes occur within this window, only one recompute is triggered. - /// Default is 300ms. - /// - /// The debounce interval in milliseconds. - /// This builder for chaining. - public ConfigManagerBuilder UseDebounce(int milliseconds) - { - _debounceMilliseconds = milliseconds; - return this; - } - - /// - /// Overrides the default provider factory used to instantiate configuration providers. - /// Intended for testing and advanced scenarios where provider construction needs to be intercepted. - /// - /// A factory function receiving the provider type and its configuration, returning the provider instance. - /// This builder for chaining. - public ConfigManagerBuilder UseProviderFactory( - Func factory) - { - _providerFactory = factory; - return this; - } - - /// - /// Sets the flags health source used by the health reporter to include - /// expired feature flags in health snapshots. - /// Called by UseFeatureFlags in Cocoar.Configuration.Flags. - /// - internal ConfigManagerBuilder SetFlagsHealthSource(IFlagsHealthSource source) - { - ArgumentNullException.ThrowIfNull(source); - _flagsHealthSource = source; - return this; - } - - /// - /// Registers an action that runs after ConfigManager is fully initialized. - /// Used by satellite libraries to perform post-init work - /// (e.g., constructing feature flags). - /// - internal ConfigManagerBuilder AfterBuild(Action action) - { - ArgumentNullException.ThrowIfNull(action); - _afterBuildActions.Add(action); - return this; - } - - /// - /// Internal seam used by the DI package's UseServiceBackedConfiguration to append satellite-supplied - /// service-backed (Layer-2, ADR-006) rules. They are placed AFTER the Layer-1 rules (later precedence). The - /// sp-gated ones are dormant during the eager build and activate on a post-container recompute. - /// - internal ConfigManagerBuilder AddServiceBackedRules(IEnumerable rules) - { - ArgumentNullException.ThrowIfNull(rules); - _serviceBackedRules.AddRange(rules); - return this; - } - - /// - /// Combines the Layer-1 rules with any appended service-backed (Layer-2) rules and records the Layer-2 - /// start index on the manager (the boundary the activation recompute restores the prefix below). - /// - private ConfigRule[] CombineWithServiceBackedRules(ConfigRule[] layer1Rules) - { - if (_serviceBackedRules.Count == 0) - { - return layer1Rules; - } - - _manager.ServiceBackedLayerStartIndex = layer1Rules.Length; - return layer1Rules.Concat(_serviceBackedRules).ToArray(); - } - - internal ConfigManager Build() - { - ConfigRule[] configuredRules; - - if (_prebuiltRules is not null) - { - configuredRules = _prebuiltRules.ToArray(); - } - else - { - var rulesBuilder = new RulesBuilder(); - configuredRules = (_rules ?? (_ => []))(rulesBuilder); - } - - configuredRules = CombineWithServiceBackedRules(configuredRules); - - _manager.Configure( - configuredRules, - _setup, - _logger, - _providerFactory, - _debounceMilliseconds, - _flagsHealthSource); - - _manager.Initialize(); - - foreach (var action in _afterBuildActions) - action(_manager); - - return _manager; - } - - internal async Task BuildAsync(CancellationToken cancellationToken = default) - { - ConfigRule[] configuredRules; - - if (_prebuiltRules is not null) - { - configuredRules = _prebuiltRules.ToArray(); - } - else - { - var rulesBuilder = new RulesBuilder(); - configuredRules = (_rules ?? (_ => []))(rulesBuilder); - } - - configuredRules = CombineWithServiceBackedRules(configuredRules); - - _manager.Configure( - configuredRules, - _setup, - _logger, - _providerFactory, - _debounceMilliseconds, - _flagsHealthSource); - - await _manager.InitializeAsync(cancellationToken).ConfigureAwait(false); - - foreach (var action in _afterBuildActions) - action(_manager); - - return _manager; - } -} +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Health; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.Configuration.Rules; +using Microsoft.Extensions.Logging; + +namespace Cocoar.Configuration.Core; + +public sealed class ConfigManagerBuilder +{ + private readonly ConfigManager _manager; + + private Func? _rules; + private IEnumerable? _prebuiltRules; + private Func? _setup; + + private ILogger? _logger; + private int _debounceMilliseconds = 300; + private Func? _providerFactory; + private IFlagsHealthSource? _flagsHealthSource; + + private readonly List> _afterBuildActions = new(); + + // Service-backed (Layer-2, ADR-006) rules contributed by the DI package's UseServiceBackedConfiguration. + // They are appended AFTER the Layer-1 rules (later precedence) and, when sp-gated, stay dormant until the + // container is built. Kept separate from _rules so Layer 1 stays eager and byte-identical. + private readonly List _serviceBackedRules = new(); + + internal ConfigManagerBuilder(ConfigManager manager) + { + _manager = manager; + } + + /// + /// Gets the CapabilityScope of the ConfigManager being built. + /// Used by satellite libraries to configure capabilities directly on the scope. + /// + public static ConfigManagerCapabilityScope GetCapabilityScope(ConfigManagerBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder._manager.CapabilityScope; + } + + /// + /// Gets the being built. + /// Used by same-assembly extensions (e.g. Flags, Secrets) to set data directly on the manager. + /// + internal static ConfigManager GetManager(ConfigManagerBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder._manager; + } + + /// + /// Configures the rules that define how configuration is loaded and mapped to types. + /// + /// A function that builds the configuration rules using the fluent API. + /// An optional function to configure DI bindings and type exposure. + /// This builder for chaining. + /// + /// + /// builder.UseConfiguration( + /// rules => [ + /// rules.For<AppSettings>().FromFile("appsettings.json"), + /// rules.For<DbSettings>().FromFile("database.json") + /// ], + /// setup => [ + /// setup.ConcreteType<AppSettings>() + /// ]); + /// + /// + public ConfigManagerBuilder UseConfiguration( + Func rules, + Func? setup = null) + { + _rules = rules; + _prebuiltRules = null; + _setup = setup; + return this; + } + + /// + /// Configures the rules using a pre-built collection of instances. + /// Use this overload when rules are constructed programmatically rather than via the fluent builder. + /// + /// A pre-built collection of configuration rules. + /// An optional function to configure DI bindings and type exposure. + /// This builder for chaining. + public ConfigManagerBuilder UseConfiguration( + IEnumerable rules, + Func? setup = null) + { + _prebuiltRules = rules; + _rules = null; + _setup = setup; + return this; + } + + /// + /// Configures the logger used by the configuration system for diagnostics and error reporting. + /// + /// The logger instance to use. + /// This builder for chaining. + public ConfigManagerBuilder UseLogger(ILogger logger) + { + _logger = logger; + return this; + } + + /// + /// Sets the debounce interval for coalescing rapid configuration changes. + /// When multiple changes occur within this window, only one recompute is triggered. + /// Default is 300ms. + /// + /// The debounce interval in milliseconds. + /// This builder for chaining. + public ConfigManagerBuilder UseDebounce(int milliseconds) + { + _debounceMilliseconds = milliseconds; + return this; + } + + /// + /// Overrides the default provider factory used to instantiate configuration providers. + /// Intended for testing and advanced scenarios where provider construction needs to be intercepted. + /// + /// A factory function receiving the provider type and its configuration, returning the provider instance. + /// This builder for chaining. + public ConfigManagerBuilder UseProviderFactory( + Func factory) + { + _providerFactory = factory; + return this; + } + + /// + /// Sets the flags health source used by the health reporter to include + /// expired feature flags in health snapshots. + /// Called by UseFeatureFlags in Cocoar.Configuration.Flags. + /// + internal ConfigManagerBuilder SetFlagsHealthSource(IFlagsHealthSource source) + { + ArgumentNullException.ThrowIfNull(source); + _flagsHealthSource = source; + return this; + } + + /// + /// Registers an action that runs after ConfigManager is fully initialized. + /// Used by satellite libraries to perform post-init work + /// (e.g., constructing feature flags). + /// + internal ConfigManagerBuilder AfterBuild(Action action) + { + ArgumentNullException.ThrowIfNull(action); + _afterBuildActions.Add(action); + return this; + } + + /// + /// Internal seam used by the DI package's UseServiceBackedConfiguration to append satellite-supplied + /// service-backed (Layer-2, ADR-006) rules. They are placed AFTER the Layer-1 rules (later precedence). The + /// sp-gated ones are dormant during the eager build and activate on a post-container recompute. + /// + internal ConfigManagerBuilder AddServiceBackedRules(IEnumerable rules) + { + ArgumentNullException.ThrowIfNull(rules); + _serviceBackedRules.AddRange(rules); + return this; + } + + /// + /// Combines the Layer-1 rules with any appended service-backed (Layer-2) rules and records the Layer-2 + /// start index on the manager (the boundary the activation recompute restores the prefix below). + /// + private ConfigRule[] CombineWithServiceBackedRules(ConfigRule[] layer1Rules) + { + if (_serviceBackedRules.Count == 0) + { + return layer1Rules; + } + + _manager.ServiceBackedLayerStartIndex = layer1Rules.Length; + return layer1Rules.Concat(_serviceBackedRules).ToArray(); + } + + internal ConfigManager Build() + { + ConfigRule[] configuredRules; + + if (_prebuiltRules is not null) + { + configuredRules = _prebuiltRules.ToArray(); + } + else + { + var rulesBuilder = new RulesBuilder(); + configuredRules = (_rules ?? (_ => []))(rulesBuilder); + } + + configuredRules = CombineWithServiceBackedRules(configuredRules); + + _manager.Configure( + configuredRules, + _setup, + _logger, + _providerFactory, + _debounceMilliseconds, + _flagsHealthSource); + + _manager.Initialize(); + + foreach (var action in _afterBuildActions) + action(_manager); + + return _manager; + } + + internal async Task BuildAsync(CancellationToken cancellationToken = default) + { + ConfigRule[] configuredRules; + + if (_prebuiltRules is not null) + { + configuredRules = _prebuiltRules.ToArray(); + } + else + { + var rulesBuilder = new RulesBuilder(); + configuredRules = (_rules ?? (_ => []))(rulesBuilder); + } + + configuredRules = CombineWithServiceBackedRules(configuredRules); + + _manager.Configure( + configuredRules, + _setup, + _logger, + _providerFactory, + _debounceMilliseconds, + _flagsHealthSource); + + await _manager.InitializeAsync(cancellationToken).ConfigureAwait(false); + + foreach (var action in _afterBuildActions) + action(_manager); + + return _manager; + } +} diff --git a/src/Cocoar.Configuration/Core/ConfigManagerCapabilityScope.cs b/src/Cocoar.Configuration/Core/ConfigManagerCapabilityScope.cs index 17fb409..878cb0d 100644 --- a/src/Cocoar.Configuration/Core/ConfigManagerCapabilityScope.cs +++ b/src/Cocoar.Configuration/Core/ConfigManagerCapabilityScope.cs @@ -1,18 +1,18 @@ -using Cocoar.Capabilities; - -namespace Cocoar.Configuration.Core; - -/// -/// Strongly-typed capability scope for ConfigManager. -/// Provides compile-time type safety for owner operations. -/// -public sealed class ConfigManagerCapabilityScope : CapabilityScope -{ - public ConfigManagerCapabilityScope(ConfigManager owner) : base(owner) - { - } - - public ConfigManagerCapabilityScope(ConfigManager owner, CapabilityScopeOptions? options) : base(owner, options) - { - } -} +using Cocoar.Capabilities; + +namespace Cocoar.Configuration.Core; + +/// +/// Strongly-typed capability scope for ConfigManager. +/// Provides compile-time type safety for owner operations. +/// +public sealed class ConfigManagerCapabilityScope : CapabilityScope +{ + public ConfigManagerCapabilityScope(ConfigManager owner) : base(owner) + { + } + + public ConfigManagerCapabilityScope(ConfigManager owner, CapabilityScopeOptions? options) : base(owner, options) + { + } +} diff --git a/src/Cocoar.Configuration/Core/ConfigSnapshot.cs b/src/Cocoar.Configuration/Core/ConfigSnapshot.cs index 7b54083..b86a941 100644 --- a/src/Cocoar.Configuration/Core/ConfigSnapshot.cs +++ b/src/Cocoar.Configuration/Core/ConfigSnapshot.cs @@ -1,102 +1,102 @@ -namespace Cocoar.Configuration.Core; - -/// -/// Immutable container for all configuration instances at a point in time. -/// Provides thread-safe, cached access to deserialized configuration objects. -/// -/// -/// -/// DO NOT mutate configuration instances. Instances are shared across -/// GetConfig calls and IReactiveConfig subscriptions. Mutations would affect -/// all consumers and cause inconsistent behavior. -/// -/// -public sealed class ConfigSnapshot -{ - private readonly IReadOnlyDictionary _instances; - - /// - /// Monotonically increasing version number for this snapshot. - /// - public long Version { get; } - - /// - /// UTC timestamp when this snapshot was created. - /// - public DateTimeOffset TimestampUtc { get; } - - private ConfigSnapshot(IReadOnlyDictionary instances, long version, DateTimeOffset timestampUtc) - { - _instances = instances; - Version = version; - TimestampUtc = timestampUtc; - } - - /// - /// Gets a configuration instance by type from the cached snapshot. - /// No deserialization occurs - returns the pre-computed instance. - /// - /// The configuration type to retrieve. - /// The configuration instance, or null if not found. - /// - /// DO NOT mutate the returned instance. It is shared across all consumers. - /// - public T? GetConfig() where T : class - { - if (_instances.TryGetValue(typeof(T), out var instance)) - { - return (T)instance; - } - return null; - } - - /// - /// Gets a configuration instance by type from the cached snapshot. - /// No deserialization occurs - returns the pre-computed instance. - /// - /// The configuration type to retrieve. - /// The configuration instance, or null if not found. - /// - /// DO NOT mutate the returned instance. It is shared across all consumers. - /// - public object? GetConfig(Type type) - { - return _instances.TryGetValue(type, out var instance) ? instance : null; - } - - /// - /// Checks if this snapshot contains a configuration of the specified type. - /// - public bool HasConfig() where T : class => _instances.ContainsKey(typeof(T)); - - /// - /// Checks if this snapshot contains a configuration of the specified type. - /// - public bool HasConfig(Type type) => _instances.ContainsKey(type); - - /// - /// Gets all configuration types registered in this snapshot. - /// - public IEnumerable ConfigTypes => _instances.Keys; - - /// - /// Gets the number of configuration types in this snapshot. - /// - public int Count => _instances.Count; - - /// - /// Creates a new snapshot with the specified instances. - /// - internal static ConfigSnapshot Create(IReadOnlyDictionary instances, long version) - { - return new ConfigSnapshot(instances, version, DateTimeOffset.UtcNow); - } - - /// - /// An empty snapshot with no configuration instances. - /// - public static ConfigSnapshot Empty { get; } = new( - new Dictionary(), - version: 0, - timestampUtc: DateTimeOffset.MinValue); -} +namespace Cocoar.Configuration.Core; + +/// +/// Immutable container for all configuration instances at a point in time. +/// Provides thread-safe, cached access to deserialized configuration objects. +/// +/// +/// +/// DO NOT mutate configuration instances. Instances are shared across +/// GetConfig calls and IReactiveConfig subscriptions. Mutations would affect +/// all consumers and cause inconsistent behavior. +/// +/// +public sealed class ConfigSnapshot +{ + private readonly IReadOnlyDictionary _instances; + + /// + /// Monotonically increasing version number for this snapshot. + /// + public long Version { get; } + + /// + /// UTC timestamp when this snapshot was created. + /// + public DateTimeOffset TimestampUtc { get; } + + private ConfigSnapshot(IReadOnlyDictionary instances, long version, DateTimeOffset timestampUtc) + { + _instances = instances; + Version = version; + TimestampUtc = timestampUtc; + } + + /// + /// Gets a configuration instance by type from the cached snapshot. + /// No deserialization occurs - returns the pre-computed instance. + /// + /// The configuration type to retrieve. + /// The configuration instance, or null if not found. + /// + /// DO NOT mutate the returned instance. It is shared across all consumers. + /// + public T? GetConfig() where T : class + { + if (_instances.TryGetValue(typeof(T), out var instance)) + { + return (T)instance; + } + return null; + } + + /// + /// Gets a configuration instance by type from the cached snapshot. + /// No deserialization occurs - returns the pre-computed instance. + /// + /// The configuration type to retrieve. + /// The configuration instance, or null if not found. + /// + /// DO NOT mutate the returned instance. It is shared across all consumers. + /// + public object? GetConfig(Type type) + { + return _instances.TryGetValue(type, out var instance) ? instance : null; + } + + /// + /// Checks if this snapshot contains a configuration of the specified type. + /// + public bool HasConfig() where T : class => _instances.ContainsKey(typeof(T)); + + /// + /// Checks if this snapshot contains a configuration of the specified type. + /// + public bool HasConfig(Type type) => _instances.ContainsKey(type); + + /// + /// Gets all configuration types registered in this snapshot. + /// + public IEnumerable ConfigTypes => _instances.Keys; + + /// + /// Gets the number of configuration types in this snapshot. + /// + public int Count => _instances.Count; + + /// + /// Creates a new snapshot with the specified instances. + /// + internal static ConfigSnapshot Create(IReadOnlyDictionary instances, long version) + { + return new ConfigSnapshot(instances, version, DateTimeOffset.UtcNow); + } + + /// + /// An empty snapshot with no configuration instances. + /// + public static ConfigSnapshot Empty { get; } = new( + new Dictionary(), + version: 0, + timestampUtc: DateTimeOffset.MinValue); +} diff --git a/src/Cocoar.Configuration/Core/ConfigSnapshotBuilder.cs b/src/Cocoar.Configuration/Core/ConfigSnapshotBuilder.cs index 1c94871..937a808 100644 --- a/src/Cocoar.Configuration/Core/ConfigSnapshotBuilder.cs +++ b/src/Cocoar.Configuration/Core/ConfigSnapshotBuilder.cs @@ -1,196 +1,196 @@ -using System.Globalization; -using System.Text; -using System.Text.Json; -using Cocoar.Capabilities; -using Cocoar.Configuration.Infrastructure; -using Cocoar.Configuration.Utilities; -using Cocoar.Json.Mutable; - -namespace Cocoar.Configuration.Core; - -/// -/// Records a single deserialization failure during snapshot building. -/// -/// The configuration type that failed to deserialize. -/// A human-readable description of the failure. -/// The underlying exception, if any. -/// A preview of the JSON that failed to deserialize (truncated for safety). -public sealed record DeserializationFailure( - Type ConfigType, - string Message, - Exception? Exception, - string? JsonPreview); - -/// -/// Exception thrown when one or more configuration types fail to deserialize during startup. -/// -public sealed class ConfigurationDeserializationException : Exception -{ - /// - /// The list of deserialization failures that caused this exception. - /// - public IReadOnlyList Failures { get; } - - public ConfigurationDeserializationException(IReadOnlyList failures) - : base(BuildMessage(failures)) - { - Failures = failures; - } - - private static string BuildMessage(IReadOnlyList failures) - { - if (failures.Count == 0) - { - return "Configuration deserialization failed with no specific failures recorded."; - } - - if (failures.Count == 1) - { - var f = failures[0]; - return $"Configuration deserialization failed for {f.ConfigType.Name}: {f.Message}"; - } - - var sb = new StringBuilder(); - sb.Append(CultureInfo.InvariantCulture, $"Configuration deserialization failed for {failures.Count} types:"); - foreach (var failure in failures) - { - sb.AppendLine(); - sb.Append(CultureInfo.InvariantCulture, $" - {failure.ConfigType.Name}: {failure.Message}"); - } - return sb.ToString(); - } -} - -/// -/// Builds a by eagerly deserializing all configuration types. -/// Collects failures and supports both fail-fast (startup) and resilient (runtime) modes. -/// -internal sealed class ConfigSnapshotBuilder -{ - private readonly Dictionary _instances = new(); - private readonly List _failures = new(); - private readonly ExposureRegistry _bindingRegistry; - private readonly ConfigManagerCapabilityScope _capabilityScope; - - public ConfigSnapshotBuilder(ExposureRegistry bindingRegistry, ConfigManagerCapabilityScope capabilityScope) - { - _bindingRegistry = bindingRegistry; - _capabilityScope = capabilityScope; - } - - /// - /// Deserializes a configuration type from the merged JSON object. - /// Failures are collected rather than thrown immediately. - /// - /// The configuration type to deserialize. - /// The merged JSON object for this type. - public void DeserializeType(Type type, MutableJsonObject json) - { - byte[] bytes; - lock (json) - { - bytes = MutableJsonDocument.ToUtf8Bytes(json); - } - - string? jsonPreview = null; - try - { - using var doc = JsonDocument.Parse(bytes); - var jsonElement = doc.RootElement.Clone(); - - jsonPreview = CreateJsonPreview(jsonElement); - - var instance = ConfigurationDeserializer.Deserialize( - jsonElement, - type, - _bindingRegistry.DeserializationMap, - _capabilityScope); - - if (instance == null) - { - _failures.Add(new DeserializationFailure( - type, - "Deserializer returned null (possible missing required properties)", - null, - jsonPreview)); - return; - } - - _instances[type] = instance; - } - catch (Exception ex) when (ex is JsonException or FormatException or InvalidCastException or NotSupportedException) - { - _failures.Add(new DeserializationFailure( - type, - ex.Message, - ex, - jsonPreview)); - } - } - - /// - /// Indicates whether any deserialization failures occurred. - /// - public bool HasFailures => _failures.Count > 0; - - /// - /// Gets the list of deserialization failures. - /// - public IReadOnlyList Failures => _failures; - - /// - /// Builds the snapshot, throwing if any failures occurred. - /// Use this during startup for fail-fast behavior. - /// - /// The version number for this snapshot. - /// A new ConfigSnapshot containing all successfully deserialized instances. - /// Thrown if any deserialization failed. - public ConfigSnapshot Build(long version) - { - if (_failures.Count > 0) - { - throw new ConfigurationDeserializationException(_failures); - } - - return ConfigSnapshot.Create(_instances, version); - } - - /// - /// Attempts to build the snapshot without throwing. - /// Use this at runtime for resilient behavior that preserves last-good state. - /// - /// The version number for this snapshot. - /// - /// A tuple containing the snapshot (or null if failures occurred) and the list of failures. - /// - public (ConfigSnapshot? Snapshot, IReadOnlyList Failures) TryBuild(long version) - { - if (_failures.Count > 0) - { - return (null, _failures); - } - - return (ConfigSnapshot.Create(_instances, version), _failures); - } - - private static string? CreateJsonPreview(JsonElement element) - { - // Only include top-level property NAMES, never values. - // Values could contain plaintext secrets (when AllowPlaintext is enabled) - // or encrypted envelopes. Neither should persist as strings in memory. - try - { - if (element.ValueKind != JsonValueKind.Object) return "{...}"; - var keys = new List(); - foreach (var prop in element.EnumerateObject()) - { - keys.Add(prop.Name); - } - return keys.Count > 0 ? $"{{ {string.Join(", ", keys)} }}" : "{}"; - } - catch - { - return null; - } - } -} +using System.Globalization; +using System.Text; +using System.Text.Json; +using Cocoar.Capabilities; +using Cocoar.Configuration.Infrastructure; +using Cocoar.Configuration.Utilities; +using Cocoar.Json.Mutable; + +namespace Cocoar.Configuration.Core; + +/// +/// Records a single deserialization failure during snapshot building. +/// +/// The configuration type that failed to deserialize. +/// A human-readable description of the failure. +/// The underlying exception, if any. +/// A preview of the JSON that failed to deserialize (truncated for safety). +public sealed record DeserializationFailure( + Type ConfigType, + string Message, + Exception? Exception, + string? JsonPreview); + +/// +/// Exception thrown when one or more configuration types fail to deserialize during startup. +/// +public sealed class ConfigurationDeserializationException : Exception +{ + /// + /// The list of deserialization failures that caused this exception. + /// + public IReadOnlyList Failures { get; } + + public ConfigurationDeserializationException(IReadOnlyList failures) + : base(BuildMessage(failures)) + { + Failures = failures; + } + + private static string BuildMessage(IReadOnlyList failures) + { + if (failures.Count == 0) + { + return "Configuration deserialization failed with no specific failures recorded."; + } + + if (failures.Count == 1) + { + var f = failures[0]; + return $"Configuration deserialization failed for {f.ConfigType.Name}: {f.Message}"; + } + + var sb = new StringBuilder(); + sb.Append(CultureInfo.InvariantCulture, $"Configuration deserialization failed for {failures.Count} types:"); + foreach (var failure in failures) + { + sb.AppendLine(); + sb.Append(CultureInfo.InvariantCulture, $" - {failure.ConfigType.Name}: {failure.Message}"); + } + return sb.ToString(); + } +} + +/// +/// Builds a by eagerly deserializing all configuration types. +/// Collects failures and supports both fail-fast (startup) and resilient (runtime) modes. +/// +internal sealed class ConfigSnapshotBuilder +{ + private readonly Dictionary _instances = new(); + private readonly List _failures = new(); + private readonly ExposureRegistry _bindingRegistry; + private readonly ConfigManagerCapabilityScope _capabilityScope; + + public ConfigSnapshotBuilder(ExposureRegistry bindingRegistry, ConfigManagerCapabilityScope capabilityScope) + { + _bindingRegistry = bindingRegistry; + _capabilityScope = capabilityScope; + } + + /// + /// Deserializes a configuration type from the merged JSON object. + /// Failures are collected rather than thrown immediately. + /// + /// The configuration type to deserialize. + /// The merged JSON object for this type. + public void DeserializeType(Type type, MutableJsonObject json) + { + byte[] bytes; + lock (json) + { + bytes = MutableJsonDocument.ToUtf8Bytes(json); + } + + string? jsonPreview = null; + try + { + using var doc = JsonDocument.Parse(bytes); + var jsonElement = doc.RootElement.Clone(); + + jsonPreview = CreateJsonPreview(jsonElement); + + var instance = ConfigurationDeserializer.Deserialize( + jsonElement, + type, + _bindingRegistry.DeserializationMap, + _capabilityScope); + + if (instance == null) + { + _failures.Add(new DeserializationFailure( + type, + "Deserializer returned null (possible missing required properties)", + null, + jsonPreview)); + return; + } + + _instances[type] = instance; + } + catch (Exception ex) when (ex is JsonException or FormatException or InvalidCastException or NotSupportedException) + { + _failures.Add(new DeserializationFailure( + type, + ex.Message, + ex, + jsonPreview)); + } + } + + /// + /// Indicates whether any deserialization failures occurred. + /// + public bool HasFailures => _failures.Count > 0; + + /// + /// Gets the list of deserialization failures. + /// + public IReadOnlyList Failures => _failures; + + /// + /// Builds the snapshot, throwing if any failures occurred. + /// Use this during startup for fail-fast behavior. + /// + /// The version number for this snapshot. + /// A new ConfigSnapshot containing all successfully deserialized instances. + /// Thrown if any deserialization failed. + public ConfigSnapshot Build(long version) + { + if (_failures.Count > 0) + { + throw new ConfigurationDeserializationException(_failures); + } + + return ConfigSnapshot.Create(_instances, version); + } + + /// + /// Attempts to build the snapshot without throwing. + /// Use this at runtime for resilient behavior that preserves last-good state. + /// + /// The version number for this snapshot. + /// + /// A tuple containing the snapshot (or null if failures occurred) and the list of failures. + /// + public (ConfigSnapshot? Snapshot, IReadOnlyList Failures) TryBuild(long version) + { + if (_failures.Count > 0) + { + return (null, _failures); + } + + return (ConfigSnapshot.Create(_instances, version), _failures); + } + + private static string? CreateJsonPreview(JsonElement element) + { + // Only include top-level property NAMES, never values. + // Values could contain plaintext secrets (when AllowPlaintext is enabled) + // or encrypted envelopes. Neither should persist as strings in memory. + try + { + if (element.ValueKind != JsonValueKind.Object) return "{...}"; + var keys = new List(); + foreach (var prop in element.EnumerateObject()) + { + keys.Add(prop.Name); + } + return keys.Count > 0 ? $"{{ {string.Join(", ", keys)} }}" : "{}"; + } + catch + { + return null; + } + } +} diff --git a/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs b/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs index d5e0a8e..48ef26e 100644 --- a/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs +++ b/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs @@ -1,298 +1,298 @@ -using System.Text.Json; -using Cocoar.Capabilities; -using Cocoar.Configuration.Infrastructure; -using Cocoar.Configuration.Rules; -using Cocoar.Configuration.Utilities; -using Cocoar.Json.Mutable; -using Microsoft.Extensions.Logging; - -namespace Cocoar.Configuration.Core; - -internal static partial class ConfigurationAccessorLog -{ - [LoggerMessage(EventId = 3000, Level = LogLevel.Debug, - Message = "Fallback deserialization for {TypeName} during recompute phase")] - public static partial void FallbackDeserialization(this ILogger logger, string typeName); - - [LoggerMessage(EventId = 3001, Level = LogLevel.Warning, - Message = "Fallback deserialization failed for {TypeName}: {Message}")] - public static partial void FallbackDeserializationFailed(this ILogger logger, string typeName, string message); -} - -internal partial class ConfigurationAccessor : IConfigurationAccessor -{ - private readonly ConfigurationState _state; - private readonly ExposureRegistry _bindingRegistry; - private readonly ILogger _logger; - private readonly List _rules; - private ConfigManagerCapabilityScope? _capabilityScope; - - public ConfigurationAccessor( - ConfigurationState state, - ExposureRegistry bindingRegistry, - ILogger logger, - List rules, - string? tenant = null) - { - _state = state; - _bindingRegistry = bindingRegistry; - _logger = logger; - _rules = rules; - Tenant = tenant; - } - - /// - public string? Tenant { get; } - - internal void SetCapabilityScope(ConfigManagerCapabilityScope capabilityScope) - { - _capabilityScope = capabilityScope; - } - - /// - /// Gets a configuration instance from the cached snapshot. - /// During recompute, falls back to on-demand deserialization. - /// - /// - /// DO NOT mutate the returned instance. It is shared across all consumers. - /// - /// No configuration rule is registered for type T. - public T? GetConfig() where T : class - { - // First try the backplane (has cached instances after initialization) - try - { - var result = _state.Backplane.GetConfig(typeof(T)); - if (result is T typed) - { - return typed; - } - } - catch (InvalidOperationException) - { - // Backplane not initialized yet - fall through to lazy deserialization - } - - // Fallback: during recompute phase, deserialize on-demand from pending configurations - return FallbackDeserialize(); - } - - private T FallbackDeserialize() - { - var type = typeof(T); - - // Try to resolve interface to concrete type - var targetType = type; - if (type.IsInterface && _bindingRegistry.TryGetConcreteType(type, out var concreteType)) - { - targetType = concreteType; - } - - if (!_state.TryGetConfiguration(targetType, out var json) || json == null) - { - throw NoConfigurationFor(type, targetType); - } - - _logger.FallbackDeserialization(type.Name); - - try - { - byte[] bytes; - lock (json) - { - bytes = MutableJsonDocument.ToUtf8Bytes(json); - } - - using var doc = JsonDocument.Parse(bytes); - var result = ConfigurationDeserializer.Deserialize( - doc.RootElement, - targetType, - _bindingRegistry.DeserializationMap, - _capabilityScope); - - if (result is T typed) - { - return typed; - } - - throw new InvalidOperationException( - $"Deserialization returned unexpected type for '{type.Name}'."); - } - catch (InvalidOperationException) - { - throw; // Re-throw our own exceptions - } - catch (Exception ex) - { - _logger.FallbackDeserializationFailed(type.Name, ex.Message); - throw new InvalidOperationException( - $"Failed to deserialize configuration for '{type.Name}': {ex.Message}", ex); - } - } - - /// - /// Builds the exception thrown when no committed configuration exists for a requested type. In the global - /// (tenant-agnostic) pipeline a type whose EVERY rule is .TenantScoped() contributes nothing — its - /// rules skip when there is no tenant — so it genuinely has no global value. Surface that precisely (point - /// the caller at the per-tenant API) instead of the generic "add a rule" message, which is misleading when - /// a rule does exist. Mirrors the tuple guard in . - /// - private InvalidOperationException NoConfigurationFor(Type requestedType, Type targetType) - { - if (string.IsNullOrWhiteSpace(Tenant) && HasOnlyTenantScopedRules(targetType)) - { - return new InvalidOperationException( - $"Configuration type '{requestedType.Name}' has only .TenantScoped() rules, so it has no global " + - $"value. Read it per tenant with GetConfigForTenant<{requestedType.Name}>(tenantId) or " + - $"GetReactiveConfigForTenant<{requestedType.Name}>(tenantId)."); - } - - return new InvalidOperationException( - $"No configuration rule is registered for type '{requestedType.Name}'. " + - $"Add a rule using: rules.For<{requestedType.Name}>().From..."); - } - - private bool HasOnlyTenantScopedRules(Type type) - { - var hasRule = false; - foreach (var rule in _rules) - { - if (rule.ConcreteType != type) - { - continue; - } - - hasRule = true; - if (rule.Options?.TenantScoped != true) - { - return false; - } - } - - return hasRule; - } - - public bool TryGetConfig(out T? value) where T : class - { - try - { - value = GetConfig(); - return true; - } - catch (InvalidOperationException) - { - value = default; - return false; - } - } - - /// - /// Gets configuration, throwing if not found. - /// - /// - /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. - /// - [Obsolete("Use GetConfig() instead - it now throws if no rule is registered. " + - "This method will be removed in a future version.")] - public T GetRequiredConfig() => (T)GetConfig(typeof(T)); - - /// - /// Gets a configuration instance from the cached snapshot. - /// During recompute, falls back to on-demand deserialization. - /// - /// - /// DO NOT mutate the returned instance. It is shared across all consumers. - /// - /// No configuration rule is registered for the type. - public object GetConfig(Type type) - { - // First try the backplane - try - { - var result = _state.Backplane.GetConfig(type); - if (result != null) - { - return result; - } - } - catch (InvalidOperationException) - { - // Backplane not initialized yet - fall through to lazy deserialization - } - - // Fallback: during recompute phase, deserialize on-demand - return FallbackDeserialize(type); - } - - private object FallbackDeserialize(Type type) - { - // Try to resolve interface to concrete type - var targetType = type; - if (type.IsInterface && _bindingRegistry.TryGetConcreteType(type, out var concreteType)) - { - targetType = concreteType; - } - - if (!_state.TryGetConfiguration(targetType, out var json) || json == null) - { - throw NoConfigurationFor(type, targetType); - } - - _logger.FallbackDeserialization(type.Name); - - try - { - byte[] bytes; - lock (json) - { - bytes = MutableJsonDocument.ToUtf8Bytes(json); - } - - using var doc = JsonDocument.Parse(bytes); - var result = ConfigurationDeserializer.Deserialize( - doc.RootElement, - targetType, - _bindingRegistry.DeserializationMap, - _capabilityScope); - - return result ?? throw new InvalidOperationException( - $"Deserialization returned null for '{type.Name}'."); - } - catch (InvalidOperationException) - { - throw; // Re-throw our own exceptions - } - catch (Exception ex) - { - _logger.FallbackDeserializationFailed(type.Name, ex.Message); - throw new InvalidOperationException( - $"Failed to deserialize configuration for '{type.Name}': {ex.Message}", ex); - } - } - - public bool TryGetConfig(Type type, out object? value) - { - try - { - value = GetConfig(type); - return true; - } - catch (InvalidOperationException) - { - value = null; - return false; - } - } - - /// - /// Gets configuration, throwing if not found. - /// - /// - /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. - /// - [Obsolete("Use GetConfig(Type) instead - it now throws if no rule is registered. " + - "This method will be removed in a future version.")] - public object GetRequiredConfig(Type type) => GetConfig(type); - - public JsonElement? GetConfigAsJson(Type type) => _state.GetConfigurationAsJson(type); -} +using System.Text.Json; +using Cocoar.Capabilities; +using Cocoar.Configuration.Infrastructure; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Utilities; +using Cocoar.Json.Mutable; +using Microsoft.Extensions.Logging; + +namespace Cocoar.Configuration.Core; + +internal static partial class ConfigurationAccessorLog +{ + [LoggerMessage(EventId = 3000, Level = LogLevel.Debug, + Message = "Fallback deserialization for {TypeName} during recompute phase")] + public static partial void FallbackDeserialization(this ILogger logger, string typeName); + + [LoggerMessage(EventId = 3001, Level = LogLevel.Warning, + Message = "Fallback deserialization failed for {TypeName}: {Message}")] + public static partial void FallbackDeserializationFailed(this ILogger logger, string typeName, string message); +} + +internal partial class ConfigurationAccessor : IConfigurationAccessor +{ + private readonly ConfigurationState _state; + private readonly ExposureRegistry _bindingRegistry; + private readonly ILogger _logger; + private readonly List _rules; + private ConfigManagerCapabilityScope? _capabilityScope; + + public ConfigurationAccessor( + ConfigurationState state, + ExposureRegistry bindingRegistry, + ILogger logger, + List rules, + string? tenant = null) + { + _state = state; + _bindingRegistry = bindingRegistry; + _logger = logger; + _rules = rules; + Tenant = tenant; + } + + /// + public string? Tenant { get; } + + internal void SetCapabilityScope(ConfigManagerCapabilityScope capabilityScope) + { + _capabilityScope = capabilityScope; + } + + /// + /// Gets a configuration instance from the cached snapshot. + /// During recompute, falls back to on-demand deserialization. + /// + /// + /// DO NOT mutate the returned instance. It is shared across all consumers. + /// + /// No configuration rule is registered for type T. + public T? GetConfig() where T : class + { + // First try the backplane (has cached instances after initialization) + try + { + var result = _state.Backplane.GetConfig(typeof(T)); + if (result is T typed) + { + return typed; + } + } + catch (InvalidOperationException) + { + // Backplane not initialized yet - fall through to lazy deserialization + } + + // Fallback: during recompute phase, deserialize on-demand from pending configurations + return FallbackDeserialize(); + } + + private T FallbackDeserialize() + { + var type = typeof(T); + + // Try to resolve interface to concrete type + var targetType = type; + if (type.IsInterface && _bindingRegistry.TryGetConcreteType(type, out var concreteType)) + { + targetType = concreteType; + } + + if (!_state.TryGetConfiguration(targetType, out var json) || json == null) + { + throw NoConfigurationFor(type, targetType); + } + + _logger.FallbackDeserialization(type.Name); + + try + { + byte[] bytes; + lock (json) + { + bytes = MutableJsonDocument.ToUtf8Bytes(json); + } + + using var doc = JsonDocument.Parse(bytes); + var result = ConfigurationDeserializer.Deserialize( + doc.RootElement, + targetType, + _bindingRegistry.DeserializationMap, + _capabilityScope); + + if (result is T typed) + { + return typed; + } + + throw new InvalidOperationException( + $"Deserialization returned unexpected type for '{type.Name}'."); + } + catch (InvalidOperationException) + { + throw; // Re-throw our own exceptions + } + catch (Exception ex) + { + _logger.FallbackDeserializationFailed(type.Name, ex.Message); + throw new InvalidOperationException( + $"Failed to deserialize configuration for '{type.Name}': {ex.Message}", ex); + } + } + + /// + /// Builds the exception thrown when no committed configuration exists for a requested type. In the global + /// (tenant-agnostic) pipeline a type whose EVERY rule is .TenantScoped() contributes nothing — its + /// rules skip when there is no tenant — so it genuinely has no global value. Surface that precisely (point + /// the caller at the per-tenant API) instead of the generic "add a rule" message, which is misleading when + /// a rule does exist. Mirrors the tuple guard in . + /// + private InvalidOperationException NoConfigurationFor(Type requestedType, Type targetType) + { + if (string.IsNullOrWhiteSpace(Tenant) && HasOnlyTenantScopedRules(targetType)) + { + return new InvalidOperationException( + $"Configuration type '{requestedType.Name}' has only .TenantScoped() rules, so it has no global " + + $"value. Read it per tenant with GetConfigForTenant<{requestedType.Name}>(tenantId) or " + + $"GetReactiveConfigForTenant<{requestedType.Name}>(tenantId)."); + } + + return new InvalidOperationException( + $"No configuration rule is registered for type '{requestedType.Name}'. " + + $"Add a rule using: rules.For<{requestedType.Name}>().From..."); + } + + private bool HasOnlyTenantScopedRules(Type type) + { + var hasRule = false; + foreach (var rule in _rules) + { + if (rule.ConcreteType != type) + { + continue; + } + + hasRule = true; + if (rule.Options?.TenantScoped != true) + { + return false; + } + } + + return hasRule; + } + + public bool TryGetConfig(out T? value) where T : class + { + try + { + value = GetConfig(); + return true; + } + catch (InvalidOperationException) + { + value = default; + return false; + } + } + + /// + /// Gets configuration, throwing if not found. + /// + /// + /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. + /// + [Obsolete("Use GetConfig() instead - it now throws if no rule is registered. " + + "This method will be removed in a future version.")] + public T GetRequiredConfig() => (T)GetConfig(typeof(T)); + + /// + /// Gets a configuration instance from the cached snapshot. + /// During recompute, falls back to on-demand deserialization. + /// + /// + /// DO NOT mutate the returned instance. It is shared across all consumers. + /// + /// No configuration rule is registered for the type. + public object GetConfig(Type type) + { + // First try the backplane + try + { + var result = _state.Backplane.GetConfig(type); + if (result != null) + { + return result; + } + } + catch (InvalidOperationException) + { + // Backplane not initialized yet - fall through to lazy deserialization + } + + // Fallback: during recompute phase, deserialize on-demand + return FallbackDeserialize(type); + } + + private object FallbackDeserialize(Type type) + { + // Try to resolve interface to concrete type + var targetType = type; + if (type.IsInterface && _bindingRegistry.TryGetConcreteType(type, out var concreteType)) + { + targetType = concreteType; + } + + if (!_state.TryGetConfiguration(targetType, out var json) || json == null) + { + throw NoConfigurationFor(type, targetType); + } + + _logger.FallbackDeserialization(type.Name); + + try + { + byte[] bytes; + lock (json) + { + bytes = MutableJsonDocument.ToUtf8Bytes(json); + } + + using var doc = JsonDocument.Parse(bytes); + var result = ConfigurationDeserializer.Deserialize( + doc.RootElement, + targetType, + _bindingRegistry.DeserializationMap, + _capabilityScope); + + return result ?? throw new InvalidOperationException( + $"Deserialization returned null for '{type.Name}'."); + } + catch (InvalidOperationException) + { + throw; // Re-throw our own exceptions + } + catch (Exception ex) + { + _logger.FallbackDeserializationFailed(type.Name, ex.Message); + throw new InvalidOperationException( + $"Failed to deserialize configuration for '{type.Name}': {ex.Message}", ex); + } + } + + public bool TryGetConfig(Type type, out object? value) + { + try + { + value = GetConfig(type); + return true; + } + catch (InvalidOperationException) + { + value = null; + return false; + } + } + + /// + /// Gets configuration, throwing if not found. + /// + /// + /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. + /// + [Obsolete("Use GetConfig(Type) instead - it now throws if no rule is registered. " + + "This method will be removed in a future version.")] + public object GetRequiredConfig(Type type) => GetConfig(type); + + public JsonElement? GetConfigAsJson(Type type) => _state.GetConfigurationAsJson(type); +} diff --git a/src/Cocoar.Configuration/Core/ConfigurationEngine.cs b/src/Cocoar.Configuration/Core/ConfigurationEngine.cs index dfcd2bd..e4cde41 100644 --- a/src/Cocoar.Configuration/Core/ConfigurationEngine.cs +++ b/src/Cocoar.Configuration/Core/ConfigurationEngine.cs @@ -1,581 +1,581 @@ -using System.Diagnostics; -using Microsoft.Extensions.Logging; -using System.Text.Json; -using Cocoar.Capabilities; -using Cocoar.Configuration.Diagnostics; -using Cocoar.Configuration.Infrastructure; -using Cocoar.Configuration.Rules; -using Cocoar.Configuration.Utilities; -using Cocoar.Json.Mutable; - -namespace Cocoar.Configuration.Core; - -internal static partial class ConfigurationEngineLog -{ - [LoggerMessage(EventId = 2000, Level = LogLevel.Error, Message = "ConfigManager initialization failed")] - public static partial void InitializationFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = 2001, Level = LogLevel.Error, Message = "Runtime recompute failed - preserving current configuration")] - public static partial void RuntimeRecomputeFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = 2002, Level = LogLevel.Debug, Message = "Recompute started")] - public static partial void RecomputeStarted(this ILogger logger); - - [LoggerMessage(EventId = 2003, Level = LogLevel.Debug, Message = "Recompute cancelled")] - public static partial void RecomputeCancelled(this ILogger logger); - - [LoggerMessage(EventId = 2004, Level = LogLevel.Debug, Message = "Recompute finished")] - public static partial void RecomputeFinished(this ILogger logger); - - [LoggerMessage(EventId = 2005, Level = LogLevel.Error, Message = "Recompute failed from change trigger")] - public static partial void RecomputeFailedFromChange(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = 2006, Level = LogLevel.Information, Message = "Startup phase complete - switching to resilient mode")] - public static partial void StartupComplete(this ILogger logger); -} - -/// -/// Central engine for configuration computation and change management. -/// Handles initialization, recomputation, and change subscriptions. -/// Delegates scheduling/cancellation to RecomputeScheduler. -/// -internal class ConfigurationEngine : IDisposable, IAsyncDisposable -{ - private readonly ConfigurationState _state; - private readonly ILogger _logger; - private readonly SemaphoreSlim _recomputeSemaphore = new(1, 1); - private readonly RecomputeScheduler _scheduler = new(); - - private bool _disposed; - private readonly List _changeSubscriptions = []; - - // Context for deserialization - private ExposureRegistry? _bindingRegistry; - private ConfigManagerCapabilityScope? _capabilityScope; - - public ConfigurationEngine(ConfigurationState state, ILogger logger) - { - _state = state; - _logger = logger; - } - - public Task? CurrentRecomputeTask => _scheduler.CurrentRecomputeTask; - - /// - /// Initializes the configuration system: analyzes rules, creates managers, performs initial computation, and sets up subscriptions. - /// Throws ConfigurationDeserializationException if any deserialization fails during startup (fail-fast behavior). - /// - public void InitializeAndCompute( - List rules, - List ruleManagers, - ProviderRegistry providerRegistry, - IConfigurationAccessor configAccessor, - ExposureRegistry bindingRegistry, - ConfigManagerCapabilityScope capabilityScope, - Action scheduleRecomputeCallback, - int debounceMilliseconds) - { - // Store context for runtime recomputes - _bindingRegistry = bindingRegistry; - _capabilityScope = capabilityScope; - - // Initialize the backplane - _state.InitializeBackplane(bindingRegistry); - - ruleManagers.Clear(); - foreach (var rule in rules) - { - if (rule is AggregateConfigRule aggregate) - ruleManagers.Add(new AggregateRuleManager(aggregate, _logger, providerRegistry)); - else - ruleManagers.Add(new RuleManager(rule, _logger, providerRegistry)); - } - - try - { - // During startup, deserialization failures throw - RecomputeAllConfigurationsSafe(ruleManagers, configAccessor); - CreateChangeSubscriptions(ruleManagers, scheduleRecomputeCallback, debounceMilliseconds); - - _state.UpdateHealth(); - - // Mark startup complete - future failures will preserve last good config - _state.MarkStartupComplete(); - _logger.StartupComplete(); - } - catch (Exception ex) - { - _logger.InitializationFailed(ex); - _state.UpdateHealth(); - throw; - } - } - - /// - /// Schedules a configuration recomputation starting from the given index. - /// Cancels any in-flight recompute and starts a new one. - /// - public void ScheduleRecompute( - List ruleManagers, - IConfigurationAccessor configAccessor, - int startIndex) - { - _scheduler.ScheduleAsync(async ct => - { - try - { - await RecomputeAllConfigurationsSafeAsync(ruleManagers, configAccessor, startIndex, ct).ConfigureAwait(false); - _state.UpdateHealth(); - } - catch (OperationCanceledException) { } - catch (Exception ex) - { - _logger.RuntimeRecomputeFailed(ex); - _state.UpdateHealth(); - } - }); - } - - /// - /// Runs a recompute from directly to completion and updates health — the same - /// post-recompute work the lambda does, but awaitable and WITHOUT the - /// cancel-on-reschedule scheduler. Used by the DI Layer-2 activation so a concurrent change cannot cancel it - /// and so health reflects a degraded Layer-2 source. Never throws (failures are caught + health updated, - /// matching the scheduler path). - /// - public async Task RecomputeAndUpdateHealthAsync( - IReadOnlyList ruleManagers, - IConfigurationAccessor configAccessor, - int startIndex, - CancellationToken cancellationToken = default) - { - try - { - await RecomputeAllConfigurationsSafeAsync(ruleManagers, configAccessor, startIndex, cancellationToken).ConfigureAwait(false); - _state.UpdateHealth(); - } - catch (OperationCanceledException) { } - catch (Exception ex) - { - _logger.RuntimeRecomputeFailed(ex); - _state.UpdateHealth(); - } - } - - /// - /// Async variant of . Used by . - /// - public async Task InitializeAndComputeAsync( - List rules, - List ruleManagers, - ProviderRegistry providerRegistry, - IConfigurationAccessor configAccessor, - ExposureRegistry bindingRegistry, - ConfigManagerCapabilityScope capabilityScope, - Action scheduleRecomputeCallback, - int debounceMilliseconds, - CancellationToken cancellationToken = default) - { - _bindingRegistry = bindingRegistry; - _capabilityScope = capabilityScope; - - _state.InitializeBackplane(bindingRegistry); - - ruleManagers.Clear(); - foreach (var rule in rules) - { - if (rule is AggregateConfigRule aggregate) - ruleManagers.Add(new AggregateRuleManager(aggregate, _logger, providerRegistry)); - else - ruleManagers.Add(new RuleManager(rule, _logger, providerRegistry)); - } - - try - { - await RecomputeAllConfigurationsSafeAsync(ruleManagers, configAccessor, 0, cancellationToken).ConfigureAwait(false); - CreateChangeSubscriptions(ruleManagers, scheduleRecomputeCallback, debounceMilliseconds); - - _state.UpdateHealth(); - _state.MarkStartupComplete(); - _logger.StartupComplete(); - } - catch (Exception ex) - { - _logger.InitializationFailed(ex); - _state.UpdateHealth(); - throw; - } - } - - /// - /// Recomputes all configurations starting from the given index, with semaphore protection and error handling. - /// - public void RecomputeAllConfigurationsSafe( - IReadOnlyList ruleManagers, - IConfigurationAccessor configAccessor, - int startIndex = 0, - CancellationToken cancellationToken = default) - { - _recomputeSemaphore.Wait(cancellationToken); - try - { - using var activity = CocoarMetrics.ActivitySource.StartActivity("cocoar.config.recompute"); - activity?.SetTag("rule_count", ruleManagers.Count); - activity?.SetTag("start_index", startIndex); - - _logger.RecomputeStarted(); - var sw = Stopwatch.StartNew(); - try - { - RecomputeAllConfigurations(ruleManagers, configAccessor, startIndex, cancellationToken); - sw.Stop(); - activity?.SetTag("status", "success"); - CocoarMetrics.RecomputeCount.Add(1, new KeyValuePair("status", "success")); - CocoarMetrics.RecomputeDuration.Record(sw.Elapsed.TotalMilliseconds); - } - catch (OperationCanceledException) - { - sw.Stop(); - activity?.SetTag("status", "cancelled"); - activity?.SetStatus(ActivityStatusCode.Error, "Cancelled"); - CocoarMetrics.RecomputeCount.Add(1, new KeyValuePair("status", "cancelled")); - CocoarMetrics.RecomputeDuration.Record(sw.Elapsed.TotalMilliseconds); - _logger.RecomputeCancelled(); - RollbackSafely(); - throw; - } - catch (Exception ex) - { - sw.Stop(); - activity?.SetTag("status", "failure"); - activity?.SetStatus(ActivityStatusCode.Error, ex.Message); - CocoarMetrics.RecomputeCount.Add(1, new KeyValuePair("status", "failure")); - CocoarMetrics.RecomputeDuration.Record(sw.Elapsed.TotalMilliseconds); - RollbackSafely(); - throw; - } - finally - { - _logger.RecomputeFinished(); - } - } - finally - { - _recomputeSemaphore.Release(); - } - } - - /// - /// Async version of RecomputeAllConfigurationsSafe. - /// - public async Task RecomputeAllConfigurationsSafeAsync( - IReadOnlyList ruleManagers, - IConfigurationAccessor configAccessor, - int startIndex = 0, - CancellationToken cancellationToken = default) - { - await _recomputeSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - using var activity = CocoarMetrics.ActivitySource.StartActivity("cocoar.config.recompute"); - activity?.SetTag("rule_count", ruleManagers.Count); - activity?.SetTag("start_index", startIndex); - - _logger.RecomputeStarted(); - var sw = Stopwatch.StartNew(); - try - { - await RecomputeAllConfigurationsAsync(ruleManagers, configAccessor, startIndex, cancellationToken).ConfigureAwait(false); - sw.Stop(); - activity?.SetTag("status", "success"); - CocoarMetrics.RecomputeCount.Add(1, new KeyValuePair("status", "success")); - CocoarMetrics.RecomputeDuration.Record(sw.Elapsed.TotalMilliseconds); - } - catch (OperationCanceledException) - { - sw.Stop(); - activity?.SetTag("status", "cancelled"); - activity?.SetStatus(ActivityStatusCode.Error, "Cancelled"); - CocoarMetrics.RecomputeCount.Add(1, new KeyValuePair("status", "cancelled")); - CocoarMetrics.RecomputeDuration.Record(sw.Elapsed.TotalMilliseconds); - _logger.RecomputeCancelled(); - RollbackSafely(); - throw; - } - catch (Exception ex) - { - sw.Stop(); - activity?.SetTag("status", "failure"); - activity?.SetStatus(ActivityStatusCode.Error, ex.Message); - CocoarMetrics.RecomputeCount.Add(1, new KeyValuePair("status", "failure")); - CocoarMetrics.RecomputeDuration.Record(sw.Elapsed.TotalMilliseconds); - RollbackSafely(); - throw; - } - finally - { - _logger.RecomputeFinished(); - } - } - finally - { - _recomputeSemaphore.Release(); - } - } - - private void RollbackSafely() - { - try - { - _state.RollbackUpdate(); - } - catch (Exception rollbackEx) - { - _logger.RuntimeRecomputeFailed(rollbackEx); - } - } - - private void RecomputeAllConfigurations( - IReadOnlyList ruleManagers, - IConfigurationAccessor configAccessor, - int startIndex = 0, - CancellationToken cancellationToken = default) - { - var mergedConfigs = new Dictionary(); - _state.BeginUpdate(); - - cancellationToken.ThrowIfCancellationRequested(); - - RestorePrefixContributions(ruleManagers, startIndex, mergedConfigs, cancellationToken); - RecomputeSuffix(ruleManagers, startIndex, configAccessor, mergedConfigs, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - - // Use new method with eager deserialization - if (_bindingRegistry != null && _capabilityScope != null) - { - _state.CommitUpdateWithDeserialization(mergedConfigs, _bindingRegistry, _capabilityScope); - } - else - { - // Fallback for tests or edge cases - _state.CommitUpdate(mergedConfigs); - } - } - - private async Task RecomputeAllConfigurationsAsync( - IReadOnlyList ruleManagers, - IConfigurationAccessor configAccessor, - int startIndex = 0, - CancellationToken cancellationToken = default) - { - var mergedConfigs = new Dictionary(); - _state.BeginUpdate(); - - cancellationToken.ThrowIfCancellationRequested(); - - RestorePrefixContributions(ruleManagers, startIndex, mergedConfigs, cancellationToken); - await RecomputeSuffixAsync(ruleManagers, startIndex, configAccessor, mergedConfigs, cancellationToken).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - - // Use new method with eager deserialization - if (_bindingRegistry != null && _capabilityScope != null) - { - _state.CommitUpdateWithDeserialization(mergedConfigs, _bindingRegistry, _capabilityScope); - } - else - { - // Fallback for tests or edge cases - _state.CommitUpdate(mergedConfigs); - } - } - - private void RestorePrefixContributions( - IReadOnlyList orderedManagers, - int startIndex, - Dictionary mergedConfigs, - CancellationToken cancellationToken) - { - if (startIndex <= 0) - { - return; - } - - for (var i = 0; i < startIndex && i < orderedManagers.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var ruleManager = orderedManagers[i]; - if (ruleManager.LastJsonContribution is not { } lastContribution) - { - continue; - } - - var mergedConfig = GetOrCreateMergedConfig(mergedConfigs, ruleManager.TypeDefinition); - MutableJsonMerge.Merge(mergedConfig, lastContribution, ConfigMergeOptions.CaseInsensitive); - - _state.UpdateConfiguration(ruleManager.TypeDefinition, mergedConfig); - } - } - - private void RecomputeSuffix( - IReadOnlyList orderedManagers, - int startIndex, - IConfigurationAccessor configAccessor, - Dictionary mergedConfigs, - CancellationToken cancellationToken) - { - for (var i = startIndex; i < orderedManagers.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var ruleManager = orderedManagers[i]; - using var ruleActivity = CocoarMetrics.ActivitySource.StartActivity("cocoar.config.rule"); - ruleActivity?.SetTag("rule_type", ruleManager.TypeDefinition.Name); - ruleActivity?.SetTag("rule_index", i); - ruleActivity?.SetTag("required", ruleManager.Required); - - var bytes = ruleManager.ComputeAsync(configAccessor, cancellationToken).GetAwaiter().GetResult(); - - ProcessRuleResult(ruleManager, bytes, mergedConfigs); - } - } - - private async Task RecomputeSuffixAsync( - IReadOnlyList orderedManagers, - int startIndex, - IConfigurationAccessor configAccessor, - Dictionary mergedConfigs, - CancellationToken cancellationToken) - { - for (var i = startIndex; i < orderedManagers.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var ruleManager = orderedManagers[i]; - using var ruleActivity = CocoarMetrics.ActivitySource.StartActivity("cocoar.config.rule"); - ruleActivity?.SetTag("rule_type", ruleManager.TypeDefinition.Name); - ruleActivity?.SetTag("rule_index", i); - ruleActivity?.SetTag("required", ruleManager.Required); - - var bytes = await ruleManager.ComputeAsync(configAccessor, cancellationToken).ConfigureAwait(false); - - ProcessRuleResult(ruleManager, bytes, mergedConfigs); - } - } - - private void ProcessRuleResult( - IRuleManager ruleManager, - ReadOnlyMemory? bytes, - Dictionary mergedConfigs) - { - if (!bytes.HasValue) - { - ruleManager.LastJsonContribution = null; - return; - } - - MutableJsonObject newContribution; - try - { - var node = MutableJsonDocument.Parse(bytes.Value.Span); - if (node is not MutableJsonObject obj) - { - throw new JsonException($"Expected JSON object for configuration type {ruleManager.TypeDefinition.Name}, got {node.Kind}"); - } - - newContribution = obj; - } - catch (Exception ex) when (ex is JsonException || ex is FormatException) - { - if (ruleManager.Required) - { - throw new InvalidOperationException($"Required rule failed during parse for {ruleManager.TypeDefinition.Name}", ex); - } - ruleManager.LastJsonContribution = null; - return; - } - - var mergedConfig = GetOrCreateMergedConfig(mergedConfigs, ruleManager.TypeDefinition); - - // Lock on mergedConfig to prevent readers from serializing while we're merging - lock (mergedConfig) - { - MutableJsonMerge.Merge(mergedConfig, newContribution, ConfigMergeOptions.CaseInsensitive); - } - - ruleManager.LastJsonContribution = newContribution; - _state.UpdateConfiguration(ruleManager.TypeDefinition, mergedConfig); - } - - private static MutableJsonObject GetOrCreateMergedConfig( - Dictionary mergedConfigs, - Type type) - { - if (!mergedConfigs.TryGetValue(type, out var config)) - { - config = new MutableJsonObject(); - mergedConfigs[type] = config; - } - - return config; - } - - private void CreateChangeSubscriptions( - IReadOnlyList ruleManagers, - Action recomputeFromIndexCallback, - int debounceMilliseconds) - { - DisposeAllSubscriptions(); - - var coalescer = new RecomputeCoalescer(_logger, recomputeFromIndexCallback, debounceMilliseconds, 40); - _changeSubscriptions.Add(coalescer); - - for (var i = 0; i < ruleManagers.Count; i++) - { - var idx = i; - var rm = ruleManagers[i]; - var subscription = rm.Changes.Subscribe(_ => - { - try - { - coalescer.Signal(idx); - } - catch (Exception ex) - { - _logger.RecomputeFailedFromChange(ex); - } - }); - _changeSubscriptions.Add(subscription); - } - } - - private void DisposeAllSubscriptions() - { - foreach (var subscription in _changeSubscriptions.ToArray()) - { - Safety.DisposeQuietly(subscription); - } - - _changeSubscriptions.Clear(); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - _scheduler.Dispose(); - DisposeAllSubscriptions(); - _recomputeSemaphore.Dispose(); - } - - public async ValueTask DisposeAsync() - { - if (_disposed) return; - _disposed = true; - - await _scheduler.DisposeAsync().ConfigureAwait(false); - DisposeAllSubscriptions(); - _recomputeSemaphore.Dispose(); - } -} +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using Cocoar.Capabilities; +using Cocoar.Configuration.Diagnostics; +using Cocoar.Configuration.Infrastructure; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Utilities; +using Cocoar.Json.Mutable; + +namespace Cocoar.Configuration.Core; + +internal static partial class ConfigurationEngineLog +{ + [LoggerMessage(EventId = 2000, Level = LogLevel.Error, Message = "ConfigManager initialization failed")] + public static partial void InitializationFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = 2001, Level = LogLevel.Error, Message = "Runtime recompute failed - preserving current configuration")] + public static partial void RuntimeRecomputeFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = 2002, Level = LogLevel.Debug, Message = "Recompute started")] + public static partial void RecomputeStarted(this ILogger logger); + + [LoggerMessage(EventId = 2003, Level = LogLevel.Debug, Message = "Recompute cancelled")] + public static partial void RecomputeCancelled(this ILogger logger); + + [LoggerMessage(EventId = 2004, Level = LogLevel.Debug, Message = "Recompute finished")] + public static partial void RecomputeFinished(this ILogger logger); + + [LoggerMessage(EventId = 2005, Level = LogLevel.Error, Message = "Recompute failed from change trigger")] + public static partial void RecomputeFailedFromChange(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = 2006, Level = LogLevel.Information, Message = "Startup phase complete - switching to resilient mode")] + public static partial void StartupComplete(this ILogger logger); +} + +/// +/// Central engine for configuration computation and change management. +/// Handles initialization, recomputation, and change subscriptions. +/// Delegates scheduling/cancellation to RecomputeScheduler. +/// +internal class ConfigurationEngine : IDisposable, IAsyncDisposable +{ + private readonly ConfigurationState _state; + private readonly ILogger _logger; + private readonly SemaphoreSlim _recomputeSemaphore = new(1, 1); + private readonly RecomputeScheduler _scheduler = new(); + + private bool _disposed; + private readonly List _changeSubscriptions = []; + + // Context for deserialization + private ExposureRegistry? _bindingRegistry; + private ConfigManagerCapabilityScope? _capabilityScope; + + public ConfigurationEngine(ConfigurationState state, ILogger logger) + { + _state = state; + _logger = logger; + } + + public Task? CurrentRecomputeTask => _scheduler.CurrentRecomputeTask; + + /// + /// Initializes the configuration system: analyzes rules, creates managers, performs initial computation, and sets up subscriptions. + /// Throws ConfigurationDeserializationException if any deserialization fails during startup (fail-fast behavior). + /// + public void InitializeAndCompute( + List rules, + List ruleManagers, + ProviderRegistry providerRegistry, + IConfigurationAccessor configAccessor, + ExposureRegistry bindingRegistry, + ConfigManagerCapabilityScope capabilityScope, + Action scheduleRecomputeCallback, + int debounceMilliseconds) + { + // Store context for runtime recomputes + _bindingRegistry = bindingRegistry; + _capabilityScope = capabilityScope; + + // Initialize the backplane + _state.InitializeBackplane(bindingRegistry); + + ruleManagers.Clear(); + foreach (var rule in rules) + { + if (rule is AggregateConfigRule aggregate) + ruleManagers.Add(new AggregateRuleManager(aggregate, _logger, providerRegistry)); + else + ruleManagers.Add(new RuleManager(rule, _logger, providerRegistry)); + } + + try + { + // During startup, deserialization failures throw + RecomputeAllConfigurationsSafe(ruleManagers, configAccessor); + CreateChangeSubscriptions(ruleManagers, scheduleRecomputeCallback, debounceMilliseconds); + + _state.UpdateHealth(); + + // Mark startup complete - future failures will preserve last good config + _state.MarkStartupComplete(); + _logger.StartupComplete(); + } + catch (Exception ex) + { + _logger.InitializationFailed(ex); + _state.UpdateHealth(); + throw; + } + } + + /// + /// Schedules a configuration recomputation starting from the given index. + /// Cancels any in-flight recompute and starts a new one. + /// + public void ScheduleRecompute( + List ruleManagers, + IConfigurationAccessor configAccessor, + int startIndex) + { + _scheduler.ScheduleAsync(async ct => + { + try + { + await RecomputeAllConfigurationsSafeAsync(ruleManagers, configAccessor, startIndex, ct).ConfigureAwait(false); + _state.UpdateHealth(); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.RuntimeRecomputeFailed(ex); + _state.UpdateHealth(); + } + }); + } + + /// + /// Runs a recompute from directly to completion and updates health — the same + /// post-recompute work the lambda does, but awaitable and WITHOUT the + /// cancel-on-reschedule scheduler. Used by the DI Layer-2 activation so a concurrent change cannot cancel it + /// and so health reflects a degraded Layer-2 source. Never throws (failures are caught + health updated, + /// matching the scheduler path). + /// + public async Task RecomputeAndUpdateHealthAsync( + IReadOnlyList ruleManagers, + IConfigurationAccessor configAccessor, + int startIndex, + CancellationToken cancellationToken = default) + { + try + { + await RecomputeAllConfigurationsSafeAsync(ruleManagers, configAccessor, startIndex, cancellationToken).ConfigureAwait(false); + _state.UpdateHealth(); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.RuntimeRecomputeFailed(ex); + _state.UpdateHealth(); + } + } + + /// + /// Async variant of . Used by . + /// + public async Task InitializeAndComputeAsync( + List rules, + List ruleManagers, + ProviderRegistry providerRegistry, + IConfigurationAccessor configAccessor, + ExposureRegistry bindingRegistry, + ConfigManagerCapabilityScope capabilityScope, + Action scheduleRecomputeCallback, + int debounceMilliseconds, + CancellationToken cancellationToken = default) + { + _bindingRegistry = bindingRegistry; + _capabilityScope = capabilityScope; + + _state.InitializeBackplane(bindingRegistry); + + ruleManagers.Clear(); + foreach (var rule in rules) + { + if (rule is AggregateConfigRule aggregate) + ruleManagers.Add(new AggregateRuleManager(aggregate, _logger, providerRegistry)); + else + ruleManagers.Add(new RuleManager(rule, _logger, providerRegistry)); + } + + try + { + await RecomputeAllConfigurationsSafeAsync(ruleManagers, configAccessor, 0, cancellationToken).ConfigureAwait(false); + CreateChangeSubscriptions(ruleManagers, scheduleRecomputeCallback, debounceMilliseconds); + + _state.UpdateHealth(); + _state.MarkStartupComplete(); + _logger.StartupComplete(); + } + catch (Exception ex) + { + _logger.InitializationFailed(ex); + _state.UpdateHealth(); + throw; + } + } + + /// + /// Recomputes all configurations starting from the given index, with semaphore protection and error handling. + /// + public void RecomputeAllConfigurationsSafe( + IReadOnlyList ruleManagers, + IConfigurationAccessor configAccessor, + int startIndex = 0, + CancellationToken cancellationToken = default) + { + _recomputeSemaphore.Wait(cancellationToken); + try + { + using var activity = CocoarMetrics.ActivitySource.StartActivity("cocoar.config.recompute"); + activity?.SetTag("rule_count", ruleManagers.Count); + activity?.SetTag("start_index", startIndex); + + _logger.RecomputeStarted(); + var sw = Stopwatch.StartNew(); + try + { + RecomputeAllConfigurations(ruleManagers, configAccessor, startIndex, cancellationToken); + sw.Stop(); + activity?.SetTag("status", "success"); + CocoarMetrics.RecomputeCount.Add(1, new KeyValuePair("status", "success")); + CocoarMetrics.RecomputeDuration.Record(sw.Elapsed.TotalMilliseconds); + } + catch (OperationCanceledException) + { + sw.Stop(); + activity?.SetTag("status", "cancelled"); + activity?.SetStatus(ActivityStatusCode.Error, "Cancelled"); + CocoarMetrics.RecomputeCount.Add(1, new KeyValuePair("status", "cancelled")); + CocoarMetrics.RecomputeDuration.Record(sw.Elapsed.TotalMilliseconds); + _logger.RecomputeCancelled(); + RollbackSafely(); + throw; + } + catch (Exception ex) + { + sw.Stop(); + activity?.SetTag("status", "failure"); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + CocoarMetrics.RecomputeCount.Add(1, new KeyValuePair("status", "failure")); + CocoarMetrics.RecomputeDuration.Record(sw.Elapsed.TotalMilliseconds); + RollbackSafely(); + throw; + } + finally + { + _logger.RecomputeFinished(); + } + } + finally + { + _recomputeSemaphore.Release(); + } + } + + /// + /// Async version of RecomputeAllConfigurationsSafe. + /// + public async Task RecomputeAllConfigurationsSafeAsync( + IReadOnlyList ruleManagers, + IConfigurationAccessor configAccessor, + int startIndex = 0, + CancellationToken cancellationToken = default) + { + await _recomputeSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + using var activity = CocoarMetrics.ActivitySource.StartActivity("cocoar.config.recompute"); + activity?.SetTag("rule_count", ruleManagers.Count); + activity?.SetTag("start_index", startIndex); + + _logger.RecomputeStarted(); + var sw = Stopwatch.StartNew(); + try + { + await RecomputeAllConfigurationsAsync(ruleManagers, configAccessor, startIndex, cancellationToken).ConfigureAwait(false); + sw.Stop(); + activity?.SetTag("status", "success"); + CocoarMetrics.RecomputeCount.Add(1, new KeyValuePair("status", "success")); + CocoarMetrics.RecomputeDuration.Record(sw.Elapsed.TotalMilliseconds); + } + catch (OperationCanceledException) + { + sw.Stop(); + activity?.SetTag("status", "cancelled"); + activity?.SetStatus(ActivityStatusCode.Error, "Cancelled"); + CocoarMetrics.RecomputeCount.Add(1, new KeyValuePair("status", "cancelled")); + CocoarMetrics.RecomputeDuration.Record(sw.Elapsed.TotalMilliseconds); + _logger.RecomputeCancelled(); + RollbackSafely(); + throw; + } + catch (Exception ex) + { + sw.Stop(); + activity?.SetTag("status", "failure"); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + CocoarMetrics.RecomputeCount.Add(1, new KeyValuePair("status", "failure")); + CocoarMetrics.RecomputeDuration.Record(sw.Elapsed.TotalMilliseconds); + RollbackSafely(); + throw; + } + finally + { + _logger.RecomputeFinished(); + } + } + finally + { + _recomputeSemaphore.Release(); + } + } + + private void RollbackSafely() + { + try + { + _state.RollbackUpdate(); + } + catch (Exception rollbackEx) + { + _logger.RuntimeRecomputeFailed(rollbackEx); + } + } + + private void RecomputeAllConfigurations( + IReadOnlyList ruleManagers, + IConfigurationAccessor configAccessor, + int startIndex = 0, + CancellationToken cancellationToken = default) + { + var mergedConfigs = new Dictionary(); + _state.BeginUpdate(); + + cancellationToken.ThrowIfCancellationRequested(); + + RestorePrefixContributions(ruleManagers, startIndex, mergedConfigs, cancellationToken); + RecomputeSuffix(ruleManagers, startIndex, configAccessor, mergedConfigs, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + // Use new method with eager deserialization + if (_bindingRegistry != null && _capabilityScope != null) + { + _state.CommitUpdateWithDeserialization(mergedConfigs, _bindingRegistry, _capabilityScope); + } + else + { + // Fallback for tests or edge cases + _state.CommitUpdate(mergedConfigs); + } + } + + private async Task RecomputeAllConfigurationsAsync( + IReadOnlyList ruleManagers, + IConfigurationAccessor configAccessor, + int startIndex = 0, + CancellationToken cancellationToken = default) + { + var mergedConfigs = new Dictionary(); + _state.BeginUpdate(); + + cancellationToken.ThrowIfCancellationRequested(); + + RestorePrefixContributions(ruleManagers, startIndex, mergedConfigs, cancellationToken); + await RecomputeSuffixAsync(ruleManagers, startIndex, configAccessor, mergedConfigs, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + // Use new method with eager deserialization + if (_bindingRegistry != null && _capabilityScope != null) + { + _state.CommitUpdateWithDeserialization(mergedConfigs, _bindingRegistry, _capabilityScope); + } + else + { + // Fallback for tests or edge cases + _state.CommitUpdate(mergedConfigs); + } + } + + private void RestorePrefixContributions( + IReadOnlyList orderedManagers, + int startIndex, + Dictionary mergedConfigs, + CancellationToken cancellationToken) + { + if (startIndex <= 0) + { + return; + } + + for (var i = 0; i < startIndex && i < orderedManagers.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var ruleManager = orderedManagers[i]; + if (ruleManager.LastJsonContribution is not { } lastContribution) + { + continue; + } + + var mergedConfig = GetOrCreateMergedConfig(mergedConfigs, ruleManager.TypeDefinition); + MutableJsonMerge.Merge(mergedConfig, lastContribution, ConfigMergeOptions.CaseInsensitive); + + _state.UpdateConfiguration(ruleManager.TypeDefinition, mergedConfig); + } + } + + private void RecomputeSuffix( + IReadOnlyList orderedManagers, + int startIndex, + IConfigurationAccessor configAccessor, + Dictionary mergedConfigs, + CancellationToken cancellationToken) + { + for (var i = startIndex; i < orderedManagers.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var ruleManager = orderedManagers[i]; + using var ruleActivity = CocoarMetrics.ActivitySource.StartActivity("cocoar.config.rule"); + ruleActivity?.SetTag("rule_type", ruleManager.TypeDefinition.Name); + ruleActivity?.SetTag("rule_index", i); + ruleActivity?.SetTag("required", ruleManager.Required); + + var bytes = ruleManager.ComputeAsync(configAccessor, cancellationToken).GetAwaiter().GetResult(); + + ProcessRuleResult(ruleManager, bytes, mergedConfigs); + } + } + + private async Task RecomputeSuffixAsync( + IReadOnlyList orderedManagers, + int startIndex, + IConfigurationAccessor configAccessor, + Dictionary mergedConfigs, + CancellationToken cancellationToken) + { + for (var i = startIndex; i < orderedManagers.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var ruleManager = orderedManagers[i]; + using var ruleActivity = CocoarMetrics.ActivitySource.StartActivity("cocoar.config.rule"); + ruleActivity?.SetTag("rule_type", ruleManager.TypeDefinition.Name); + ruleActivity?.SetTag("rule_index", i); + ruleActivity?.SetTag("required", ruleManager.Required); + + var bytes = await ruleManager.ComputeAsync(configAccessor, cancellationToken).ConfigureAwait(false); + + ProcessRuleResult(ruleManager, bytes, mergedConfigs); + } + } + + private void ProcessRuleResult( + IRuleManager ruleManager, + ReadOnlyMemory? bytes, + Dictionary mergedConfigs) + { + if (!bytes.HasValue) + { + ruleManager.LastJsonContribution = null; + return; + } + + MutableJsonObject newContribution; + try + { + var node = MutableJsonDocument.Parse(bytes.Value.Span); + if (node is not MutableJsonObject obj) + { + throw new JsonException($"Expected JSON object for configuration type {ruleManager.TypeDefinition.Name}, got {node.Kind}"); + } + + newContribution = obj; + } + catch (Exception ex) when (ex is JsonException || ex is FormatException) + { + if (ruleManager.Required) + { + throw new InvalidOperationException($"Required rule failed during parse for {ruleManager.TypeDefinition.Name}", ex); + } + ruleManager.LastJsonContribution = null; + return; + } + + var mergedConfig = GetOrCreateMergedConfig(mergedConfigs, ruleManager.TypeDefinition); + + // Lock on mergedConfig to prevent readers from serializing while we're merging + lock (mergedConfig) + { + MutableJsonMerge.Merge(mergedConfig, newContribution, ConfigMergeOptions.CaseInsensitive); + } + + ruleManager.LastJsonContribution = newContribution; + _state.UpdateConfiguration(ruleManager.TypeDefinition, mergedConfig); + } + + private static MutableJsonObject GetOrCreateMergedConfig( + Dictionary mergedConfigs, + Type type) + { + if (!mergedConfigs.TryGetValue(type, out var config)) + { + config = new MutableJsonObject(); + mergedConfigs[type] = config; + } + + return config; + } + + private void CreateChangeSubscriptions( + IReadOnlyList ruleManagers, + Action recomputeFromIndexCallback, + int debounceMilliseconds) + { + DisposeAllSubscriptions(); + + var coalescer = new RecomputeCoalescer(_logger, recomputeFromIndexCallback, debounceMilliseconds, 40); + _changeSubscriptions.Add(coalescer); + + for (var i = 0; i < ruleManagers.Count; i++) + { + var idx = i; + var rm = ruleManagers[i]; + var subscription = rm.Changes.Subscribe(_ => + { + try + { + coalescer.Signal(idx); + } + catch (Exception ex) + { + _logger.RecomputeFailedFromChange(ex); + } + }); + _changeSubscriptions.Add(subscription); + } + } + + private void DisposeAllSubscriptions() + { + foreach (var subscription in _changeSubscriptions.ToArray()) + { + Safety.DisposeQuietly(subscription); + } + + _changeSubscriptions.Clear(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _scheduler.Dispose(); + DisposeAllSubscriptions(); + _recomputeSemaphore.Dispose(); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + await _scheduler.DisposeAsync().ConfigureAwait(false); + DisposeAllSubscriptions(); + _recomputeSemaphore.Dispose(); + } +} diff --git a/src/Cocoar.Configuration/Core/ConfigurationState.cs b/src/Cocoar.Configuration/Core/ConfigurationState.cs index c1f5270..0201f2a 100644 --- a/src/Cocoar.Configuration/Core/ConfigurationState.cs +++ b/src/Cocoar.Configuration/Core/ConfigurationState.cs @@ -1,184 +1,184 @@ -using System.Text.Json; -using Cocoar.Capabilities; -using Cocoar.Configuration.Health; -using Cocoar.Configuration.Infrastructure; -using Cocoar.Configuration.Rules; -using Cocoar.Json.Mutable; -using Microsoft.Extensions.Logging; - -namespace Cocoar.Configuration.Core; - -internal static partial class ConfigurationStateLog -{ - [LoggerMessage(EventId = 4000, Level = LogLevel.Error, - Message = "Deserialization failed for {TypeName}: {Message}")] - public static partial void DeserializationFailed(this ILogger logger, Exception? ex, string typeName, string message); - - [LoggerMessage(EventId = 4001, Level = LogLevel.Warning, - Message = "Runtime deserialization failed for {FailureCount} types, keeping last good configuration")] - public static partial void RuntimeDeserializationFailed(this ILogger logger, int failureCount); - - [LoggerMessage(EventId = 4002, Level = LogLevel.Information, - Message = "Configuration snapshot published: version={Version}, types={TypeCount}")] - public static partial void SnapshotPublished(this ILogger logger, long version, int typeCount); -} - -/// -/// Central state management for configurations and health monitoring. -/// Coordinates JSON storage, backplane publication, and health tracking. -/// Uses MasterBackplane for atomic, eager-deserialized configuration updates. -/// -internal class ConfigurationState : IDisposable -{ - private readonly ConfigJsonRepository _jsonRepository = new(); - private readonly ConfigurationHealthTracker _tracker; - private readonly ILogger _logger; - private long _configVersion; - - // Master Backplane fields - private MasterBackplane? _backplane; - private bool _isStartupPhase = true; - private IReadOnlyList _lastDeserializationFailures = []; - - public ConfigurationState(List ruleManagers, List rules, ILogger logger, IFlagsHealthSource? flagsHealthSource = null) - { - _logger = logger; - _tracker = new ConfigurationHealthTracker(ruleManagers, flagsHealthSource); - } - - /// - /// Gets the MasterBackplane for accessing cached configuration instances. - /// - public MasterBackplane Backplane => _backplane ?? throw new InvalidOperationException("Backplane not initialized. Call InitializeBackplane first."); - - /// - /// Indicates whether the system is still in the startup phase. - /// During startup, deserialization failures throw exceptions. - /// After startup, failures preserve the last good configuration. - /// - public bool IsStartupPhase => _isStartupPhase; - - /// - /// Gets the last deserialization failures that occurred during runtime (non-startup) updates. - /// - public IReadOnlyList LastDeserializationFailures => _lastDeserializationFailures; - - /// - /// Gets the current configuration dictionary (or pending if available). - /// Thread-safe access to avoid race conditions. - /// - public Dictionary CurrentConfigurations => _jsonRepository.CurrentConfigurations; - - /// - /// Initializes the MasterBackplane with the binding registry. - /// Must be called before CommitUpdateWithDeserialization. - /// - internal void InitializeBackplane(ExposureRegistry bindingRegistry) - { - _backplane = new MasterBackplane(bindingRegistry); - } - - /// - /// Marks the startup phase as complete. - /// After this, deserialization failures will preserve last good configuration instead of throwing. - /// - public void MarkStartupComplete() - { - _isStartupPhase = false; - } - - public void BeginUpdate() => _jsonRepository.BeginUpdate(); - - public void UpdateConfiguration(Type type, MutableJsonObject value) => _jsonRepository.UpdateConfiguration(type, value); - - /// - /// Commits the update with eager deserialization to the MasterBackplane. - /// During startup, throws on any deserialization failure. - /// At runtime, keeps last good configuration on failure. - /// - public void CommitUpdateWithDeserialization( - Dictionary finalConfigurations, - ExposureRegistry bindingRegistry, - ConfigManagerCapabilityScope capabilityScope) - { - // Build the snapshot with eager deserialization - var builder = new ConfigSnapshotBuilder(bindingRegistry, capabilityScope); - - foreach (var (type, json) in finalConfigurations) - { - builder.DeserializeType(type, json); - } - - if (_isStartupPhase) - { - // Startup: fail fast with all errors - var snapshot = builder.Build(++_configVersion); - _backplane?.Publish(snapshot); - _logger.SnapshotPublished(snapshot.Version, snapshot.Count); - - // Store the raw JSON for backward compatibility with GetConfigurationAsJson - _jsonRepository.CommitUpdate(finalConfigurations); - } - else - { - // Runtime: keep last good on failure - var (snapshot, failures) = builder.TryBuild(++_configVersion); - - if (snapshot != null) - { - _lastDeserializationFailures = []; - _backplane?.Publish(snapshot); - _logger.SnapshotPublished(snapshot.Version, snapshot.Count); - - // Only update JSON when deserialization succeeds - keeps consistency - // between GetConfig() (cached instances) and GetConfigAsJson() (raw JSON) - _jsonRepository.CommitUpdate(finalConfigurations); - } - else - { - _lastDeserializationFailures = failures; - _logger.RuntimeDeserializationFailed(failures.Count); - - foreach (var failure in failures) - { - _logger.DeserializationFailed(failure.Exception, failure.ConfigType.Name, failure.Message); - } - - // Rollback: keep old JSON AND old cached instances - // Don't update _configs - keep the last good JSON - // Don't decrement version - we want to track that an attempt was made - _jsonRepository.RollbackUpdate(); - } - } - } - - /// - /// Legacy commit that only stores JSON without deserialization. - /// Used during the transition period. - /// - public void CommitUpdate(Dictionary finalConfigurations) - => _jsonRepository.CommitUpdate(finalConfigurations); - - public void RollbackUpdate() => _jsonRepository.RollbackUpdate(); - - public Type? FindRegistration() => _jsonRepository.FindRegistration(); - - public Type? FindRegistration(Type type) => _jsonRepository.FindRegistration(type); - - public bool TryGetConfiguration(out MutableJsonObject? value) => _jsonRepository.TryGetConfiguration(out value); - - public bool TryGetConfiguration(Type type, out MutableJsonObject? value) => _jsonRepository.TryGetConfiguration(type, out value); - - public JsonElement? GetConfigurationAsJson(Type type) => _jsonRepository.GetConfigurationAsJson(type); - - public HealthStatus HealthStatus => _tracker.Status; - public bool IsHealthy => _tracker.Status == HealthStatus.Healthy; - internal string HealthDescription => _tracker.Description; - - public void UpdateHealth() => _tracker.UpdateAfterRecompute(); - - public void Dispose() - { - _backplane?.Dispose(); - } -} +using System.Text.Json; +using Cocoar.Capabilities; +using Cocoar.Configuration.Health; +using Cocoar.Configuration.Infrastructure; +using Cocoar.Configuration.Rules; +using Cocoar.Json.Mutable; +using Microsoft.Extensions.Logging; + +namespace Cocoar.Configuration.Core; + +internal static partial class ConfigurationStateLog +{ + [LoggerMessage(EventId = 4000, Level = LogLevel.Error, + Message = "Deserialization failed for {TypeName}: {Message}")] + public static partial void DeserializationFailed(this ILogger logger, Exception? ex, string typeName, string message); + + [LoggerMessage(EventId = 4001, Level = LogLevel.Warning, + Message = "Runtime deserialization failed for {FailureCount} types, keeping last good configuration")] + public static partial void RuntimeDeserializationFailed(this ILogger logger, int failureCount); + + [LoggerMessage(EventId = 4002, Level = LogLevel.Information, + Message = "Configuration snapshot published: version={Version}, types={TypeCount}")] + public static partial void SnapshotPublished(this ILogger logger, long version, int typeCount); +} + +/// +/// Central state management for configurations and health monitoring. +/// Coordinates JSON storage, backplane publication, and health tracking. +/// Uses MasterBackplane for atomic, eager-deserialized configuration updates. +/// +internal class ConfigurationState : IDisposable +{ + private readonly ConfigJsonRepository _jsonRepository = new(); + private readonly ConfigurationHealthTracker _tracker; + private readonly ILogger _logger; + private long _configVersion; + + // Master Backplane fields + private MasterBackplane? _backplane; + private bool _isStartupPhase = true; + private IReadOnlyList _lastDeserializationFailures = []; + + public ConfigurationState(List ruleManagers, List rules, ILogger logger, IFlagsHealthSource? flagsHealthSource = null) + { + _logger = logger; + _tracker = new ConfigurationHealthTracker(ruleManagers, flagsHealthSource); + } + + /// + /// Gets the MasterBackplane for accessing cached configuration instances. + /// + public MasterBackplane Backplane => _backplane ?? throw new InvalidOperationException("Backplane not initialized. Call InitializeBackplane first."); + + /// + /// Indicates whether the system is still in the startup phase. + /// During startup, deserialization failures throw exceptions. + /// After startup, failures preserve the last good configuration. + /// + public bool IsStartupPhase => _isStartupPhase; + + /// + /// Gets the last deserialization failures that occurred during runtime (non-startup) updates. + /// + public IReadOnlyList LastDeserializationFailures => _lastDeserializationFailures; + + /// + /// Gets the current configuration dictionary (or pending if available). + /// Thread-safe access to avoid race conditions. + /// + public Dictionary CurrentConfigurations => _jsonRepository.CurrentConfigurations; + + /// + /// Initializes the MasterBackplane with the binding registry. + /// Must be called before CommitUpdateWithDeserialization. + /// + internal void InitializeBackplane(ExposureRegistry bindingRegistry) + { + _backplane = new MasterBackplane(bindingRegistry); + } + + /// + /// Marks the startup phase as complete. + /// After this, deserialization failures will preserve last good configuration instead of throwing. + /// + public void MarkStartupComplete() + { + _isStartupPhase = false; + } + + public void BeginUpdate() => _jsonRepository.BeginUpdate(); + + public void UpdateConfiguration(Type type, MutableJsonObject value) => _jsonRepository.UpdateConfiguration(type, value); + + /// + /// Commits the update with eager deserialization to the MasterBackplane. + /// During startup, throws on any deserialization failure. + /// At runtime, keeps last good configuration on failure. + /// + public void CommitUpdateWithDeserialization( + Dictionary finalConfigurations, + ExposureRegistry bindingRegistry, + ConfigManagerCapabilityScope capabilityScope) + { + // Build the snapshot with eager deserialization + var builder = new ConfigSnapshotBuilder(bindingRegistry, capabilityScope); + + foreach (var (type, json) in finalConfigurations) + { + builder.DeserializeType(type, json); + } + + if (_isStartupPhase) + { + // Startup: fail fast with all errors + var snapshot = builder.Build(++_configVersion); + _backplane?.Publish(snapshot); + _logger.SnapshotPublished(snapshot.Version, snapshot.Count); + + // Store the raw JSON for backward compatibility with GetConfigurationAsJson + _jsonRepository.CommitUpdate(finalConfigurations); + } + else + { + // Runtime: keep last good on failure + var (snapshot, failures) = builder.TryBuild(++_configVersion); + + if (snapshot != null) + { + _lastDeserializationFailures = []; + _backplane?.Publish(snapshot); + _logger.SnapshotPublished(snapshot.Version, snapshot.Count); + + // Only update JSON when deserialization succeeds - keeps consistency + // between GetConfig() (cached instances) and GetConfigAsJson() (raw JSON) + _jsonRepository.CommitUpdate(finalConfigurations); + } + else + { + _lastDeserializationFailures = failures; + _logger.RuntimeDeserializationFailed(failures.Count); + + foreach (var failure in failures) + { + _logger.DeserializationFailed(failure.Exception, failure.ConfigType.Name, failure.Message); + } + + // Rollback: keep old JSON AND old cached instances + // Don't update _configs - keep the last good JSON + // Don't decrement version - we want to track that an attempt was made + _jsonRepository.RollbackUpdate(); + } + } + } + + /// + /// Legacy commit that only stores JSON without deserialization. + /// Used during the transition period. + /// + public void CommitUpdate(Dictionary finalConfigurations) + => _jsonRepository.CommitUpdate(finalConfigurations); + + public void RollbackUpdate() => _jsonRepository.RollbackUpdate(); + + public Type? FindRegistration() => _jsonRepository.FindRegistration(); + + public Type? FindRegistration(Type type) => _jsonRepository.FindRegistration(type); + + public bool TryGetConfiguration(out MutableJsonObject? value) => _jsonRepository.TryGetConfiguration(out value); + + public bool TryGetConfiguration(Type type, out MutableJsonObject? value) => _jsonRepository.TryGetConfiguration(type, out value); + + public JsonElement? GetConfigurationAsJson(Type type) => _jsonRepository.GetConfigurationAsJson(type); + + public HealthStatus HealthStatus => _tracker.Status; + public bool IsHealthy => _tracker.Status == HealthStatus.Healthy; + internal string HealthDescription => _tracker.Description; + + public void UpdateHealth() => _tracker.UpdateAfterRecompute(); + + public void Dispose() + { + _backplane?.Dispose(); + } +} diff --git a/src/Cocoar.Configuration/Core/MasterBackplane.cs b/src/Cocoar.Configuration/Core/MasterBackplane.cs index 1c60573..59379a4 100644 --- a/src/Cocoar.Configuration/Core/MasterBackplane.cs +++ b/src/Cocoar.Configuration/Core/MasterBackplane.cs @@ -1,169 +1,169 @@ -using System.Collections.Concurrent; -using Cocoar.Configuration.Infrastructure; - -namespace Cocoar.Configuration.Core; - -/// -/// Single source of truth for all configuration instances. -/// Provides atomic updates across all types and type-specific projections for reactive consumers. -/// -internal sealed class MasterBackplane : IDisposable -{ - private readonly SimpleBehaviorSubject _snapshotSubject; - private readonly ExposureRegistry _bindingRegistry; - private readonly ConcurrentDictionary _typeProjectionCache = new(); -#if NET9_0_OR_GREATER - private readonly Lock _publishLock = new(); -#else - private readonly object _publishLock = new(); -#endif - private bool _disposed; - - public MasterBackplane(ExposureRegistry bindingRegistry) - { - _bindingRegistry = bindingRegistry; - _snapshotSubject = new SimpleBehaviorSubject(ConfigSnapshot.Empty); - } - - /// - /// Gets the current configuration snapshot. - /// - public ConfigSnapshot CurrentSnapshot => _snapshotSubject.Value; - - /// - /// Observable stream of configuration snapshots. - /// Emits whenever a new snapshot is published. - /// - public IObservable SnapshotStream => _snapshotSubject; - - /// - /// Publishes a new configuration snapshot atomically. - /// All type projections will be updated in a single operation. - /// - /// The new snapshot to publish. - public void Publish(ConfigSnapshot snapshot) - { - lock (_publishLock) - { - ObjectDisposedException.ThrowIf(_disposed, this); - _snapshotSubject.OnNext(snapshot); - } - } - - /// - /// Gets an observable projection for a specific configuration type. - /// Uses ReferenceEquals for efficient change detection. - /// - /// The configuration type to project. - /// An observable that emits when the configuration instance reference changes. - public IObservable GetTypeProjection() where T : class - { - var type = typeof(T); - return (IObservable)_typeProjectionCache.GetOrAdd(type, _ => CreateTypeProjection()); - } - - /// - /// Gets a configuration instance from the current snapshot. - /// Supports interface-to-concrete type mapping. - /// - /// The configuration type to retrieve. - /// The configuration instance, or null if not found. - public T? GetConfig() where T : class - { - var snapshot = CurrentSnapshot; - - // Try direct type lookup first - var result = snapshot.GetConfig(); - if (result != null) - { - return result; - } - - // Try interface-to-concrete mapping - if (_bindingRegistry.TryGetConcreteType(typeof(T), out var concreteType)) - { - var concrete = snapshot.GetConfig(concreteType); - if (concrete is T typed) - { - return typed; - } - } - - return null; - } - - /// - /// Gets a configuration instance from the current snapshot. - /// Supports interface-to-concrete type mapping. - /// - /// The configuration type to retrieve. - /// The configuration instance, or null if not found. - public object? GetConfig(Type type) - { - var snapshot = CurrentSnapshot; - - // Try direct type lookup first - var result = snapshot.GetConfig(type); - if (result != null) - { - return result; - } - - // Try interface-to-concrete mapping - if (_bindingRegistry.TryGetConcreteType(type, out var concreteType)) - { - return snapshot.GetConfig(concreteType); - } - - return null; - } - - private IObservable CreateTypeProjection() where T : class - { - // Project the snapshot stream to the specific type - // Use DistinctUntilChanged with ReferenceEquals for efficient change detection - return _snapshotSubject - .Select(snapshot => - { - // First try direct lookup - var config = snapshot.GetConfig(); - if (config != null) return config; - - // Then try interface mapping - if (_bindingRegistry.TryGetConcreteType(typeof(T), out var concreteType)) - { - var concrete = snapshot.GetConfig(concreteType); - if (concrete is T typed) return typed; - } - - return null!; - }) - .Where(config => config != null) - .DistinctUntilChanged(ReferenceEqualityComparer.Instance); - } - - public void Dispose() - { - lock (_publishLock) - { - if (_disposed) return; - _disposed = true; - - _snapshotSubject.OnCompleted(); - _snapshotSubject.Dispose(); - } - - _typeProjectionCache.Clear(); - } - - /// - /// Comparer that uses ReferenceEquals for efficient change detection. - /// - private sealed class ReferenceEqualityComparer : IEqualityComparer where T : class - { - public static readonly ReferenceEqualityComparer Instance = new(); - - public bool Equals(T? x, T? y) => ReferenceEquals(x, y); - public int GetHashCode(T obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); - } -} +using System.Collections.Concurrent; +using Cocoar.Configuration.Infrastructure; + +namespace Cocoar.Configuration.Core; + +/// +/// Single source of truth for all configuration instances. +/// Provides atomic updates across all types and type-specific projections for reactive consumers. +/// +internal sealed class MasterBackplane : IDisposable +{ + private readonly SimpleBehaviorSubject _snapshotSubject; + private readonly ExposureRegistry _bindingRegistry; + private readonly ConcurrentDictionary _typeProjectionCache = new(); +#if NET9_0_OR_GREATER + private readonly Lock _publishLock = new(); +#else + private readonly object _publishLock = new(); +#endif + private bool _disposed; + + public MasterBackplane(ExposureRegistry bindingRegistry) + { + _bindingRegistry = bindingRegistry; + _snapshotSubject = new SimpleBehaviorSubject(ConfigSnapshot.Empty); + } + + /// + /// Gets the current configuration snapshot. + /// + public ConfigSnapshot CurrentSnapshot => _snapshotSubject.Value; + + /// + /// Observable stream of configuration snapshots. + /// Emits whenever a new snapshot is published. + /// + public IObservable SnapshotStream => _snapshotSubject; + + /// + /// Publishes a new configuration snapshot atomically. + /// All type projections will be updated in a single operation. + /// + /// The new snapshot to publish. + public void Publish(ConfigSnapshot snapshot) + { + lock (_publishLock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _snapshotSubject.OnNext(snapshot); + } + } + + /// + /// Gets an observable projection for a specific configuration type. + /// Uses ReferenceEquals for efficient change detection. + /// + /// The configuration type to project. + /// An observable that emits when the configuration instance reference changes. + public IObservable GetTypeProjection() where T : class + { + var type = typeof(T); + return (IObservable)_typeProjectionCache.GetOrAdd(type, _ => CreateTypeProjection()); + } + + /// + /// Gets a configuration instance from the current snapshot. + /// Supports interface-to-concrete type mapping. + /// + /// The configuration type to retrieve. + /// The configuration instance, or null if not found. + public T? GetConfig() where T : class + { + var snapshot = CurrentSnapshot; + + // Try direct type lookup first + var result = snapshot.GetConfig(); + if (result != null) + { + return result; + } + + // Try interface-to-concrete mapping + if (_bindingRegistry.TryGetConcreteType(typeof(T), out var concreteType)) + { + var concrete = snapshot.GetConfig(concreteType); + if (concrete is T typed) + { + return typed; + } + } + + return null; + } + + /// + /// Gets a configuration instance from the current snapshot. + /// Supports interface-to-concrete type mapping. + /// + /// The configuration type to retrieve. + /// The configuration instance, or null if not found. + public object? GetConfig(Type type) + { + var snapshot = CurrentSnapshot; + + // Try direct type lookup first + var result = snapshot.GetConfig(type); + if (result != null) + { + return result; + } + + // Try interface-to-concrete mapping + if (_bindingRegistry.TryGetConcreteType(type, out var concreteType)) + { + return snapshot.GetConfig(concreteType); + } + + return null; + } + + private IObservable CreateTypeProjection() where T : class + { + // Project the snapshot stream to the specific type + // Use DistinctUntilChanged with ReferenceEquals for efficient change detection + return _snapshotSubject + .Select(snapshot => + { + // First try direct lookup + var config = snapshot.GetConfig(); + if (config != null) return config; + + // Then try interface mapping + if (_bindingRegistry.TryGetConcreteType(typeof(T), out var concreteType)) + { + var concrete = snapshot.GetConfig(concreteType); + if (concrete is T typed) return typed; + } + + return null!; + }) + .Where(config => config != null) + .DistinctUntilChanged(ReferenceEqualityComparer.Instance); + } + + public void Dispose() + { + lock (_publishLock) + { + if (_disposed) return; + _disposed = true; + + _snapshotSubject.OnCompleted(); + _snapshotSubject.Dispose(); + } + + _typeProjectionCache.Clear(); + } + + /// + /// Comparer that uses ReferenceEquals for efficient change detection. + /// + private sealed class ReferenceEqualityComparer : IEqualityComparer where T : class + { + public static readonly ReferenceEqualityComparer Instance = new(); + + public bool Equals(T? x, T? y) => ReferenceEquals(x, y); + public int GetHashCode(T obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } +} diff --git a/src/Cocoar.Configuration/Core/RecomputeScheduler.cs b/src/Cocoar.Configuration/Core/RecomputeScheduler.cs index e31f469..28b985b 100644 --- a/src/Cocoar.Configuration/Core/RecomputeScheduler.cs +++ b/src/Cocoar.Configuration/Core/RecomputeScheduler.cs @@ -1,90 +1,90 @@ -using Cocoar.Configuration.Utilities; - -namespace Cocoar.Configuration.Core; - -/// -/// Manages scheduling and cancellation of configuration recompute operations. -/// Handles concurrency boundaries: cancels in-flight recomputes when new ones are triggered. -/// -internal sealed class RecomputeScheduler : IDisposable, IAsyncDisposable -{ -#if NET9_0_OR_GREATER - private readonly Lock _recomputeGate = new(); -#else - private readonly object _recomputeGate = new(); -#endif - private CancellationTokenSource? _recomputeCts; - private Task? _currentRecomputeTask; - private bool _disposed; - - /// - /// Gets the currently running recompute task, if any. - /// - public Task? CurrentRecomputeTask => _currentRecomputeTask; - - /// - /// Schedules a recompute operation. Cancels any in-flight recompute before starting a new one. - /// - /// The action to execute for recomputation. Receives a CancellationToken. - public void Schedule(Action recomputeAction) - { - lock (_recomputeGate) - { - var cts = RenewCancellationSource(); - - _currentRecomputeTask = Task.Run(() => - { - recomputeAction(cts.Token); - }, cts.Token); - } - } - - /// - /// Schedules an async recompute operation. Cancels any in-flight recompute before starting a new one. - /// - /// The async action to execute for recomputation. Receives a CancellationToken. - public void ScheduleAsync(Func recomputeAction) - { - lock (_recomputeGate) - { - var cts = RenewCancellationSource(); - - _currentRecomputeTask = Task.Run(async () => - { - await recomputeAction(cts.Token).ConfigureAwait(false); - }, cts.Token); - } - } - - private CancellationTokenSource RenewCancellationSource() - { - var newCts = new CancellationTokenSource(); - var previous = Interlocked.Exchange(ref _recomputeCts, newCts); - Safety.CancelAndDisposeQuietly(previous); - return newCts; - } - - private void DisposeCancellationSource() - { - var cts = Interlocked.Exchange(ref _recomputeCts, null); - Safety.CancelAndDisposeQuietly(cts); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - DisposeCancellationSource(); - } - - public async ValueTask DisposeAsync() - { - if (_disposed) return; - _disposed = true; - - DisposeCancellationSource(); - if (_currentRecomputeTask is { } task) - await task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); - } -} +using Cocoar.Configuration.Utilities; + +namespace Cocoar.Configuration.Core; + +/// +/// Manages scheduling and cancellation of configuration recompute operations. +/// Handles concurrency boundaries: cancels in-flight recomputes when new ones are triggered. +/// +internal sealed class RecomputeScheduler : IDisposable, IAsyncDisposable +{ +#if NET9_0_OR_GREATER + private readonly Lock _recomputeGate = new(); +#else + private readonly object _recomputeGate = new(); +#endif + private CancellationTokenSource? _recomputeCts; + private Task? _currentRecomputeTask; + private bool _disposed; + + /// + /// Gets the currently running recompute task, if any. + /// + public Task? CurrentRecomputeTask => _currentRecomputeTask; + + /// + /// Schedules a recompute operation. Cancels any in-flight recompute before starting a new one. + /// + /// The action to execute for recomputation. Receives a CancellationToken. + public void Schedule(Action recomputeAction) + { + lock (_recomputeGate) + { + var cts = RenewCancellationSource(); + + _currentRecomputeTask = Task.Run(() => + { + recomputeAction(cts.Token); + }, cts.Token); + } + } + + /// + /// Schedules an async recompute operation. Cancels any in-flight recompute before starting a new one. + /// + /// The async action to execute for recomputation. Receives a CancellationToken. + public void ScheduleAsync(Func recomputeAction) + { + lock (_recomputeGate) + { + var cts = RenewCancellationSource(); + + _currentRecomputeTask = Task.Run(async () => + { + await recomputeAction(cts.Token).ConfigureAwait(false); + }, cts.Token); + } + } + + private CancellationTokenSource RenewCancellationSource() + { + var newCts = new CancellationTokenSource(); + var previous = Interlocked.Exchange(ref _recomputeCts, newCts); + Safety.CancelAndDisposeQuietly(previous); + return newCts; + } + + private void DisposeCancellationSource() + { + var cts = Interlocked.Exchange(ref _recomputeCts, null); + Safety.CancelAndDisposeQuietly(cts); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + DisposeCancellationSource(); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + DisposeCancellationSource(); + if (_currentRecomputeTask is { } task) + await task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } +} diff --git a/src/Cocoar.Configuration/Diagnostics/CocoarMetrics.cs b/src/Cocoar.Configuration/Diagnostics/CocoarMetrics.cs index 7cb44fb..0b00277 100644 --- a/src/Cocoar.Configuration/Diagnostics/CocoarMetrics.cs +++ b/src/Cocoar.Configuration/Diagnostics/CocoarMetrics.cs @@ -1,22 +1,22 @@ -using System.Diagnostics; -using System.Diagnostics.Metrics; - -namespace Cocoar.Configuration.Diagnostics; - -internal static class CocoarMetrics -{ - public const string MeterName = "Cocoar.Configuration"; - internal static readonly Meter Instance = new(MeterName, "1.0.0"); - - internal static readonly Counter RecomputeCount = Instance.CreateCounter( - "cocoar.config.recompute.count", description: "Configuration recompute cycles"); - internal static readonly Histogram RecomputeDuration = Instance.CreateHistogram( - "cocoar.config.recompute.duration", unit: "ms"); - internal static readonly Counter ProviderErrors = Instance.CreateCounter( - "cocoar.config.provider.errors"); - internal static readonly Counter FlagEvaluations = Instance.CreateCounter( - "cocoar.config.flags.evaluations"); - - // Distributed tracing - internal static readonly ActivitySource ActivitySource = new(MeterName, "1.0.0"); -} +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Cocoar.Configuration.Diagnostics; + +internal static class CocoarMetrics +{ + public const string MeterName = "Cocoar.Configuration"; + internal static readonly Meter Instance = new(MeterName, "1.0.0"); + + internal static readonly Counter RecomputeCount = Instance.CreateCounter( + "cocoar.config.recompute.count", description: "Configuration recompute cycles"); + internal static readonly Histogram RecomputeDuration = Instance.CreateHistogram( + "cocoar.config.recompute.duration", unit: "ms"); + internal static readonly Counter ProviderErrors = Instance.CreateCounter( + "cocoar.config.provider.errors"); + internal static readonly Counter FlagEvaluations = Instance.CreateCounter( + "cocoar.config.flags.evaluations"); + + // Distributed tracing + internal static readonly ActivitySource ActivitySource = new(MeterName, "1.0.0"); +} diff --git a/src/Cocoar.Configuration/Extensibility/ISerializerSetupCapability.cs b/src/Cocoar.Configuration/Extensibility/ISerializerSetupCapability.cs index f69d798..7ea5739 100644 --- a/src/Cocoar.Configuration/Extensibility/ISerializerSetupCapability.cs +++ b/src/Cocoar.Configuration/Extensibility/ISerializerSetupCapability.cs @@ -1,15 +1,15 @@ -using System.Text.Json; - -namespace Cocoar.Configuration.Extensibility; - -/// -/// Capability interface for contributing to JSON serializer setup. -/// Implementations are retrieved from the composition and applied to configure JsonSerializerOptions. -/// -public interface ISerializerSetupCapability -{ - /// - /// Configure the JSON serializer options (e.g., add converters). - /// - void Configure(JsonSerializerOptions options); -} +using System.Text.Json; + +namespace Cocoar.Configuration.Extensibility; + +/// +/// Capability interface for contributing to JSON serializer setup. +/// Implementations are retrieved from the composition and applied to configure JsonSerializerOptions. +/// +public interface ISerializerSetupCapability +{ + /// + /// Configure the JSON serializer options (e.g., add converters). + /// + void Configure(JsonSerializerOptions options); +} diff --git a/src/Cocoar.Configuration/Extensibility/ISerializerSetupContributor.cs b/src/Cocoar.Configuration/Extensibility/ISerializerSetupContributor.cs index ac61c4c..2f35db1 100644 --- a/src/Cocoar.Configuration/Extensibility/ISerializerSetupContributor.cs +++ b/src/Cocoar.Configuration/Extensibility/ISerializerSetupContributor.cs @@ -1,11 +1,11 @@ -using System.Text.Json; - -namespace Cocoar.Configuration.Extensibility; - -/// -/// Allows external libraries to contribute JSON converters to the configuration deserializer. -/// -public interface ISerializerSetupContributor -{ - void Configure(JsonSerializerOptions options); -} +using System.Text.Json; + +namespace Cocoar.Configuration.Extensibility; + +/// +/// Allows external libraries to contribute JSON converters to the configuration deserializer. +/// +public interface ISerializerSetupContributor +{ + void Configure(JsonSerializerOptions options); +} diff --git a/src/Cocoar.Configuration/Flags/ConfigManagerFlagsExtensions.cs b/src/Cocoar.Configuration/Flags/ConfigManagerFlagsExtensions.cs index 412a94b..d88b3ee 100644 --- a/src/Cocoar.Configuration/Flags/ConfigManagerFlagsExtensions.cs +++ b/src/Cocoar.Configuration/Flags/ConfigManagerFlagsExtensions.cs @@ -1,135 +1,135 @@ -using System.Reflection; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Flags.Internal; -using Cocoar.Configuration.Reactive; - -namespace Cocoar.Configuration.Flags; - -/// -/// Extension methods on for resolving flag and entitlement -/// instances without a DI container. Instances are singletons — created once and cached -/// on the capability for the lifetime of the ConfigManager. -/// -public static class ConfigManagerFlagsExtensions -{ - private static readonly Type ReactiveConfigDef = typeof(IReactiveConfig<>); - private static readonly MethodInfo GetReactiveConfigMethod = - typeof(ConfigManager).GetMethod(nameof(ConfigManager.GetReactiveConfig))!; - private static readonly MethodInfo GetReactiveConfigForTenantMethod = - typeof(ConfigManager).GetMethod(nameof(ConfigManager.GetReactiveConfigForTenant))!; - - /// - /// Resolves the singleton instance of the specified feature flag class. - /// The instance is constructed once and cached for the lifetime of the manager. - /// - /// A feature flag class registered via UseFeatureFlags. - public static T GetFeatureFlags(this ConfigManager manager) where T : class - { - var setup = manager.FlagsSetup - ?? throw new InvalidOperationException( - "UseFeatureFlags has not been configured. Call .UseFeatureFlags() in ConfigManager.Create()."); - - return (T)setup.InstanceCache.GetOrAdd(typeof(T), _ => CreateInstance(manager)); - } - - /// - /// Resolves the singleton instance of the specified entitlement class. - /// The instance is constructed once and cached for the lifetime of the manager. - /// - /// An entitlement class registered via UseEntitlements. - public static T GetEntitlements(this ConfigManager manager) where T : class - { - var setup = manager.EntitlementsSetup - ?? throw new InvalidOperationException( - "UseEntitlements has not been configured. Call .UseEntitlements() in ConfigManager.Create()."); - - return (T)setup.InstanceCache.GetOrAdd(typeof(T), _ => CreateInstance(manager)); - } - - /// - /// Resolves the per-tenant singleton instance of the specified feature flag class — the SAME generated - /// class constructed with the tenant's own , so the flag evaluates against - /// that tenant's effective config (ADR-005 §7). No source-generator change. Cached per (tenant, T). - /// - /// A feature flag class registered via UseFeatureFlags. - /// Flags not configured, or the tenant is not initialized. - public static T GetFeatureFlagsForTenant(this ConfigManager manager, string tenantId) where T : class - { - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - var setup = manager.FlagsSetup - ?? throw new InvalidOperationException( - "UseFeatureFlags has not been configured. Call .UseFeatureFlags() in ConfigManager.Create()."); - EnsureTenantInitialized(manager, tenantId); - - return (T)setup.TenantInstanceCache.GetOrAdd((tenantId, typeof(T)), _ => CreateInstanceForTenant(manager, tenantId)); - } - - /// - /// Resolves the per-tenant singleton instance of the specified entitlement class — constructed with the - /// tenant's own (ADR-005 §7). No source-generator change. Cached per (tenant, T). - /// - /// An entitlement class registered via UseEntitlements. - /// Entitlements not configured, or the tenant is not initialized. - public static T GetEntitlementsForTenant(this ConfigManager manager, string tenantId) where T : class - { - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - var setup = manager.EntitlementsSetup - ?? throw new InvalidOperationException( - "UseEntitlements has not been configured. Call .UseEntitlements() in ConfigManager.Create()."); - EnsureTenantInitialized(manager, tenantId); - - return (T)setup.TenantInstanceCache.GetOrAdd((tenantId, typeof(T)), _ => CreateInstanceForTenant(manager, tenantId)); - } - - /// - /// Constructs a flag or entitlement class instance, resolving its - /// dependencies from the global ConfigManager. Parameterless constructors are also supported. - /// - private static T CreateInstance(ConfigManager manager) - => Construct(p => ResolveReactiveParameter(p, configType => - GetReactiveConfigMethod.MakeGenericMethod(configType).Invoke(manager, null)!)); - - private static void EnsureTenantInitialized(ConfigManager manager, string tenantId) - { - if (!manager.IsTenantInitialized(tenantId)) - { - throw new InvalidOperationException( - $"Tenant '{tenantId}' is not initialized. Call InitializeTenantAsync/EnsureTenantInitializedAsync first."); - } - } - - /// Tenant variant of — resolves the tenant's IReactiveConfig<T>. - private static T CreateInstanceForTenant(ConfigManager manager, string tenantId) - => Construct(p => ResolveReactiveParameter(p, configType => - GetReactiveConfigForTenantMethod.MakeGenericMethod(configType).Invoke(manager, [tenantId])!)); - - private static T Construct(Func resolveParameter) - { - var type = typeof(T); - - // Prefer the constructor with the fewest parameters to handle common cases - // where a parameterless ctor exists alongside injected ones. - var ctor = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) - .OrderBy(c => c.GetParameters().Length) - .FirstOrDefault() - ?? throw new InvalidOperationException( - $"No public constructor found on '{type.Name}'."); - - var args = ctor.GetParameters().Select(resolveParameter).ToArray(); - return (T)ctor.Invoke(args); - } - - private static object ResolveReactiveParameter(ParameterInfo param, Func resolveReactive) - { - var paramType = param.ParameterType; - if (paramType.IsGenericType && paramType.GetGenericTypeDefinition() == ReactiveConfigDef) - { - var configType = paramType.GetGenericArguments()[0]; - return resolveReactive(configType); - } - - throw new InvalidOperationException( - $"Constructor parameter '{param.Name}' of type '{paramType.Name}' on '{param.Member.DeclaringType?.Name}' cannot be resolved. " + - $"FeatureFlag and entitlement constructors may only depend on IReactiveConfig."); - } -} +using System.Reflection; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Flags.Internal; +using Cocoar.Configuration.Reactive; + +namespace Cocoar.Configuration.Flags; + +/// +/// Extension methods on for resolving flag and entitlement +/// instances without a DI container. Instances are singletons — created once and cached +/// on the capability for the lifetime of the ConfigManager. +/// +public static class ConfigManagerFlagsExtensions +{ + private static readonly Type ReactiveConfigDef = typeof(IReactiveConfig<>); + private static readonly MethodInfo GetReactiveConfigMethod = + typeof(ConfigManager).GetMethod(nameof(ConfigManager.GetReactiveConfig))!; + private static readonly MethodInfo GetReactiveConfigForTenantMethod = + typeof(ConfigManager).GetMethod(nameof(ConfigManager.GetReactiveConfigForTenant))!; + + /// + /// Resolves the singleton instance of the specified feature flag class. + /// The instance is constructed once and cached for the lifetime of the manager. + /// + /// A feature flag class registered via UseFeatureFlags. + public static T GetFeatureFlags(this ConfigManager manager) where T : class + { + var setup = manager.FlagsSetup + ?? throw new InvalidOperationException( + "UseFeatureFlags has not been configured. Call .UseFeatureFlags() in ConfigManager.Create()."); + + return (T)setup.InstanceCache.GetOrAdd(typeof(T), _ => CreateInstance(manager)); + } + + /// + /// Resolves the singleton instance of the specified entitlement class. + /// The instance is constructed once and cached for the lifetime of the manager. + /// + /// An entitlement class registered via UseEntitlements. + public static T GetEntitlements(this ConfigManager manager) where T : class + { + var setup = manager.EntitlementsSetup + ?? throw new InvalidOperationException( + "UseEntitlements has not been configured. Call .UseEntitlements() in ConfigManager.Create()."); + + return (T)setup.InstanceCache.GetOrAdd(typeof(T), _ => CreateInstance(manager)); + } + + /// + /// Resolves the per-tenant singleton instance of the specified feature flag class — the SAME generated + /// class constructed with the tenant's own , so the flag evaluates against + /// that tenant's effective config (ADR-005 §7). No source-generator change. Cached per (tenant, T). + /// + /// A feature flag class registered via UseFeatureFlags. + /// Flags not configured, or the tenant is not initialized. + public static T GetFeatureFlagsForTenant(this ConfigManager manager, string tenantId) where T : class + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var setup = manager.FlagsSetup + ?? throw new InvalidOperationException( + "UseFeatureFlags has not been configured. Call .UseFeatureFlags() in ConfigManager.Create()."); + EnsureTenantInitialized(manager, tenantId); + + return (T)setup.TenantInstanceCache.GetOrAdd((tenantId, typeof(T)), _ => CreateInstanceForTenant(manager, tenantId)); + } + + /// + /// Resolves the per-tenant singleton instance of the specified entitlement class — constructed with the + /// tenant's own (ADR-005 §7). No source-generator change. Cached per (tenant, T). + /// + /// An entitlement class registered via UseEntitlements. + /// Entitlements not configured, or the tenant is not initialized. + public static T GetEntitlementsForTenant(this ConfigManager manager, string tenantId) where T : class + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var setup = manager.EntitlementsSetup + ?? throw new InvalidOperationException( + "UseEntitlements has not been configured. Call .UseEntitlements() in ConfigManager.Create()."); + EnsureTenantInitialized(manager, tenantId); + + return (T)setup.TenantInstanceCache.GetOrAdd((tenantId, typeof(T)), _ => CreateInstanceForTenant(manager, tenantId)); + } + + /// + /// Constructs a flag or entitlement class instance, resolving its + /// dependencies from the global ConfigManager. Parameterless constructors are also supported. + /// + private static T CreateInstance(ConfigManager manager) + => Construct(p => ResolveReactiveParameter(p, configType => + GetReactiveConfigMethod.MakeGenericMethod(configType).Invoke(manager, null)!)); + + private static void EnsureTenantInitialized(ConfigManager manager, string tenantId) + { + if (!manager.IsTenantInitialized(tenantId)) + { + throw new InvalidOperationException( + $"Tenant '{tenantId}' is not initialized. Call InitializeTenantAsync/EnsureTenantInitializedAsync first."); + } + } + + /// Tenant variant of — resolves the tenant's IReactiveConfig<T>. + private static T CreateInstanceForTenant(ConfigManager manager, string tenantId) + => Construct(p => ResolveReactiveParameter(p, configType => + GetReactiveConfigForTenantMethod.MakeGenericMethod(configType).Invoke(manager, [tenantId])!)); + + private static T Construct(Func resolveParameter) + { + var type = typeof(T); + + // Prefer the constructor with the fewest parameters to handle common cases + // where a parameterless ctor exists alongside injected ones. + var ctor = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) + .OrderBy(c => c.GetParameters().Length) + .FirstOrDefault() + ?? throw new InvalidOperationException( + $"No public constructor found on '{type.Name}'."); + + var args = ctor.GetParameters().Select(resolveParameter).ToArray(); + return (T)ctor.Invoke(args); + } + + private static object ResolveReactiveParameter(ParameterInfo param, Func resolveReactive) + { + var paramType = param.ParameterType; + if (paramType.IsGenericType && paramType.GetGenericTypeDefinition() == ReactiveConfigDef) + { + var configType = paramType.GetGenericArguments()[0]; + return resolveReactive(configType); + } + + throw new InvalidOperationException( + $"Constructor parameter '{param.Name}' of type '{paramType.Name}' on '{param.Member.DeclaringType?.Name}' cannot be resolved. " + + $"FeatureFlag and entitlement constructors may only depend on IReactiveConfig."); + } +} diff --git a/src/Cocoar.Configuration/Flags/Descriptors/EntitlementClassDescriptor.cs b/src/Cocoar.Configuration/Flags/Descriptors/EntitlementClassDescriptor.cs index f5a465e..b3d9553 100644 --- a/src/Cocoar.Configuration/Flags/Descriptors/EntitlementClassDescriptor.cs +++ b/src/Cocoar.Configuration/Flags/Descriptors/EntitlementClassDescriptor.cs @@ -1,9 +1,9 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Compile-time descriptor for an entitlement class. -/// Populated at startup by the source generator via CocoarFlagsDescriptors.Entitlements. -/// -public sealed record EntitlementClassDescriptor( - Type Type, - IReadOnlyList Entitlements); +namespace Cocoar.Configuration.Flags; + +/// +/// Compile-time descriptor for an entitlement class. +/// Populated at startup by the source generator via CocoarFlagsDescriptors.Entitlements. +/// +public sealed record EntitlementClassDescriptor( + Type Type, + IReadOnlyList Entitlements); diff --git a/src/Cocoar.Configuration/Flags/Descriptors/EntitlementDefinitionDescriptor.cs b/src/Cocoar.Configuration/Flags/Descriptors/EntitlementDefinitionDescriptor.cs index dab3d29..94b39d3 100644 --- a/src/Cocoar.Configuration/Flags/Descriptors/EntitlementDefinitionDescriptor.cs +++ b/src/Cocoar.Configuration/Flags/Descriptors/EntitlementDefinitionDescriptor.cs @@ -1,8 +1,8 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Compile-time descriptor for an individual entitlement defined within an entitlement class. -/// -public sealed record EntitlementDefinitionDescriptor( - string Name, - string? Description); +namespace Cocoar.Configuration.Flags; + +/// +/// Compile-time descriptor for an individual entitlement defined within an entitlement class. +/// +public sealed record EntitlementDefinitionDescriptor( + string Name, + string? Description); diff --git a/src/Cocoar.Configuration/Flags/Descriptors/FeatureFlagClassDescriptor.cs b/src/Cocoar.Configuration/Flags/Descriptors/FeatureFlagClassDescriptor.cs index 6ba0519..667dfc2 100644 --- a/src/Cocoar.Configuration/Flags/Descriptors/FeatureFlagClassDescriptor.cs +++ b/src/Cocoar.Configuration/Flags/Descriptors/FeatureFlagClassDescriptor.cs @@ -1,14 +1,14 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Compile-time descriptor for a feature flag class. -/// Populated at startup by the source generator via CocoarFlagsDescriptors.Flags. -/// -public sealed record FeatureFlagClassDescriptor( - Type Type, - DateTimeOffset ExpiresAt, - IReadOnlyList Flags) -{ - /// Whether the class-level expiry has passed. - public bool IsExpired => DateTimeOffset.UtcNow > ExpiresAt; -} +namespace Cocoar.Configuration.Flags; + +/// +/// Compile-time descriptor for a feature flag class. +/// Populated at startup by the source generator via CocoarFlagsDescriptors.Flags. +/// +public sealed record FeatureFlagClassDescriptor( + Type Type, + DateTimeOffset ExpiresAt, + IReadOnlyList Flags) +{ + /// Whether the class-level expiry has passed. + public bool IsExpired => DateTimeOffset.UtcNow > ExpiresAt; +} diff --git a/src/Cocoar.Configuration/Flags/Descriptors/FlagDefinitionDescriptor.cs b/src/Cocoar.Configuration/Flags/Descriptors/FlagDefinitionDescriptor.cs index 409aa43..c86c870 100644 --- a/src/Cocoar.Configuration/Flags/Descriptors/FlagDefinitionDescriptor.cs +++ b/src/Cocoar.Configuration/Flags/Descriptors/FlagDefinitionDescriptor.cs @@ -1,8 +1,8 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Compile-time descriptor for an individual flag defined within a feature flag class. -/// -public sealed record FlagDefinitionDescriptor( - string Name, - string? Description); +namespace Cocoar.Configuration.Flags; + +/// +/// Compile-time descriptor for an individual flag defined within a feature flag class. +/// +public sealed record FlagDefinitionDescriptor( + string Name, + string? Description); diff --git a/src/Cocoar.Configuration/Flags/Entitlement.cs b/src/Cocoar.Configuration/Flags/Entitlement.cs index 65e05b8..acf3939 100644 --- a/src/Cocoar.Configuration/Flags/Entitlement.cs +++ b/src/Cocoar.Configuration/Flags/Entitlement.cs @@ -1,14 +1,14 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Delegate for an entitlement without context. -/// -/// The return type of the entitlement. -public delegate TResult Entitlement(); - -/// -/// Delegate for a context-aware entitlement. -/// -/// The context type required for evaluation. -/// The return type of the entitlement. -public delegate TResult Entitlement(TContext context); +namespace Cocoar.Configuration.Flags; + +/// +/// Delegate for an entitlement without context. +/// +/// The return type of the entitlement. +public delegate TResult Entitlement(); + +/// +/// Delegate for a context-aware entitlement. +/// +/// The context type required for evaluation. +/// The return type of the entitlement. +public delegate TResult Entitlement(TContext context); diff --git a/src/Cocoar.Configuration/Flags/EntitlementsDescriptors.cs b/src/Cocoar.Configuration/Flags/EntitlementsDescriptors.cs index ca8b17d..2f8ce98 100644 --- a/src/Cocoar.Configuration/Flags/EntitlementsDescriptors.cs +++ b/src/Cocoar.Configuration/Flags/EntitlementsDescriptors.cs @@ -1,15 +1,15 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Immutable catalog of instances, built once at startup. -/// -public sealed class EntitlementsDescriptors : IEntitlementsDescriptors -{ - /// - public IReadOnlyList All { get; } - - internal EntitlementsDescriptors(IReadOnlyList descriptors) - { - All = descriptors; - } -} +namespace Cocoar.Configuration.Flags; + +/// +/// Immutable catalog of instances, built once at startup. +/// +public sealed class EntitlementsDescriptors : IEntitlementsDescriptors +{ + /// + public IReadOnlyList All { get; } + + internal EntitlementsDescriptors(IReadOnlyList descriptors) + { + All = descriptors; + } +} diff --git a/src/Cocoar.Configuration/Flags/FeatureFlag.cs b/src/Cocoar.Configuration/Flags/FeatureFlag.cs index 50e0a58..ca960ef 100644 --- a/src/Cocoar.Configuration/Flags/FeatureFlag.cs +++ b/src/Cocoar.Configuration/Flags/FeatureFlag.cs @@ -1,14 +1,14 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Delegate for a feature flag without context. -/// -/// The return type of the flag. -public delegate TResult FeatureFlag(); - -/// -/// Delegate for a context-aware feature flag. -/// -/// The context type required for evaluation. -/// The return type of the flag. -public delegate TResult FeatureFlag(TContext context); +namespace Cocoar.Configuration.Flags; + +/// +/// Delegate for a feature flag without context. +/// +/// The return type of the flag. +public delegate TResult FeatureFlag(); + +/// +/// Delegate for a context-aware feature flag. +/// +/// The context type required for evaluation. +/// The return type of the flag. +public delegate TResult FeatureFlag(TContext context); diff --git a/src/Cocoar.Configuration/Flags/FeatureFlagsDescriptors.cs b/src/Cocoar.Configuration/Flags/FeatureFlagsDescriptors.cs index 70a8424..b845de7 100644 --- a/src/Cocoar.Configuration/Flags/FeatureFlagsDescriptors.cs +++ b/src/Cocoar.Configuration/Flags/FeatureFlagsDescriptors.cs @@ -1,19 +1,19 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Immutable catalog of instances, built once at startup. -/// -public sealed class FeatureFlagsDescriptors : IFeatureFlagsDescriptors -{ - /// - public IReadOnlyList All { get; } - - /// - public IReadOnlyList Expired { get; } - - internal FeatureFlagsDescriptors(IReadOnlyList descriptors) - { - All = descriptors; - Expired = descriptors.Where(d => d.IsExpired).ToList().AsReadOnly(); - } -} +namespace Cocoar.Configuration.Flags; + +/// +/// Immutable catalog of instances, built once at startup. +/// +public sealed class FeatureFlagsDescriptors : IFeatureFlagsDescriptors +{ + /// + public IReadOnlyList All { get; } + + /// + public IReadOnlyList Expired { get; } + + internal FeatureFlagsDescriptors(IReadOnlyList descriptors) + { + All = descriptors; + Expired = descriptors.Where(d => d.IsExpired).ToList().AsReadOnly(); + } +} diff --git a/src/Cocoar.Configuration/Flags/IContextResolver.cs b/src/Cocoar.Configuration/Flags/IContextResolver.cs index 5cb7594..4534b3e 100644 --- a/src/Cocoar.Configuration/Flags/IContextResolver.cs +++ b/src/Cocoar.Configuration/Flags/IContextResolver.cs @@ -1,39 +1,39 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Hydrates a rich domain context object from a raw HTTP request payload. -/// -/// Implement this interface to bridge the gap between what a TypeScript (or other) client -/// sends over HTTP () and the strongly-typed context that a -/// lambda receives (). -/// -/// -/// Implementations may perform any I/O — database lookups, service calls, cache reads — -/// and are resolved from DI at request time. They are registered as Scoped by -/// default, allowing resolvers to share per-request state (e.g. DbContext). -/// -/// -/// The deserialized HTTP request body type. -/// The domain context type expected by the flag evaluator. -/// -/// -/// public class UserByIdResolver : IContextResolver<UserIdRequest, UserContext> -/// { -/// private readonly IUserRepository _users; -/// public UserByIdResolver(IUserRepository users) => _users = users; -/// -/// public async Task<UserContext> ResolveAsync(UserIdRequest request) -/// { -/// var user = await _users.GetByIdAsync(request.UserId); -/// return new UserContext(user.Id, user.Email, user.PlanTier); -/// } -/// } -/// -/// -public interface IContextResolver -{ - /// - /// Resolves the domain context from the given HTTP request payload. - /// - Task ResolveAsync(TRequest request); -} +namespace Cocoar.Configuration.Flags; + +/// +/// Hydrates a rich domain context object from a raw HTTP request payload. +/// +/// Implement this interface to bridge the gap between what a TypeScript (or other) client +/// sends over HTTP () and the strongly-typed context that a +/// lambda receives (). +/// +/// +/// Implementations may perform any I/O — database lookups, service calls, cache reads — +/// and are resolved from DI at request time. They are registered as Scoped by +/// default, allowing resolvers to share per-request state (e.g. DbContext). +/// +/// +/// The deserialized HTTP request body type. +/// The domain context type expected by the flag evaluator. +/// +/// +/// public class UserByIdResolver : IContextResolver<UserIdRequest, UserContext> +/// { +/// private readonly IUserRepository _users; +/// public UserByIdResolver(IUserRepository users) => _users = users; +/// +/// public async Task<UserContext> ResolveAsync(UserIdRequest request) +/// { +/// var user = await _users.GetByIdAsync(request.UserId); +/// return new UserContext(user.Id, user.Email, user.PlanTier); +/// } +/// } +/// +/// +public interface IContextResolver +{ + /// + /// Resolves the domain context from the given HTTP request payload. + /// + Task ResolveAsync(TRequest request); +} diff --git a/src/Cocoar.Configuration/Flags/IEntitlementEvaluator.cs b/src/Cocoar.Configuration/Flags/IEntitlementEvaluator.cs index e25484e..40db090 100644 --- a/src/Cocoar.Configuration/Flags/IEntitlementEvaluator.cs +++ b/src/Cocoar.Configuration/Flags/IEntitlementEvaluator.cs @@ -1,64 +1,64 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Evaluates a contextual entitlement by key, hydrating the domain context via a registered -/// . -/// -/// Entitlements are permanent — they represent what a user or tenant is allowed to do -/// based on their plan, license, or role. Use for temporary -/// feature flags that should eventually be removed. -/// -/// -/// This service provides a second path for evaluating contextual entitlements alongside the direct -/// delegate call. Two equivalent ways to evaluate the same entitlement: -/// -/// -/// -/// -/// Direct — resolve the entitlement class from DI and invoke the delegate: -/// entitlements.MaxUsersForTenant(tenantContext) -/// -/// -/// -/// -/// Via evaluator — pass a resolver request DTO and let the service hydrate context: -/// await evaluator.EvaluateAsync("PlanEntitlements/MaxUsersForTenant", new TenantIdRequest("t_123")) -/// -/// -/// -/// -/// The evaluator is registered as Scoped in DI so it resolves the entitlement class and resolver -/// from the current scope (per-request in web applications). -/// -/// -/// Key format: "{EntitlementClassName}/{PropertyName}" — e.g. "PlanEntitlements/MaxUsersForTenant". -/// Keys are derived from the registered entitlement class name and property name. -/// -/// -public interface IEntitlementEvaluator -{ - /// - /// Returns if the key maps to a contextual entitlement with a registered - /// resolver. Returns for unknown keys or entitlements with no resolver. - /// - bool CanEvaluate(string key); - - /// - /// Evaluates the contextual entitlement identified by using - /// to hydrate the context. - /// - /// - /// Entitlement key in the form "{EntitlementClassName}/{PropertyName}". - /// - /// - /// The resolver request DTO. Must be assignable to the TRequest type expected by - /// the registered . - /// - /// Propagated to the resolver's ResolveAsync call. - /// The entitlement result as . - /// The key has no registered evaluation entry. - Task EvaluateAsync( - string key, - object resolverRequest, - CancellationToken cancellationToken = default); -} +namespace Cocoar.Configuration.Flags; + +/// +/// Evaluates a contextual entitlement by key, hydrating the domain context via a registered +/// . +/// +/// Entitlements are permanent — they represent what a user or tenant is allowed to do +/// based on their plan, license, or role. Use for temporary +/// feature flags that should eventually be removed. +/// +/// +/// This service provides a second path for evaluating contextual entitlements alongside the direct +/// delegate call. Two equivalent ways to evaluate the same entitlement: +/// +/// +/// +/// +/// Direct — resolve the entitlement class from DI and invoke the delegate: +/// entitlements.MaxUsersForTenant(tenantContext) +/// +/// +/// +/// +/// Via evaluator — pass a resolver request DTO and let the service hydrate context: +/// await evaluator.EvaluateAsync("PlanEntitlements/MaxUsersForTenant", new TenantIdRequest("t_123")) +/// +/// +/// +/// +/// The evaluator is registered as Scoped in DI so it resolves the entitlement class and resolver +/// from the current scope (per-request in web applications). +/// +/// +/// Key format: "{EntitlementClassName}/{PropertyName}" — e.g. "PlanEntitlements/MaxUsersForTenant". +/// Keys are derived from the registered entitlement class name and property name. +/// +/// +public interface IEntitlementEvaluator +{ + /// + /// Returns if the key maps to a contextual entitlement with a registered + /// resolver. Returns for unknown keys or entitlements with no resolver. + /// + bool CanEvaluate(string key); + + /// + /// Evaluates the contextual entitlement identified by using + /// to hydrate the context. + /// + /// + /// Entitlement key in the form "{EntitlementClassName}/{PropertyName}". + /// + /// + /// The resolver request DTO. Must be assignable to the TRequest type expected by + /// the registered . + /// + /// Propagated to the resolver's ResolveAsync call. + /// The entitlement result as . + /// The key has no registered evaluation entry. + Task EvaluateAsync( + string key, + object resolverRequest, + CancellationToken cancellationToken = default); +} diff --git a/src/Cocoar.Configuration/Flags/IEntitlementsDescriptors.cs b/src/Cocoar.Configuration/Flags/IEntitlementsDescriptors.cs index b94abf2..81423dd 100644 --- a/src/Cocoar.Configuration/Flags/IEntitlementsDescriptors.cs +++ b/src/Cocoar.Configuration/Flags/IEntitlementsDescriptors.cs @@ -1,20 +1,20 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Read-only catalog of registered entitlement classes. -/// Populated at startup from source-generator output — no runtime reflection required. -/// -/// -/// -/// Use this to: -/// -/// -/// Inventory all entitlements in the application -/// Populate management UI (ConfigHub) with entitlement names and descriptions -/// -/// -public interface IEntitlementsDescriptors -{ - /// All registered entitlement class descriptors. - IReadOnlyList All { get; } -} +namespace Cocoar.Configuration.Flags; + +/// +/// Read-only catalog of registered entitlement classes. +/// Populated at startup from source-generator output — no runtime reflection required. +/// +/// +/// +/// Use this to: +/// +/// +/// Inventory all entitlements in the application +/// Populate management UI (ConfigHub) with entitlement names and descriptions +/// +/// +public interface IEntitlementsDescriptors +{ + /// All registered entitlement class descriptors. + IReadOnlyList All { get; } +} diff --git a/src/Cocoar.Configuration/Flags/IFeatureFlagEvaluator.cs b/src/Cocoar.Configuration/Flags/IFeatureFlagEvaluator.cs index 63ef26d..307be2b 100644 --- a/src/Cocoar.Configuration/Flags/IFeatureFlagEvaluator.cs +++ b/src/Cocoar.Configuration/Flags/IFeatureFlagEvaluator.cs @@ -1,64 +1,64 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Evaluates a contextual feature flag by key, hydrating the domain context via a registered -/// . -/// -/// Feature flags are temporary — they represent in-progress rollouts or experiments -/// that should eventually be removed or converted to entitlements. -/// Use for permanent capability checks. -/// -/// -/// This service provides a second path for evaluating contextual flags alongside the direct -/// delegate call. Two equivalent ways to evaluate the same flag: -/// -/// -/// -/// -/// Direct — resolve the flag class from DI and invoke the delegate: -/// flags.NewDashboardForUser(userContext) -/// -/// -/// -/// -/// Via evaluator — pass a resolver request DTO and let the service hydrate context: -/// await evaluator.EvaluateAsync("AppFeatureFlags/NewDashboardForUser", new UserIdRequest("123")) -/// -/// -/// -/// -/// The evaluator is registered as Scoped in DI so it resolves the flag class and resolver -/// from the current scope (per-request in web applications). -/// -/// -/// Key format: "{FlagClassName}/{PropertyName}" — e.g. "AppFeatureFlags/NewDashboardForUser". -/// Keys are derived from the registered flag class name and property name. -/// -/// -public interface IFeatureFlagEvaluator -{ - /// - /// Returns if the key maps to a contextual feature flag with a registered - /// resolver. Returns for unknown keys or flags with no resolver. - /// - bool CanEvaluate(string key); - - /// - /// Evaluates the contextual feature flag identified by using - /// to hydrate the context. - /// - /// - /// FeatureFlag key in the form "{FlagClassName}/{PropertyName}". - /// - /// - /// The resolver request DTO. Must be assignable to the TRequest type expected by - /// the registered . - /// - /// Propagated to the resolver's ResolveAsync call. - /// The flag result as . - /// The key has no registered evaluation entry. - Task EvaluateAsync( - string key, - object resolverRequest, - CancellationToken cancellationToken = default); -} +namespace Cocoar.Configuration.Flags; + +/// +/// Evaluates a contextual feature flag by key, hydrating the domain context via a registered +/// . +/// +/// Feature flags are temporary — they represent in-progress rollouts or experiments +/// that should eventually be removed or converted to entitlements. +/// Use for permanent capability checks. +/// +/// +/// This service provides a second path for evaluating contextual flags alongside the direct +/// delegate call. Two equivalent ways to evaluate the same flag: +/// +/// +/// +/// +/// Direct — resolve the flag class from DI and invoke the delegate: +/// flags.NewDashboardForUser(userContext) +/// +/// +/// +/// +/// Via evaluator — pass a resolver request DTO and let the service hydrate context: +/// await evaluator.EvaluateAsync("AppFeatureFlags/NewDashboardForUser", new UserIdRequest("123")) +/// +/// +/// +/// +/// The evaluator is registered as Scoped in DI so it resolves the flag class and resolver +/// from the current scope (per-request in web applications). +/// +/// +/// Key format: "{FlagClassName}/{PropertyName}" — e.g. "AppFeatureFlags/NewDashboardForUser". +/// Keys are derived from the registered flag class name and property name. +/// +/// +public interface IFeatureFlagEvaluator +{ + /// + /// Returns if the key maps to a contextual feature flag with a registered + /// resolver. Returns for unknown keys or flags with no resolver. + /// + bool CanEvaluate(string key); + + /// + /// Evaluates the contextual feature flag identified by using + /// to hydrate the context. + /// + /// + /// FeatureFlag key in the form "{FlagClassName}/{PropertyName}". + /// + /// + /// The resolver request DTO. Must be assignable to the TRequest type expected by + /// the registered . + /// + /// Propagated to the resolver's ResolveAsync call. + /// The flag result as . + /// The key has no registered evaluation entry. + Task EvaluateAsync( + string key, + object resolverRequest, + CancellationToken cancellationToken = default); +} diff --git a/src/Cocoar.Configuration/Flags/IFeatureFlagsDescriptors.cs b/src/Cocoar.Configuration/Flags/IFeatureFlagsDescriptors.cs index 26a3e16..03932f5 100644 --- a/src/Cocoar.Configuration/Flags/IFeatureFlagsDescriptors.cs +++ b/src/Cocoar.Configuration/Flags/IFeatureFlagsDescriptors.cs @@ -1,27 +1,27 @@ -namespace Cocoar.Configuration.Flags; - -/// -/// Read-only catalog of registered feature flag classes. -/// Populated at startup from source-generator output — no runtime reflection required. -/// -/// -/// -/// Use this to: -/// -/// -/// Inventory all feature flags in the application -/// Drive health checks for expired feature flag classes -/// Populate management UI (ConfigHub) with flag names and descriptions -/// -/// -public interface IFeatureFlagsDescriptors -{ - /// All registered feature flag class descriptors. - IReadOnlyList All { get; } - - /// - /// Descriptors whose class-level ExpiresAt has passed. - /// When non-empty, the health status is reported as Degraded. - /// - IReadOnlyList Expired { get; } -} +namespace Cocoar.Configuration.Flags; + +/// +/// Read-only catalog of registered feature flag classes. +/// Populated at startup from source-generator output — no runtime reflection required. +/// +/// +/// +/// Use this to: +/// +/// +/// Inventory all feature flags in the application +/// Drive health checks for expired feature flag classes +/// Populate management UI (ConfigHub) with flag names and descriptions +/// +/// +public interface IFeatureFlagsDescriptors +{ + /// All registered feature flag class descriptors. + IReadOnlyList All { get; } + + /// + /// Descriptors whose class-level ExpiresAt has passed. + /// When non-empty, the health status is reported as Degraded. + /// + IReadOnlyList Expired { get; } +} diff --git a/src/Cocoar.Configuration/Flags/Internal/DescriptorLookup.cs b/src/Cocoar.Configuration/Flags/Internal/DescriptorLookup.cs index ee12dce..92ac9af 100644 --- a/src/Cocoar.Configuration/Flags/Internal/DescriptorLookup.cs +++ b/src/Cocoar.Configuration/Flags/Internal/DescriptorLookup.cs @@ -1,54 +1,54 @@ -using System.Diagnostics; -using System.Reflection; - -namespace Cocoar.Configuration.Flags.Internal; - -/// -/// Bridges and -/// to the source-generated CocoarFlagsDescriptors class in the caller's assembly. -/// Called once per Register<T>() invocation at startup — not on hot paths. -/// -internal static class DescriptorLookup -{ - private const string GeneratedTypeName = "Cocoar.Configuration.Flags.Generated.CocoarFlagsDescriptors"; - - internal static FeatureFlagClassDescriptor? GetFlagsDescriptor(Type flagType) - { - var generated = flagType.Assembly.GetType(GeneratedTypeName); - if (generated is null) - { - WarnMissingGenerator(flagType); - return null; - } - - var field = generated.GetField("Flags", - BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); - return field?.GetValue(null) - is IReadOnlyDictionary dict - && dict.TryGetValue(flagType, out var d) ? d : null; - } - - internal static EntitlementClassDescriptor? GetEntitlementsDescriptor(Type entitlementType) - { - var generated = entitlementType.Assembly.GetType(GeneratedTypeName); - if (generated is null) - { - WarnMissingGenerator(entitlementType); - return null; - } - - var field = generated.GetField("Entitlements", - BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); - return field?.GetValue(null) - is IReadOnlyDictionary dict - && dict.TryGetValue(entitlementType, out var d) ? d : null; - } - - private static void WarnMissingGenerator(Type type) - { - Trace.TraceWarning( - $"Cocoar.Configuration: Source-generated '{GeneratedTypeName}' not found in assembly '{type.Assembly.GetName().Name}'. " + - $"Ensure 'Cocoar.Configuration.Analyzers' is referenced with OutputItemType=\"Analyzer\". " + - $"Without it, ExpiresAt defaults to MaxValue and flag/entitlement descriptions are unavailable."); - } -} +using System.Diagnostics; +using System.Reflection; + +namespace Cocoar.Configuration.Flags.Internal; + +/// +/// Bridges and +/// to the source-generated CocoarFlagsDescriptors class in the caller's assembly. +/// Called once per Register<T>() invocation at startup — not on hot paths. +/// +internal static class DescriptorLookup +{ + private const string GeneratedTypeName = "Cocoar.Configuration.Flags.Generated.CocoarFlagsDescriptors"; + + internal static FeatureFlagClassDescriptor? GetFlagsDescriptor(Type flagType) + { + var generated = flagType.Assembly.GetType(GeneratedTypeName); + if (generated is null) + { + WarnMissingGenerator(flagType); + return null; + } + + var field = generated.GetField("Flags", + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + return field?.GetValue(null) + is IReadOnlyDictionary dict + && dict.TryGetValue(flagType, out var d) ? d : null; + } + + internal static EntitlementClassDescriptor? GetEntitlementsDescriptor(Type entitlementType) + { + var generated = entitlementType.Assembly.GetType(GeneratedTypeName); + if (generated is null) + { + WarnMissingGenerator(entitlementType); + return null; + } + + var field = generated.GetField("Entitlements", + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + return field?.GetValue(null) + is IReadOnlyDictionary dict + && dict.TryGetValue(entitlementType, out var d) ? d : null; + } + + private static void WarnMissingGenerator(Type type) + { + Trace.TraceWarning( + $"Cocoar.Configuration: Source-generated '{GeneratedTypeName}' not found in assembly '{type.Assembly.GetName().Name}'. " + + $"Ensure 'Cocoar.Configuration.Analyzers' is referenced with OutputItemType=\"Analyzer\". " + + $"Without it, ExpiresAt defaults to MaxValue and flag/entitlement descriptions are unavailable."); + } +} diff --git a/src/Cocoar.Configuration/Flags/Internal/EntitlementEvaluator.cs b/src/Cocoar.Configuration/Flags/Internal/EntitlementEvaluator.cs index 4eb584c..9279e34 100644 --- a/src/Cocoar.Configuration/Flags/Internal/EntitlementEvaluator.cs +++ b/src/Cocoar.Configuration/Flags/Internal/EntitlementEvaluator.cs @@ -1,83 +1,83 @@ -using Cocoar.Configuration.Diagnostics; -using System.Diagnostics; - -namespace Cocoar.Configuration.Flags.Internal; - -/// -/// Default implementation of . Registered as Scoped in DI so the -/// service provider it holds is the current request's scope — resolvers are resolved from that -/// scope, not the root container, which allows resolvers to have Scoped dependencies (e.g. DbContext). -/// Entitlement classes are Singleton and resolve cleanly from any scope. -/// -internal sealed class EntitlementEvaluator : IEntitlementEvaluator -{ - private readonly IReadOnlyDictionary _entries; - private readonly IServiceProvider _services; - - internal EntitlementEvaluator( - IReadOnlyDictionary entries, - IServiceProvider services) - { - _entries = entries; - _services = services; - } - - public bool CanEvaluate(string key) => _entries.ContainsKey(key); - - public async Task EvaluateAsync( - string key, - object resolverRequest, - CancellationToken cancellationToken = default) - { - using var activity = CocoarMetrics.ActivitySource.StartActivity("cocoar.entitlement.evaluate"); - activity?.SetTag("flag.key", key); - activity?.SetTag("flag.kind", "entitlement"); - - if (!_entries.TryGetValue(key, out var entry)) - { - activity?.SetStatus(ActivityStatusCode.Error, "Key not found"); - var available = string.Join(", ", _entries.Keys); - throw new KeyNotFoundException( - $"No contextual entitlement evaluation entry found for key '{key}'. " + - $"Registered keys: [{available}]"); - } - - var resolver = _services.GetService(entry.Resolver.ResolverType) - ?? throw new InvalidOperationException( - $"No service of type '{entry.Resolver.ResolverType.Name}' is registered. " + - $"Ensure the resolver is registered via resolvers.Global() or resolvers.For(...)."); - - var context = await entry.CompiledResolveAsync(resolver, resolverRequest).ConfigureAwait(false); - - var entitlementClass = _services.GetService(entry.FlagClassType) - ?? throw new InvalidOperationException( - $"No service of type '{entry.FlagClassType.Name}' is registered. " + - $"Ensure the entitlement class is registered via Register<{entry.FlagClassType.Name}>()."); - - try - { - var result = entry.CompiledFlagInvoke(entitlementClass, context); - - activity?.SetTag("flag.status", "success"); - CocoarMetrics.FlagEvaluations.Add(1, - new KeyValuePair("key", key), - new KeyValuePair("kind", "entitlement"), - new KeyValuePair("status", "success")); - - return result; - } - catch (Exception ex) - { - activity?.SetTag("flag.status", "failure"); - activity?.SetStatus(ActivityStatusCode.Error, ex.Message); - CocoarMetrics.FlagEvaluations.Add(1, - new KeyValuePair("key", key), - new KeyValuePair("kind", "entitlement"), - new KeyValuePair("status", "failure")); - - throw new InvalidOperationException( - $"Entitlement evaluation '{entry.FlagClassType.Name}.{entry.Property.Name}' threw an exception.", - ex); - } - } -} +using Cocoar.Configuration.Diagnostics; +using System.Diagnostics; + +namespace Cocoar.Configuration.Flags.Internal; + +/// +/// Default implementation of . Registered as Scoped in DI so the +/// service provider it holds is the current request's scope — resolvers are resolved from that +/// scope, not the root container, which allows resolvers to have Scoped dependencies (e.g. DbContext). +/// Entitlement classes are Singleton and resolve cleanly from any scope. +/// +internal sealed class EntitlementEvaluator : IEntitlementEvaluator +{ + private readonly IReadOnlyDictionary _entries; + private readonly IServiceProvider _services; + + internal EntitlementEvaluator( + IReadOnlyDictionary entries, + IServiceProvider services) + { + _entries = entries; + _services = services; + } + + public bool CanEvaluate(string key) => _entries.ContainsKey(key); + + public async Task EvaluateAsync( + string key, + object resolverRequest, + CancellationToken cancellationToken = default) + { + using var activity = CocoarMetrics.ActivitySource.StartActivity("cocoar.entitlement.evaluate"); + activity?.SetTag("flag.key", key); + activity?.SetTag("flag.kind", "entitlement"); + + if (!_entries.TryGetValue(key, out var entry)) + { + activity?.SetStatus(ActivityStatusCode.Error, "Key not found"); + var available = string.Join(", ", _entries.Keys); + throw new KeyNotFoundException( + $"No contextual entitlement evaluation entry found for key '{key}'. " + + $"Registered keys: [{available}]"); + } + + var resolver = _services.GetService(entry.Resolver.ResolverType) + ?? throw new InvalidOperationException( + $"No service of type '{entry.Resolver.ResolverType.Name}' is registered. " + + $"Ensure the resolver is registered via resolvers.Global() or resolvers.For(...)."); + + var context = await entry.CompiledResolveAsync(resolver, resolverRequest).ConfigureAwait(false); + + var entitlementClass = _services.GetService(entry.FlagClassType) + ?? throw new InvalidOperationException( + $"No service of type '{entry.FlagClassType.Name}' is registered. " + + $"Ensure the entitlement class is registered via Register<{entry.FlagClassType.Name}>()."); + + try + { + var result = entry.CompiledFlagInvoke(entitlementClass, context); + + activity?.SetTag("flag.status", "success"); + CocoarMetrics.FlagEvaluations.Add(1, + new KeyValuePair("key", key), + new KeyValuePair("kind", "entitlement"), + new KeyValuePair("status", "success")); + + return result; + } + catch (Exception ex) + { + activity?.SetTag("flag.status", "failure"); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + CocoarMetrics.FlagEvaluations.Add(1, + new KeyValuePair("key", key), + new KeyValuePair("kind", "entitlement"), + new KeyValuePair("status", "failure")); + + throw new InvalidOperationException( + $"Entitlement evaluation '{entry.FlagClassType.Name}.{entry.Property.Name}' threw an exception.", + ex); + } + } +} diff --git a/src/Cocoar.Configuration/Flags/Internal/FeatureFlagEvaluator.cs b/src/Cocoar.Configuration/Flags/Internal/FeatureFlagEvaluator.cs index b92992c..cd0d0a6 100644 --- a/src/Cocoar.Configuration/Flags/Internal/FeatureFlagEvaluator.cs +++ b/src/Cocoar.Configuration/Flags/Internal/FeatureFlagEvaluator.cs @@ -1,83 +1,83 @@ -using Cocoar.Configuration.Diagnostics; -using System.Diagnostics; - -namespace Cocoar.Configuration.Flags.Internal; - -/// -/// Default implementation of . Registered as Scoped in DI so the -/// service provider it holds is the current request's scope — resolvers are resolved from that -/// scope, not the root container, which allows resolvers to have Scoped dependencies (e.g. DbContext). -/// FeatureFlag classes are Singleton and resolve cleanly from any scope. -/// -internal sealed class FeatureFlagEvaluator : IFeatureFlagEvaluator -{ - private readonly IReadOnlyDictionary _entries; - private readonly IServiceProvider _services; - - internal FeatureFlagEvaluator( - IReadOnlyDictionary entries, - IServiceProvider services) - { - _entries = entries; - _services = services; - } - - public bool CanEvaluate(string key) => _entries.ContainsKey(key); - - public async Task EvaluateAsync( - string key, - object resolverRequest, - CancellationToken cancellationToken = default) - { - using var activity = CocoarMetrics.ActivitySource.StartActivity("cocoar.feature_flag.evaluate"); - activity?.SetTag("flag.key", key); - activity?.SetTag("flag.kind", "feature_flag"); - - if (!_entries.TryGetValue(key, out var entry)) - { - activity?.SetStatus(ActivityStatusCode.Error, "Key not found"); - var available = string.Join(", ", _entries.Keys); - throw new KeyNotFoundException( - $"No contextual feature flag evaluation entry found for key '{key}'. " + - $"Registered keys: [{available}]"); - } - - var resolver = _services.GetService(entry.Resolver.ResolverType) - ?? throw new InvalidOperationException( - $"No service of type '{entry.Resolver.ResolverType.Name}' is registered. " + - $"Ensure the resolver is registered via resolvers.Global() or resolvers.For(...)."); - - var context = await entry.CompiledResolveAsync(resolver, resolverRequest).ConfigureAwait(false); - - var flagClass = _services.GetService(entry.FlagClassType) - ?? throw new InvalidOperationException( - $"No service of type '{entry.FlagClassType.Name}' is registered. " + - $"Ensure the flag class is registered via Register<{entry.FlagClassType.Name}>()."); - - try - { - var result = entry.CompiledFlagInvoke(flagClass, context); - - activity?.SetTag("flag.status", "success"); - CocoarMetrics.FlagEvaluations.Add(1, - new KeyValuePair("key", key), - new KeyValuePair("kind", "feature_flag"), - new KeyValuePair("status", "success")); - - return result; - } - catch (Exception ex) - { - activity?.SetTag("flag.status", "failure"); - activity?.SetStatus(ActivityStatusCode.Error, ex.Message); - CocoarMetrics.FlagEvaluations.Add(1, - new KeyValuePair("key", key), - new KeyValuePair("kind", "feature_flag"), - new KeyValuePair("status", "failure")); - - throw new InvalidOperationException( - $"Feature flag evaluation '{entry.FlagClassType.Name}.{entry.Property.Name}' threw an exception.", - ex); - } - } -} +using Cocoar.Configuration.Diagnostics; +using System.Diagnostics; + +namespace Cocoar.Configuration.Flags.Internal; + +/// +/// Default implementation of . Registered as Scoped in DI so the +/// service provider it holds is the current request's scope — resolvers are resolved from that +/// scope, not the root container, which allows resolvers to have Scoped dependencies (e.g. DbContext). +/// FeatureFlag classes are Singleton and resolve cleanly from any scope. +/// +internal sealed class FeatureFlagEvaluator : IFeatureFlagEvaluator +{ + private readonly IReadOnlyDictionary _entries; + private readonly IServiceProvider _services; + + internal FeatureFlagEvaluator( + IReadOnlyDictionary entries, + IServiceProvider services) + { + _entries = entries; + _services = services; + } + + public bool CanEvaluate(string key) => _entries.ContainsKey(key); + + public async Task EvaluateAsync( + string key, + object resolverRequest, + CancellationToken cancellationToken = default) + { + using var activity = CocoarMetrics.ActivitySource.StartActivity("cocoar.feature_flag.evaluate"); + activity?.SetTag("flag.key", key); + activity?.SetTag("flag.kind", "feature_flag"); + + if (!_entries.TryGetValue(key, out var entry)) + { + activity?.SetStatus(ActivityStatusCode.Error, "Key not found"); + var available = string.Join(", ", _entries.Keys); + throw new KeyNotFoundException( + $"No contextual feature flag evaluation entry found for key '{key}'. " + + $"Registered keys: [{available}]"); + } + + var resolver = _services.GetService(entry.Resolver.ResolverType) + ?? throw new InvalidOperationException( + $"No service of type '{entry.Resolver.ResolverType.Name}' is registered. " + + $"Ensure the resolver is registered via resolvers.Global() or resolvers.For(...)."); + + var context = await entry.CompiledResolveAsync(resolver, resolverRequest).ConfigureAwait(false); + + var flagClass = _services.GetService(entry.FlagClassType) + ?? throw new InvalidOperationException( + $"No service of type '{entry.FlagClassType.Name}' is registered. " + + $"Ensure the flag class is registered via Register<{entry.FlagClassType.Name}>()."); + + try + { + var result = entry.CompiledFlagInvoke(flagClass, context); + + activity?.SetTag("flag.status", "success"); + CocoarMetrics.FlagEvaluations.Add(1, + new KeyValuePair("key", key), + new KeyValuePair("kind", "feature_flag"), + new KeyValuePair("status", "success")); + + return result; + } + catch (Exception ex) + { + activity?.SetTag("flag.status", "failure"); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + CocoarMetrics.FlagEvaluations.Add(1, + new KeyValuePair("key", key), + new KeyValuePair("kind", "feature_flag"), + new KeyValuePair("status", "failure")); + + throw new InvalidOperationException( + $"Feature flag evaluation '{entry.FlagClassType.Name}.{entry.Property.Name}' threw an exception.", + ex); + } + } +} diff --git a/src/Cocoar.Configuration/Flags/Internal/FeatureFlagsHealthSource.cs b/src/Cocoar.Configuration/Flags/Internal/FeatureFlagsHealthSource.cs index 292d7b1..5fed16c 100644 --- a/src/Cocoar.Configuration/Flags/Internal/FeatureFlagsHealthSource.cs +++ b/src/Cocoar.Configuration/Flags/Internal/FeatureFlagsHealthSource.cs @@ -1,12 +1,12 @@ -using Cocoar.Configuration.Health; - -namespace Cocoar.Configuration.Flags.Internal; - -/// -/// Provides expired feature flag detection for . -/// Reads from the descriptor catalog to detect expired flags classes. -/// -internal sealed class FeatureFlagsHealthSource(IFeatureFlagsDescriptors descriptors) : IFlagsHealthSource -{ - public bool HasExpiredFlags() => descriptors.Expired.Count > 0; -} +using Cocoar.Configuration.Health; + +namespace Cocoar.Configuration.Flags.Internal; + +/// +/// Provides expired feature flag detection for . +/// Reads from the descriptor catalog to detect expired flags classes. +/// +internal sealed class FeatureFlagsHealthSource(IFeatureFlagsDescriptors descriptors) : IFlagsHealthSource +{ + public bool HasExpiredFlags() => descriptors.Expired.Count > 0; +} diff --git a/src/Cocoar.Configuration/Flags/Internal/FlagEvaluationEntry.cs b/src/Cocoar.Configuration/Flags/Internal/FlagEvaluationEntry.cs index 3a92f0c..6d3fae1 100644 --- a/src/Cocoar.Configuration/Flags/Internal/FlagEvaluationEntry.cs +++ b/src/Cocoar.Configuration/Flags/Internal/FlagEvaluationEntry.cs @@ -1,104 +1,104 @@ -using System.Linq.Expressions; -using System.Reflection; - -namespace Cocoar.Configuration.Flags.Internal; - -/// -/// Pre-computed metadata for a single contextual flag property, built once at startup -/// from the resolver cascade and stored in . -/// Includes compiled delegates that replace per-request reflection. -/// -/// The feature flag or entitlement class that owns the property. -/// The FeatureFlag<TContext, TResult> property info. -/// The TContext type extracted from the flag property's generic args. -/// The winning resolver registration after cascade resolution. -/// -/// Pre-compiled delegate that calls IContextResolver<TRequest,TContext>.ResolveAsync(request) -/// on the resolver instance and returns the resolved context as Task<object?>. -/// Built once at startup to avoid per-request / reflection overhead. -/// -/// -/// Pre-compiled delegate that reads the FeatureFlag<TContext,TResult> property from the flag class -/// instance, invokes it with the resolved context, and returns the result as object?. -/// Built once at startup to avoid per-request reflection overhead. -/// -internal sealed record FlagEvaluationEntry( - Type FlagClassType, - PropertyInfo Property, - Type ContextType, - ContextResolverRegistration Resolver, - Func> CompiledResolveAsync, - Func CompiledFlagInvoke) -{ - /// - /// Builds strongly-typed delegate wrappers for resolver invocation and flag evaluation. - /// Uses a generic helper method so that the cast and call are baked into the delegate - /// at construction time, eliminating all per-request reflection. - /// - internal static FlagEvaluationEntry Create( - Type flagClassType, - PropertyInfo property, - Type contextType, - ContextResolverRegistration resolver) - { - var resolveAsync = BuildResolveAsyncDelegate(resolver.RequestType, contextType); - var flagInvoke = BuildFlagInvokeDelegate(flagClassType, property, contextType); - - return new FlagEvaluationEntry( - flagClassType, property, contextType, resolver, - resolveAsync, flagInvoke); - } - - private static Func> BuildResolveAsyncDelegate( - Type requestType, Type contextType) - { - // Call the generic helper via reflection once at startup to produce a - // strongly-typed lambda that runs without reflection at request time. - var method = typeof(FlagEvaluationEntry) - .GetMethod(nameof(CreateResolveAsyncDelegate), BindingFlags.NonPublic | BindingFlags.Static)! - .MakeGenericMethod(requestType, contextType); - - return (Func>)method.Invoke(null, null)!; - } - - private static Func> CreateResolveAsyncDelegate() - { - return async (resolverInstance, request) => - { - var typed = (IContextResolver)resolverInstance; - var result = await typed.ResolveAsync((TRequest)request).ConfigureAwait(false); - return result; - }; - } - - /// - /// Builds a compiled expression tree that: casts the flag class instance to its concrete type, - /// reads the FeatureFlag<TContext, TResult> property, invokes the delegate with the - /// cast context argument, and boxes the result to object?. - /// - private static Func BuildFlagInvokeDelegate( - Type flagClassType, PropertyInfo property, Type contextType) - { - // Parameters: (object flagClassInstance, object? context) - var instanceParam = Expression.Parameter(typeof(object), "flagClassInstance"); - var contextParam = Expression.Parameter(typeof(object), "context"); - - // (FlagClassType)flagClassInstance - var castInstance = Expression.Convert(instanceParam, flagClassType); - - // ((FlagClassType)flagClassInstance).Property - var propertyAccess = Expression.Property(castInstance, property); - - // (TContext)context - var castContext = Expression.Convert(contextParam, contextType); - - // Invoke the FeatureFlag delegate with the cast context - var invokeCall = Expression.Invoke(propertyAccess, castContext); - - // Box the result to object - var boxed = Expression.Convert(invokeCall, typeof(object)); - - return Expression.Lambda>( - boxed, instanceParam, contextParam).Compile(); - } -} +using System.Linq.Expressions; +using System.Reflection; + +namespace Cocoar.Configuration.Flags.Internal; + +/// +/// Pre-computed metadata for a single contextual flag property, built once at startup +/// from the resolver cascade and stored in . +/// Includes compiled delegates that replace per-request reflection. +/// +/// The feature flag or entitlement class that owns the property. +/// The FeatureFlag<TContext, TResult> property info. +/// The TContext type extracted from the flag property's generic args. +/// The winning resolver registration after cascade resolution. +/// +/// Pre-compiled delegate that calls IContextResolver<TRequest,TContext>.ResolveAsync(request) +/// on the resolver instance and returns the resolved context as Task<object?>. +/// Built once at startup to avoid per-request / reflection overhead. +/// +/// +/// Pre-compiled delegate that reads the FeatureFlag<TContext,TResult> property from the flag class +/// instance, invokes it with the resolved context, and returns the result as object?. +/// Built once at startup to avoid per-request reflection overhead. +/// +internal sealed record FlagEvaluationEntry( + Type FlagClassType, + PropertyInfo Property, + Type ContextType, + ContextResolverRegistration Resolver, + Func> CompiledResolveAsync, + Func CompiledFlagInvoke) +{ + /// + /// Builds strongly-typed delegate wrappers for resolver invocation and flag evaluation. + /// Uses a generic helper method so that the cast and call are baked into the delegate + /// at construction time, eliminating all per-request reflection. + /// + internal static FlagEvaluationEntry Create( + Type flagClassType, + PropertyInfo property, + Type contextType, + ContextResolverRegistration resolver) + { + var resolveAsync = BuildResolveAsyncDelegate(resolver.RequestType, contextType); + var flagInvoke = BuildFlagInvokeDelegate(flagClassType, property, contextType); + + return new FlagEvaluationEntry( + flagClassType, property, contextType, resolver, + resolveAsync, flagInvoke); + } + + private static Func> BuildResolveAsyncDelegate( + Type requestType, Type contextType) + { + // Call the generic helper via reflection once at startup to produce a + // strongly-typed lambda that runs without reflection at request time. + var method = typeof(FlagEvaluationEntry) + .GetMethod(nameof(CreateResolveAsyncDelegate), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(requestType, contextType); + + return (Func>)method.Invoke(null, null)!; + } + + private static Func> CreateResolveAsyncDelegate() + { + return async (resolverInstance, request) => + { + var typed = (IContextResolver)resolverInstance; + var result = await typed.ResolveAsync((TRequest)request).ConfigureAwait(false); + return result; + }; + } + + /// + /// Builds a compiled expression tree that: casts the flag class instance to its concrete type, + /// reads the FeatureFlag<TContext, TResult> property, invokes the delegate with the + /// cast context argument, and boxes the result to object?. + /// + private static Func BuildFlagInvokeDelegate( + Type flagClassType, PropertyInfo property, Type contextType) + { + // Parameters: (object flagClassInstance, object? context) + var instanceParam = Expression.Parameter(typeof(object), "flagClassInstance"); + var contextParam = Expression.Parameter(typeof(object), "context"); + + // (FlagClassType)flagClassInstance + var castInstance = Expression.Convert(instanceParam, flagClassType); + + // ((FlagClassType)flagClassInstance).Property + var propertyAccess = Expression.Property(castInstance, property); + + // (TContext)context + var castContext = Expression.Convert(contextParam, contextType); + + // Invoke the FeatureFlag delegate with the cast context + var invokeCall = Expression.Invoke(propertyAccess, castContext); + + // Box the result to object + var boxed = Expression.Convert(invokeCall, typeof(object)); + + return Expression.Lambda>( + boxed, instanceParam, contextParam).Compile(); + } +} diff --git a/src/Cocoar.Configuration/Fluent/IConfigRuleBuilder.cs b/src/Cocoar.Configuration/Fluent/IConfigRuleBuilder.cs index e9d1d2e..9049886 100644 --- a/src/Cocoar.Configuration/Fluent/IConfigRuleBuilder.cs +++ b/src/Cocoar.Configuration/Fluent/IConfigRuleBuilder.cs @@ -1,8 +1,8 @@ -using Cocoar.Configuration.Rules; - -namespace Cocoar.Configuration.Fluent; - -public interface IConfigRuleBuilder -{ - ConfigRule Build(); -} +using Cocoar.Configuration.Rules; + +namespace Cocoar.Configuration.Fluent; + +public interface IConfigRuleBuilder +{ + ConfigRule Build(); +} diff --git a/src/Cocoar.Configuration/Fluent/ProviderRuleBuilder.cs b/src/Cocoar.Configuration/Fluent/ProviderRuleBuilder.cs index 9850cf4..3e53071 100644 --- a/src/Cocoar.Configuration/Fluent/ProviderRuleBuilder.cs +++ b/src/Cocoar.Configuration/Fluent/ProviderRuleBuilder.cs @@ -1,60 +1,60 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Providers.Abstractions; -using Cocoar.Configuration.Rules; - -namespace Cocoar.Configuration.Fluent; - -public sealed class ProviderRuleBuilder : RuleBuilderBase>, IConfigRuleBuilder - where TProvider : ConfigurationProvider - where TInstanceOptions : IProviderConfiguration - where TQueryOptions : IProviderQuery -{ - private readonly Func _instanceFactory; - private readonly Func _queryFactory; - - public ProviderRuleBuilder( - Func instanceFactory, - Func queryFactory) - { - _instanceFactory = instanceFactory ?? throw new ArgumentNullException(nameof(instanceFactory)); - _queryFactory = queryFactory ?? throw new ArgumentNullException(nameof(queryFactory)); - } - - public ProviderRuleBuilder( - Func instanceFactory, - Func queryFactory, - Type concreteType) - : this(instanceFactory, queryFactory) - { - ConcreteType = concreteType; - } - - public ConfigRule Build() - { - var registration = BuildRegistration(); - var opts = new ConfigRuleOptions( - Required: IsRequired, - UseWhen: UseWhen, - Name: Name, - TenantScoped: IsTenantScoped, - ActivationGate: ActivationGate) - .WithMount(MountPath) - .WithSelect(SelectPath); - - return ConfigRule.Create( - _instanceFactory, - _queryFactory, - registration, - opts); - } - - /// - /// This implicit conversion triggers immediately. - /// Ensure the fluent chain is complete before the implicit conversion occurs, - /// as any further method calls after conversion will not be reflected in the built rule. - /// - public static implicit operator ConfigRule(ProviderRuleBuilder builder) - { - return builder.Build(); - } -} +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.Configuration.Rules; + +namespace Cocoar.Configuration.Fluent; + +public sealed class ProviderRuleBuilder : RuleBuilderBase>, IConfigRuleBuilder + where TProvider : ConfigurationProvider + where TInstanceOptions : IProviderConfiguration + where TQueryOptions : IProviderQuery +{ + private readonly Func _instanceFactory; + private readonly Func _queryFactory; + + public ProviderRuleBuilder( + Func instanceFactory, + Func queryFactory) + { + _instanceFactory = instanceFactory ?? throw new ArgumentNullException(nameof(instanceFactory)); + _queryFactory = queryFactory ?? throw new ArgumentNullException(nameof(queryFactory)); + } + + public ProviderRuleBuilder( + Func instanceFactory, + Func queryFactory, + Type concreteType) + : this(instanceFactory, queryFactory) + { + ConcreteType = concreteType; + } + + public ConfigRule Build() + { + var registration = BuildRegistration(); + var opts = new ConfigRuleOptions( + Required: IsRequired, + UseWhen: UseWhen, + Name: Name, + TenantScoped: IsTenantScoped, + ActivationGate: ActivationGate) + .WithMount(MountPath) + .WithSelect(SelectPath); + + return ConfigRule.Create( + _instanceFactory, + _queryFactory, + registration, + opts); + } + + /// + /// This implicit conversion triggers immediately. + /// Ensure the fluent chain is complete before the implicit conversion occurs, + /// as any further method calls after conversion will not be reflected in the built rule. + /// + public static implicit operator ConfigRule(ProviderRuleBuilder builder) + { + return builder.Build(); + } +} diff --git a/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs b/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs index 6addb1b..37a8c9a 100644 --- a/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs +++ b/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs @@ -1,152 +1,152 @@ -using Cocoar.Configuration.Core; - -namespace Cocoar.Configuration.Fluent; - -public abstract class RuleBuilderBase - where TBuilder : RuleBuilderBase -{ - protected bool IsRequired { get; set; } - - /// - /// Static marker set by . Distinct from the predicate (which - /// drives runtime skip): the DI planner and analyzers read this to exclude purely tenant-scoped types from - /// the global injection plan (ADR-005 §5) without having to evaluate the predicate. - /// - protected bool IsTenantScoped { get; set; } - - protected Func? UseWhen { get; set; } - - /// - /// A system-level activation gate, set by the DI/HTTP service-backed (Layer-2, ADR-006) overloads. - /// Evaluated independently of and the marker, so a later - /// user cannot remove it; the rule is skipped until the gate returns true. - /// - internal Func? ActivationGate { get; private set; } - - protected Type? ConcreteType { get; set; } - protected string? MountPath { get; set; } - protected string? SelectPath { get; set; } - protected string? Name { get; set; } - - /// - /// Marks the rule as required. If a required rule fails to load or deserialize, - /// the entire recompute is rolled back and the previous configuration snapshot is retained. - /// - /// True to mark the rule as required (default); false to revert to optional. - /// This builder for chaining. - public TBuilder Required(bool value = true) - { - IsRequired = value; - return (TBuilder)this; - } - - /// - /// Conditionally executes this rule based on a predicate evaluated against the current configuration state. - /// If the predicate returns false, the rule is skipped during recompute. - /// - /// A function that receives the current and returns true if the rule should run. - /// This builder for chaining. - public TBuilder When(Func predicate) - { - UseWhen = predicate; - return (TBuilder)this; - } - - /// - /// Marks this rule as tenant-scoped: it runs only when the configuration is resolved for a tenant - /// ( is present) and is skipped in the global, tenant-agnostic - /// pipeline. Shorthand for .When(a => !string.IsNullOrWhiteSpace(a.Tenant)), composed (AND) with any - /// existing predicate. Use together with a tenant-varying factory, e.g. - /// .FromFile(a => $"db.{a.Tenant}.json").TenantScoped(). - /// - /// This builder for chaining. - public TBuilder TenantScoped() - { - IsTenantScoped = true; - var existing = UseWhen; - UseWhen = existing is null - ? static a => !string.IsNullOrWhiteSpace(a.Tenant) - : a => existing(a) && !string.IsNullOrWhiteSpace(a.Tenant); - return (TBuilder)this; - } - - /// - /// Attaches a system-level activation gate (composed with AND), evaluated independently of - /// so a later user .When() cannot clobber it. The service-backed overloads (FromStore, - /// FromHttp((sp,a)=>…)) — and third-party ones — use it to keep a Layer-2 rule dormant until the - /// container is built: .WithActivationGate(_ => context.IsActive) (ADR-006). - /// - public TBuilder WithActivationGate(Func gate) - { - ArgumentNullException.ThrowIfNull(gate); - var existing = ActivationGate; - ActivationGate = existing is null ? gate : a => existing(a) && gate(a); - return (TBuilder)this; - } - - /// - /// Sets the JSON mount path used when merging this rule's output into the target configuration type. - /// Values from this rule are nested under the given path before deserialization. - /// - /// The dot-notation path to mount values under (e.g., "Database" or "Feature.Flags"). - /// This builder for chaining. - public TBuilder MountAt(string mountPath) - { - if (string.IsNullOrWhiteSpace(mountPath)) - { - throw new ArgumentException("mountPath cannot be null/empty", nameof(mountPath)); - } - - MountPath = mountPath.Trim(); - return (TBuilder)this; - } - - /// - /// Sets the JSON selection path used to extract a sub-document from the provider's output - /// before it is merged into the target configuration type. - /// - /// The dot-notation path of the JSON property to select (e.g., "ConnectionStrings.Default"). - /// This builder for chaining. - public TBuilder Select(string selectPath) - { - if (string.IsNullOrWhiteSpace(selectPath)) - { - throw new ArgumentException("selectPath cannot be null/empty", nameof(selectPath)); - } - - SelectPath = selectPath.Trim(); - return (TBuilder)this; - } - - /// - /// Assigns a display name to this rule for use in health monitoring and diagnostic output. - /// - /// A short, descriptive name for the rule (e.g., "BaseSettings", "ProductionOverride"). - /// This builder for chaining. - public TBuilder Named(string name) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("name cannot be null/empty", nameof(name)); - } - - Name = name.Trim(); - return (TBuilder)this; - } - - protected void SetConcreteType() - { - ConcreteType = typeof(T); - } - - protected Type BuildRegistration() - { - if (ConcreteType is null) - { - throw new InvalidOperationException( - "Missing .For() call. Every rule needs to know which config class to populate."); - } - - return ConcreteType; - } -} +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.Fluent; + +public abstract class RuleBuilderBase + where TBuilder : RuleBuilderBase +{ + protected bool IsRequired { get; set; } + + /// + /// Static marker set by . Distinct from the predicate (which + /// drives runtime skip): the DI planner and analyzers read this to exclude purely tenant-scoped types from + /// the global injection plan (ADR-005 §5) without having to evaluate the predicate. + /// + protected bool IsTenantScoped { get; set; } + + protected Func? UseWhen { get; set; } + + /// + /// A system-level activation gate, set by the DI/HTTP service-backed (Layer-2, ADR-006) overloads. + /// Evaluated independently of and the marker, so a later + /// user cannot remove it; the rule is skipped until the gate returns true. + /// + internal Func? ActivationGate { get; private set; } + + protected Type? ConcreteType { get; set; } + protected string? MountPath { get; set; } + protected string? SelectPath { get; set; } + protected string? Name { get; set; } + + /// + /// Marks the rule as required. If a required rule fails to load or deserialize, + /// the entire recompute is rolled back and the previous configuration snapshot is retained. + /// + /// True to mark the rule as required (default); false to revert to optional. + /// This builder for chaining. + public TBuilder Required(bool value = true) + { + IsRequired = value; + return (TBuilder)this; + } + + /// + /// Conditionally executes this rule based on a predicate evaluated against the current configuration state. + /// If the predicate returns false, the rule is skipped during recompute. + /// + /// A function that receives the current and returns true if the rule should run. + /// This builder for chaining. + public TBuilder When(Func predicate) + { + UseWhen = predicate; + return (TBuilder)this; + } + + /// + /// Marks this rule as tenant-scoped: it runs only when the configuration is resolved for a tenant + /// ( is present) and is skipped in the global, tenant-agnostic + /// pipeline. Shorthand for .When(a => !string.IsNullOrWhiteSpace(a.Tenant)), composed (AND) with any + /// existing predicate. Use together with a tenant-varying factory, e.g. + /// .FromFile(a => $"db.{a.Tenant}.json").TenantScoped(). + /// + /// This builder for chaining. + public TBuilder TenantScoped() + { + IsTenantScoped = true; + var existing = UseWhen; + UseWhen = existing is null + ? static a => !string.IsNullOrWhiteSpace(a.Tenant) + : a => existing(a) && !string.IsNullOrWhiteSpace(a.Tenant); + return (TBuilder)this; + } + + /// + /// Attaches a system-level activation gate (composed with AND), evaluated independently of + /// so a later user .When() cannot clobber it. The service-backed overloads (FromStore, + /// FromHttp((sp,a)=>…)) — and third-party ones — use it to keep a Layer-2 rule dormant until the + /// container is built: .WithActivationGate(_ => context.IsActive) (ADR-006). + /// + public TBuilder WithActivationGate(Func gate) + { + ArgumentNullException.ThrowIfNull(gate); + var existing = ActivationGate; + ActivationGate = existing is null ? gate : a => existing(a) && gate(a); + return (TBuilder)this; + } + + /// + /// Sets the JSON mount path used when merging this rule's output into the target configuration type. + /// Values from this rule are nested under the given path before deserialization. + /// + /// The dot-notation path to mount values under (e.g., "Database" or "Feature.Flags"). + /// This builder for chaining. + public TBuilder MountAt(string mountPath) + { + if (string.IsNullOrWhiteSpace(mountPath)) + { + throw new ArgumentException("mountPath cannot be null/empty", nameof(mountPath)); + } + + MountPath = mountPath.Trim(); + return (TBuilder)this; + } + + /// + /// Sets the JSON selection path used to extract a sub-document from the provider's output + /// before it is merged into the target configuration type. + /// + /// The dot-notation path of the JSON property to select (e.g., "ConnectionStrings.Default"). + /// This builder for chaining. + public TBuilder Select(string selectPath) + { + if (string.IsNullOrWhiteSpace(selectPath)) + { + throw new ArgumentException("selectPath cannot be null/empty", nameof(selectPath)); + } + + SelectPath = selectPath.Trim(); + return (TBuilder)this; + } + + /// + /// Assigns a display name to this rule for use in health monitoring and diagnostic output. + /// + /// A short, descriptive name for the rule (e.g., "BaseSettings", "ProductionOverride"). + /// This builder for chaining. + public TBuilder Named(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("name cannot be null/empty", nameof(name)); + } + + Name = name.Trim(); + return (TBuilder)this; + } + + protected void SetConcreteType() + { + ConcreteType = typeof(T); + } + + protected Type BuildRegistration() + { + if (ConcreteType is null) + { + throw new InvalidOperationException( + "Missing .For() call. Every rule needs to know which config class to populate."); + } + + return ConcreteType; + } +} diff --git a/src/Cocoar.Configuration/Fluent/RulesBuilder.cs b/src/Cocoar.Configuration/Fluent/RulesBuilder.cs index 572924e..82c5319 100644 --- a/src/Cocoar.Configuration/Fluent/RulesBuilder.cs +++ b/src/Cocoar.Configuration/Fluent/RulesBuilder.cs @@ -1,40 +1,40 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.Fluent; - -/// -/// Builder for creating configuration rules with a fluent API. -/// Start with For<T>() to create type-safe rules. -/// -public sealed class RulesBuilder -{ - /// - /// Start a type-safe rule for configuration type T. - /// - /// The configuration type this rule will populate. - /// A typed rule builder for specifying the configuration source. -#pragma warning disable CA1822 // Mark members as static - intentionally instance method for fluent API consistency - public TypedRuleBuilder For() where T : class => new(); -#pragma warning restore CA1822 -} - -/// -/// Extension methods for advanced provider scenarios on TypedRuleBuilder. -/// -public static class TypedRuleBuilderExtensions -{ - /// - /// Creates a rule using a generic provider with custom options factories. - /// For advanced scenarios where you need full control over provider instantiation. - /// - public static ProviderRuleBuilder FromProvider( - this TypedProviderBuilder builder, - Func instanceOptions, - Func queryOptions) - where T : class - where TProvider : ConfigurationProvider - where TInstanceOptions : IProviderConfiguration - where TQueryOptions : IProviderQuery - => new(instanceOptions, queryOptions, typeof(T)); -} +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Fluent; + +/// +/// Builder for creating configuration rules with a fluent API. +/// Start with For<T>() to create type-safe rules. +/// +public sealed class RulesBuilder +{ + /// + /// Start a type-safe rule for configuration type T. + /// + /// The configuration type this rule will populate. + /// A typed rule builder for specifying the configuration source. +#pragma warning disable CA1822 // Mark members as static - intentionally instance method for fluent API consistency + public TypedRuleBuilder For() where T : class => new(); +#pragma warning restore CA1822 +} + +/// +/// Extension methods for advanced provider scenarios on TypedRuleBuilder. +/// +public static class TypedRuleBuilderExtensions +{ + /// + /// Creates a rule using a generic provider with custom options factories. + /// For advanced scenarios where you need full control over provider instantiation. + /// + public static ProviderRuleBuilder FromProvider( + this TypedProviderBuilder builder, + Func instanceOptions, + Func queryOptions) + where T : class + where TProvider : ConfigurationProvider + where TInstanceOptions : IProviderConfiguration + where TQueryOptions : IProviderQuery + => new(instanceOptions, queryOptions, typeof(T)); +} diff --git a/src/Cocoar.Configuration/Fluent/TypedRuleBuilder.cs b/src/Cocoar.Configuration/Fluent/TypedRuleBuilder.cs index 93708d7..eb5d6a9 100644 --- a/src/Cocoar.Configuration/Fluent/TypedRuleBuilder.cs +++ b/src/Cocoar.Configuration/Fluent/TypedRuleBuilder.cs @@ -1,12 +1,12 @@ -namespace Cocoar.Configuration.Fluent; - -/// -/// Type-first rule builder - starts with For<T>() to ensure type safety. -/// Inherits provider methods from and adds -/// aggregate operations (Aggregate, FromFiles) that are not available inside aggregate lambdas. -/// -/// The configuration type this rule will populate. -public sealed class TypedRuleBuilder : TypedProviderBuilder where T : class -{ - internal TypedRuleBuilder() { } -} +namespace Cocoar.Configuration.Fluent; + +/// +/// Type-first rule builder - starts with For<T>() to ensure type safety. +/// Inherits provider methods from and adds +/// aggregate operations (Aggregate, FromFiles) that are not available inside aggregate lambdas. +/// +/// The configuration type this rule will populate. +public sealed class TypedRuleBuilder : TypedProviderBuilder where T : class +{ + internal TypedRuleBuilder() { } +} diff --git a/src/Cocoar.Configuration/GlobalUsings.cs b/src/Cocoar.Configuration/GlobalUsings.cs index d37d24f..8ee28e6 100644 --- a/src/Cocoar.Configuration/GlobalUsings.cs +++ b/src/Cocoar.Configuration/GlobalUsings.cs @@ -1 +1 @@ -global using Cocoar.Configuration.Reactive.Internal; +global using Cocoar.Configuration.Reactive.Internal; diff --git a/src/Cocoar.Configuration/Health/ConfigurationHealthModels.cs b/src/Cocoar.Configuration/Health/ConfigurationHealthModels.cs index fadb1cd..91d543e 100644 --- a/src/Cocoar.Configuration/Health/ConfigurationHealthModels.cs +++ b/src/Cocoar.Configuration/Health/ConfigurationHealthModels.cs @@ -1,9 +1,9 @@ -namespace Cocoar.Configuration.Health; - -public enum HealthStatus -{ - Unknown = 0, - Healthy = 1, - Degraded = 2, - Unhealthy = 3 -} +namespace Cocoar.Configuration.Health; + +public enum HealthStatus +{ + Unknown = 0, + Healthy = 1, + Degraded = 2, + Unhealthy = 3 +} diff --git a/src/Cocoar.Configuration/Health/IFlagsHealthSource.cs b/src/Cocoar.Configuration/Health/IFlagsHealthSource.cs index 7af667f..50144f9 100644 --- a/src/Cocoar.Configuration/Health/IFlagsHealthSource.cs +++ b/src/Cocoar.Configuration/Health/IFlagsHealthSource.cs @@ -1,13 +1,13 @@ -namespace Cocoar.Configuration.Health; - -/// -/// Provides expired feature flag detection for health tracking. -/// Implemented by FeatureFlagsHealthSource and composed via UseFeatureFlags. -/// -public interface IFlagsHealthSource -{ - /// - /// Returns true if any registered feature flag class has expired. - /// - bool HasExpiredFlags(); -} +namespace Cocoar.Configuration.Health; + +/// +/// Provides expired feature flag detection for health tracking. +/// Implemented by FeatureFlagsHealthSource and composed via UseFeatureFlags. +/// +public interface IFlagsHealthSource +{ + /// + /// Returns true if any registered feature flag class has expired. + /// + bool HasExpiredFlags(); +} diff --git a/src/Cocoar.Configuration/Helper/JsonHelper.cs b/src/Cocoar.Configuration/Helper/JsonHelper.cs index 28d59f5..07a1748 100644 --- a/src/Cocoar.Configuration/Helper/JsonHelper.cs +++ b/src/Cocoar.Configuration/Helper/JsonHelper.cs @@ -1,222 +1,222 @@ -using System.Buffers; -using System.Text; -using System.Text.Json; - -namespace Cocoar.Configuration.Helper; - -internal static class JsonHelper -{ - private static readonly JsonDocument s_emptyDoc = JsonDocument.Parse("{}"); - internal static readonly JsonElement EmptyObject = s_emptyDoc.RootElement; - - /// - /// Wraps element under nested objects for a colon-separated path. - /// "a:b:c" -> { "a": { "b": { "c": element } } } - /// - public static JsonElement WrapIfNeeded(JsonElement element, string? targetPath) - { - if (string.IsNullOrWhiteSpace(targetPath)) - { - return element; - } - - var segments = targetPath.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (segments.Length == 0) - { - return element; - } - - JsonElement current = element; - for (var i = segments.Length - 1; i >= 0; i--) - { - var obj = new Dictionary(1) { [segments[i]] = current }; - current = JsonSerializer.SerializeToElement(obj); - } - - return current; - } - - public static bool TrySelectByPath(JsonElement root, string path, out JsonElement result) - { - if (string.IsNullOrWhiteSpace(path)) - { - result = root; - return true; - } - - var cur = root; - var span = path.AsSpan(); - var start = 0; - - while (start <= span.Length) - { - var rel = span[start..].IndexOf(':'); - var seg = (rel < 0 ? span[start..] : span.Slice(start, rel)).Trim(); - - if (!seg.IsEmpty) - { - if (cur.ValueKind == JsonValueKind.Array && TryParseNonNegativeInt(seg, out var idx)) - { - if ((uint)idx >= (uint)cur.GetArrayLength()) - { - result = default; - return false; - } - - cur = cur[idx]; - } - else if (!TryGetPropertyUtf8(cur, seg, out var next)) - { - result = default; - return false; - } - else - { - cur = next; - } - } - - if (rel < 0) - { - break; - } - - start += rel + 1; - } - - result = cur; - return true; - } - - public static JsonElement SelectByPathOrEmpty(JsonElement root, string path) - => TrySelectByPath(root, path, out var found) ? found : EmptyObject; - - - public static JsonElement SelectColonDelimited(JsonElement root, string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return root; - } - - if (!TrySelectByPath(root, path, out var found)) - { - throw new KeyNotFoundException($"Path '{path}' not found in JSON document."); - } - - return found; - } - - public static Dictionary Flatten(JsonElement element) - { - var dict = new Dictionary(); - FlattenRecursive(element, null, dict); - return dict; - } - - - public static JsonElement Unflatten(Dictionary flat) - { - var root = new Dictionary(); - - foreach (var kvp in flat) - { - var keys = kvp.Key.Split(':'); - var current = root; - - for (var i = 0; i < keys.Length; i++) - { - var key = keys[i]; - if (i == keys.Length - 1) - { - current[key] = kvp.Value; - } - else - { - if (!current.TryGetValue(key, out var next) || next is not Dictionary) - { - next = new Dictionary(); - current[key] = next; - } - - current = (Dictionary)next; - } - } - } - - // Avoid creating a managed string for user data; serialize directly to UTF-8 bytes - var buffer = new ArrayBufferWriter(); - using (var writer = new Utf8JsonWriter(buffer)) - { - JsonSerializer.Serialize(writer, root); - writer.Flush(); - } - return JsonDocument.Parse(buffer.WrittenMemory).RootElement; - } - - private static void FlattenRecursive(JsonElement e, string? prefix, Dictionary dict) - { - if (e.ValueKind == JsonValueKind.Object) - { - foreach (var prop in e.EnumerateObject()) - { - var key = prefix == null ? prop.Name : $"{prefix}:{prop.Name}"; - FlattenRecursive(prop.Value, key, dict); - } - } - else if (prefix != null) - { - dict[prefix] = e; - } - } - - private static bool TryParseNonNegativeInt(ReadOnlySpan s, out int value) - { - var v = 0; - if (s.IsEmpty) - { - value = 0; - return false; - } - - foreach (var c in s) - { - if ((uint)(c - '0') > 9u) - { - value = 0; - return false; - } - - v = v * 10 + (c - '0'); - } - - value = v; - return true; - } - - private static bool TryGetPropertyUtf8(JsonElement obj, ReadOnlySpan name, out JsonElement value) - { - const int stackLimit = 256; - var maxBytes = Encoding.UTF8.GetMaxByteCount(name.Length); - - if (maxBytes <= stackLimit) - { - Span buf = stackalloc byte[stackLimit]; - var written = Encoding.UTF8.GetBytes(name, buf); - return obj.TryGetProperty(buf.Slice(0, written), out value); - } - else - { - var rented = ArrayPool.Shared.Rent(maxBytes); - try - { - var written = Encoding.UTF8.GetBytes(name, rented); - return obj.TryGetProperty(new ReadOnlySpan(rented, 0, written), out value); - } - finally - { - ArrayPool.Shared.Return(rented); - } - } - } -} +using System.Buffers; +using System.Text; +using System.Text.Json; + +namespace Cocoar.Configuration.Helper; + +internal static class JsonHelper +{ + private static readonly JsonDocument s_emptyDoc = JsonDocument.Parse("{}"); + internal static readonly JsonElement EmptyObject = s_emptyDoc.RootElement; + + /// + /// Wraps element under nested objects for a colon-separated path. + /// "a:b:c" -> { "a": { "b": { "c": element } } } + /// + public static JsonElement WrapIfNeeded(JsonElement element, string? targetPath) + { + if (string.IsNullOrWhiteSpace(targetPath)) + { + return element; + } + + var segments = targetPath.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length == 0) + { + return element; + } + + JsonElement current = element; + for (var i = segments.Length - 1; i >= 0; i--) + { + var obj = new Dictionary(1) { [segments[i]] = current }; + current = JsonSerializer.SerializeToElement(obj); + } + + return current; + } + + public static bool TrySelectByPath(JsonElement root, string path, out JsonElement result) + { + if (string.IsNullOrWhiteSpace(path)) + { + result = root; + return true; + } + + var cur = root; + var span = path.AsSpan(); + var start = 0; + + while (start <= span.Length) + { + var rel = span[start..].IndexOf(':'); + var seg = (rel < 0 ? span[start..] : span.Slice(start, rel)).Trim(); + + if (!seg.IsEmpty) + { + if (cur.ValueKind == JsonValueKind.Array && TryParseNonNegativeInt(seg, out var idx)) + { + if ((uint)idx >= (uint)cur.GetArrayLength()) + { + result = default; + return false; + } + + cur = cur[idx]; + } + else if (!TryGetPropertyUtf8(cur, seg, out var next)) + { + result = default; + return false; + } + else + { + cur = next; + } + } + + if (rel < 0) + { + break; + } + + start += rel + 1; + } + + result = cur; + return true; + } + + public static JsonElement SelectByPathOrEmpty(JsonElement root, string path) + => TrySelectByPath(root, path, out var found) ? found : EmptyObject; + + + public static JsonElement SelectColonDelimited(JsonElement root, string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return root; + } + + if (!TrySelectByPath(root, path, out var found)) + { + throw new KeyNotFoundException($"Path '{path}' not found in JSON document."); + } + + return found; + } + + public static Dictionary Flatten(JsonElement element) + { + var dict = new Dictionary(); + FlattenRecursive(element, null, dict); + return dict; + } + + + public static JsonElement Unflatten(Dictionary flat) + { + var root = new Dictionary(); + + foreach (var kvp in flat) + { + var keys = kvp.Key.Split(':'); + var current = root; + + for (var i = 0; i < keys.Length; i++) + { + var key = keys[i]; + if (i == keys.Length - 1) + { + current[key] = kvp.Value; + } + else + { + if (!current.TryGetValue(key, out var next) || next is not Dictionary) + { + next = new Dictionary(); + current[key] = next; + } + + current = (Dictionary)next; + } + } + } + + // Avoid creating a managed string for user data; serialize directly to UTF-8 bytes + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + JsonSerializer.Serialize(writer, root); + writer.Flush(); + } + return JsonDocument.Parse(buffer.WrittenMemory).RootElement; + } + + private static void FlattenRecursive(JsonElement e, string? prefix, Dictionary dict) + { + if (e.ValueKind == JsonValueKind.Object) + { + foreach (var prop in e.EnumerateObject()) + { + var key = prefix == null ? prop.Name : $"{prefix}:{prop.Name}"; + FlattenRecursive(prop.Value, key, dict); + } + } + else if (prefix != null) + { + dict[prefix] = e; + } + } + + private static bool TryParseNonNegativeInt(ReadOnlySpan s, out int value) + { + var v = 0; + if (s.IsEmpty) + { + value = 0; + return false; + } + + foreach (var c in s) + { + if ((uint)(c - '0') > 9u) + { + value = 0; + return false; + } + + v = v * 10 + (c - '0'); + } + + value = v; + return true; + } + + private static bool TryGetPropertyUtf8(JsonElement obj, ReadOnlySpan name, out JsonElement value) + { + const int stackLimit = 256; + var maxBytes = Encoding.UTF8.GetMaxByteCount(name.Length); + + if (maxBytes <= stackLimit) + { + Span buf = stackalloc byte[stackLimit]; + var written = Encoding.UTF8.GetBytes(name, buf); + return obj.TryGetProperty(buf.Slice(0, written), out value); + } + else + { + var rented = ArrayPool.Shared.Rent(maxBytes); + try + { + var written = Encoding.UTF8.GetBytes(name, rented); + return obj.TryGetProperty(new ReadOnlySpan(rented, 0, written), out value); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + } +} diff --git a/src/Cocoar.Configuration/Helper/JsonTransform.cs b/src/Cocoar.Configuration/Helper/JsonTransform.cs index b9e7a07..171e99c 100644 --- a/src/Cocoar.Configuration/Helper/JsonTransform.cs +++ b/src/Cocoar.Configuration/Helper/JsonTransform.cs @@ -1,229 +1,229 @@ -using System.Buffers; -using System.Text; -using System.Text.Json; - -namespace Cocoar.Configuration.Helper; - -internal static class JsonTransform -{ - private readonly struct Segment - { - public readonly byte[]? NameUtf8; // when property name - public readonly int Index; // when array index - public readonly bool IsIndex; - - public Segment(byte[] nameUtf8) - { - NameUtf8 = nameUtf8; - Index = -1; - IsIndex = false; - } - - public Segment(int index) - { - NameUtf8 = null; - Index = index; - IsIndex = true; - } - } - - public static byte[] SelectAndMount(ReadOnlyMemory input, string? selectPath, string? mountPath) - { - if (string.IsNullOrWhiteSpace(selectPath)) - { - if (string.IsNullOrWhiteSpace(mountPath)) - { - return input.ToArray(); - } - - var segments = ParseMountSegments(mountPath); - var buffer = new ArrayBufferWriter(input.Length + segments.Count * 32); - using (var writer = new Utf8JsonWriter(buffer)) - { - for (int i = 0; i < segments.Count; i++) - { - writer.WriteStartObject(); - writer.WritePropertyName(segments[i]); - } - writer.Flush(); - } - buffer.Write(input.Span); - for (int i = 0; i < segments.Count; i++) - { - buffer.GetSpan(1)[0] = (byte)'}'; - buffer.Advance(1); - } - - return buffer.WrittenSpan.ToArray(); - } - var data = input.Span; - if (TryGetSelectedSlice(data, ParseSelectSegments(selectPath!), out var start, out var length)) - { - var selectedSlice = data.Slice(start, length); - if (string.IsNullOrWhiteSpace(mountPath)) - { - return selectedSlice.ToArray(); - } - - var segments = ParseMountSegments(mountPath!); - var buffer = new ArrayBufferWriter(selectedSlice.Length + segments.Count * 32); - using (var writer = new Utf8JsonWriter(buffer)) - { - for (int i = 0; i < segments.Count; i++) - { - writer.WriteStartObject(); - writer.WritePropertyName(segments[i]); - } - writer.Flush(); - } - - buffer.Write(selectedSlice); - for (int i = 0; i < segments.Count; i++) - { - buffer.GetSpan(1)[0] = (byte)'}'; - buffer.Advance(1); - } - return buffer.WrittenSpan.ToArray(); - } - // Note: This builds a JsonDocument only to select/mount and immediately writes bytes back. - // It does not materialize user payload values as strings, and it does not leak DOM across layers. - // Providers and RuleManager still operate on bytes; parsing for merge happens in the Orchestrator. - using var doc = JsonDocument.Parse(input); - var element = doc.RootElement; - element = JsonHelper.SelectColonDelimited(element, selectPath!); - if (!string.IsNullOrWhiteSpace(mountPath)) - { - element = JsonHelper.WrapIfNeeded(element, mountPath!); - } - var domBuffer = new ArrayBufferWriter(); - using (var writer = new Utf8JsonWriter(domBuffer)) - { - element.WriteTo(writer); - writer.Flush(); - } - return domBuffer.WrittenSpan.ToArray(); - } - - private static List ParseMountSegments(string mountPath) - { - var list = new List(); - var parts = mountPath.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (var p in parts) - { - list.Add(Encoding.UTF8.GetBytes(p)); - } - return list; - } - - private static List ParseSelectSegments(string selectPath) - { - var list = new List(); - var parts = selectPath.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (var p in parts) - { - if (int.TryParse(p, out var idx) && idx >= 0) - { - list.Add(new Segment(idx)); - } - else - { - list.Add(new Segment(Encoding.UTF8.GetBytes(p))); - } - } - return list; - } - - private static bool TryGetSelectedSlice(ReadOnlySpan data, List segs, out int start, out int length) - { - start = length = 0; - var reader = new Utf8JsonReader(data, isFinalBlock: true, state: default); - if (!reader.Read()) return false; - - return FindAtCurrent(ref reader, data, segs, 0, out start, out length); - } - - private static bool FindAtCurrent(ref Utf8JsonReader reader, ReadOnlySpan data, List segs, int segIndex, out int start, out int length) - { - if (segIndex >= segs.Count) - { - var s = checked((int)reader.TokenStartIndex); - if (!reader.TrySkip()) { start = length = 0; return false; } - var e = checked((int)reader.BytesConsumed); - start = s; length = e - s; return true; - } - - switch (reader.TokenType) - { - case JsonTokenType.StartObject: - return FindInObject(ref reader, data, segs, segIndex, out start, out length); - case JsonTokenType.StartArray: - return FindInArray(ref reader, data, segs, segIndex, out start, out length); - default: - start = length = 0; return false; - } - } - - private static bool FindInObject(ref Utf8JsonReader reader, ReadOnlySpan data, List segs, int segIndex, out int start, out int length) - { - start = length = 0; - if (segIndex >= segs.Count || segs[segIndex].IsIndex) return false; - - var target = segs[segIndex].NameUtf8!; - - while (reader.Read()) - { - if (reader.TokenType == JsonTokenType.PropertyName) - { - var name = reader.ValueSpan; - var isMatch = name.SequenceEqual(target); - if (!reader.Read()) return false; // move to value - - if (isMatch) - { - if (FindAtCurrent(ref reader, data, segs, segIndex + 1, out start, out length)) - { - return true; - } - if (!reader.TrySkip()) return false; - } - else - { - if (!reader.TrySkip()) return false; - } - } - else if (reader.TokenType == JsonTokenType.EndObject) - { - break; - } - } - return false; - } - - private static bool FindInArray(ref Utf8JsonReader reader, ReadOnlySpan data, List segs, int segIndex, out int start, out int length) - { - start = length = 0; - if (segIndex >= segs.Count || !segs[segIndex].IsIndex) return false; - var desired = segs[segIndex].Index; - var idx = 0; - - while (reader.Read()) - { - if (reader.TokenType == JsonTokenType.EndArray) break; - - if (idx == desired) - { - if (FindAtCurrent(ref reader, data, segs, segIndex + 1, out start, out length)) - { - return true; - } - if (!reader.TrySkip()) return false; - } - else - { - if (!reader.TrySkip()) return false; - } - idx++; - } - return false; - } -} +using System.Buffers; +using System.Text; +using System.Text.Json; + +namespace Cocoar.Configuration.Helper; + +internal static class JsonTransform +{ + private readonly struct Segment + { + public readonly byte[]? NameUtf8; // when property name + public readonly int Index; // when array index + public readonly bool IsIndex; + + public Segment(byte[] nameUtf8) + { + NameUtf8 = nameUtf8; + Index = -1; + IsIndex = false; + } + + public Segment(int index) + { + NameUtf8 = null; + Index = index; + IsIndex = true; + } + } + + public static byte[] SelectAndMount(ReadOnlyMemory input, string? selectPath, string? mountPath) + { + if (string.IsNullOrWhiteSpace(selectPath)) + { + if (string.IsNullOrWhiteSpace(mountPath)) + { + return input.ToArray(); + } + + var segments = ParseMountSegments(mountPath); + var buffer = new ArrayBufferWriter(input.Length + segments.Count * 32); + using (var writer = new Utf8JsonWriter(buffer)) + { + for (int i = 0; i < segments.Count; i++) + { + writer.WriteStartObject(); + writer.WritePropertyName(segments[i]); + } + writer.Flush(); + } + buffer.Write(input.Span); + for (int i = 0; i < segments.Count; i++) + { + buffer.GetSpan(1)[0] = (byte)'}'; + buffer.Advance(1); + } + + return buffer.WrittenSpan.ToArray(); + } + var data = input.Span; + if (TryGetSelectedSlice(data, ParseSelectSegments(selectPath!), out var start, out var length)) + { + var selectedSlice = data.Slice(start, length); + if (string.IsNullOrWhiteSpace(mountPath)) + { + return selectedSlice.ToArray(); + } + + var segments = ParseMountSegments(mountPath!); + var buffer = new ArrayBufferWriter(selectedSlice.Length + segments.Count * 32); + using (var writer = new Utf8JsonWriter(buffer)) + { + for (int i = 0; i < segments.Count; i++) + { + writer.WriteStartObject(); + writer.WritePropertyName(segments[i]); + } + writer.Flush(); + } + + buffer.Write(selectedSlice); + for (int i = 0; i < segments.Count; i++) + { + buffer.GetSpan(1)[0] = (byte)'}'; + buffer.Advance(1); + } + return buffer.WrittenSpan.ToArray(); + } + // Note: This builds a JsonDocument only to select/mount and immediately writes bytes back. + // It does not materialize user payload values as strings, and it does not leak DOM across layers. + // Providers and RuleManager still operate on bytes; parsing for merge happens in the Orchestrator. + using var doc = JsonDocument.Parse(input); + var element = doc.RootElement; + element = JsonHelper.SelectColonDelimited(element, selectPath!); + if (!string.IsNullOrWhiteSpace(mountPath)) + { + element = JsonHelper.WrapIfNeeded(element, mountPath!); + } + var domBuffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(domBuffer)) + { + element.WriteTo(writer); + writer.Flush(); + } + return domBuffer.WrittenSpan.ToArray(); + } + + private static List ParseMountSegments(string mountPath) + { + var list = new List(); + var parts = mountPath.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var p in parts) + { + list.Add(Encoding.UTF8.GetBytes(p)); + } + return list; + } + + private static List ParseSelectSegments(string selectPath) + { + var list = new List(); + var parts = selectPath.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var p in parts) + { + if (int.TryParse(p, out var idx) && idx >= 0) + { + list.Add(new Segment(idx)); + } + else + { + list.Add(new Segment(Encoding.UTF8.GetBytes(p))); + } + } + return list; + } + + private static bool TryGetSelectedSlice(ReadOnlySpan data, List segs, out int start, out int length) + { + start = length = 0; + var reader = new Utf8JsonReader(data, isFinalBlock: true, state: default); + if (!reader.Read()) return false; + + return FindAtCurrent(ref reader, data, segs, 0, out start, out length); + } + + private static bool FindAtCurrent(ref Utf8JsonReader reader, ReadOnlySpan data, List segs, int segIndex, out int start, out int length) + { + if (segIndex >= segs.Count) + { + var s = checked((int)reader.TokenStartIndex); + if (!reader.TrySkip()) { start = length = 0; return false; } + var e = checked((int)reader.BytesConsumed); + start = s; length = e - s; return true; + } + + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + return FindInObject(ref reader, data, segs, segIndex, out start, out length); + case JsonTokenType.StartArray: + return FindInArray(ref reader, data, segs, segIndex, out start, out length); + default: + start = length = 0; return false; + } + } + + private static bool FindInObject(ref Utf8JsonReader reader, ReadOnlySpan data, List segs, int segIndex, out int start, out int length) + { + start = length = 0; + if (segIndex >= segs.Count || segs[segIndex].IsIndex) return false; + + var target = segs[segIndex].NameUtf8!; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.PropertyName) + { + var name = reader.ValueSpan; + var isMatch = name.SequenceEqual(target); + if (!reader.Read()) return false; // move to value + + if (isMatch) + { + if (FindAtCurrent(ref reader, data, segs, segIndex + 1, out start, out length)) + { + return true; + } + if (!reader.TrySkip()) return false; + } + else + { + if (!reader.TrySkip()) return false; + } + } + else if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + } + return false; + } + + private static bool FindInArray(ref Utf8JsonReader reader, ReadOnlySpan data, List segs, int segIndex, out int start, out int length) + { + start = length = 0; + if (segIndex >= segs.Count || !segs[segIndex].IsIndex) return false; + var desired = segs[segIndex].Index; + var idx = 0; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) break; + + if (idx == desired) + { + if (FindAtCurrent(ref reader, data, segs, segIndex + 1, out start, out length)) + { + return true; + } + if (!reader.TrySkip()) return false; + } + else + { + if (!reader.TrySkip()) return false; + } + idx++; + } + return false; + } +} diff --git a/src/Cocoar.Configuration/Infrastructure/ChangeSubscriptionManager.cs b/src/Cocoar.Configuration/Infrastructure/ChangeSubscriptionManager.cs index dff4f40..e11886b 100644 --- a/src/Cocoar.Configuration/Infrastructure/ChangeSubscriptionManager.cs +++ b/src/Cocoar.Configuration/Infrastructure/ChangeSubscriptionManager.cs @@ -1,55 +1,55 @@ -using Microsoft.Extensions.Logging; -using Cocoar.Configuration.Rules; -using Cocoar.Configuration.Utilities; - -namespace Cocoar.Configuration.Infrastructure; - -internal static partial class ChangeSubscriptionManagerLog -{ - [LoggerMessage(EventId = 4100, Level = LogLevel.Error, Message = "Recompute failed from change trigger")] - public static partial void RecomputeFailedFromChange(this ILogger logger, Exception exception); -} - -internal class ChangeSubscriptionManager(ILogger logger) : IDisposable -{ - private readonly List _changeSubscriptions = new(); - - public void CreateSubscriptions( - IEnumerable ruleManagers, - Action recomputeFromIndexCallback, - int debounceMilliseconds = 300, - int trailingMilliseconds = 40) - { - DisposeAllSubscriptions(); - var list = ruleManagers.ToList(); - var coalescer = new RecomputeCoalescer(logger, recomputeFromIndexCallback, debounceMilliseconds, trailingMilliseconds); - _changeSubscriptions.Add(coalescer); - - for (var i = 0; i < list.Count; i++) - { - var idx = i; - var rm = list[i]; - var subscription = rm.Changes.Subscribe(_ => - { - try { coalescer.Signal(idx); } - catch (Exception ex) { logger.RecomputeFailedFromChange(ex); } - }); - _changeSubscriptions.Add(subscription); - } - } - - public void DisposeAllSubscriptions() - { - foreach (var subscription in _changeSubscriptions.ToArray()) - { - Safety.DisposeQuietly(subscription); - } - - _changeSubscriptions.Clear(); - } - - public void Dispose() - { - DisposeAllSubscriptions(); - } -} +using Microsoft.Extensions.Logging; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Utilities; + +namespace Cocoar.Configuration.Infrastructure; + +internal static partial class ChangeSubscriptionManagerLog +{ + [LoggerMessage(EventId = 4100, Level = LogLevel.Error, Message = "Recompute failed from change trigger")] + public static partial void RecomputeFailedFromChange(this ILogger logger, Exception exception); +} + +internal class ChangeSubscriptionManager(ILogger logger) : IDisposable +{ + private readonly List _changeSubscriptions = new(); + + public void CreateSubscriptions( + IEnumerable ruleManagers, + Action recomputeFromIndexCallback, + int debounceMilliseconds = 300, + int trailingMilliseconds = 40) + { + DisposeAllSubscriptions(); + var list = ruleManagers.ToList(); + var coalescer = new RecomputeCoalescer(logger, recomputeFromIndexCallback, debounceMilliseconds, trailingMilliseconds); + _changeSubscriptions.Add(coalescer); + + for (var i = 0; i < list.Count; i++) + { + var idx = i; + var rm = list[i]; + var subscription = rm.Changes.Subscribe(_ => + { + try { coalescer.Signal(idx); } + catch (Exception ex) { logger.RecomputeFailedFromChange(ex); } + }); + _changeSubscriptions.Add(subscription); + } + } + + public void DisposeAllSubscriptions() + { + foreach (var subscription in _changeSubscriptions.ToArray()) + { + Safety.DisposeQuietly(subscription); + } + + _changeSubscriptions.Clear(); + } + + public void Dispose() + { + DisposeAllSubscriptions(); + } +} diff --git a/src/Cocoar.Configuration/Infrastructure/ExposureRegistry.cs b/src/Cocoar.Configuration/Infrastructure/ExposureRegistry.cs index 25098ae..b7a8757 100644 --- a/src/Cocoar.Configuration/Infrastructure/ExposureRegistry.cs +++ b/src/Cocoar.Configuration/Infrastructure/ExposureRegistry.cs @@ -1,122 +1,122 @@ -using Microsoft.Extensions.Logging; -using System.Collections.Frozen; -using Cocoar.Capabilities; -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Core; - -namespace Cocoar.Configuration.Infrastructure; - - -internal static partial class ExposureRegistryLog -{ - [LoggerMessage(EventId = 3000, Level = LogLevel.Debug, Message = "ConfigureSpec does not have a valid primary type capability, skipping")] - public static partial void MissingPrimaryTypeCapability(this ILogger logger); - - [LoggerMessage(EventId = 3001, Level = LogLevel.Warning, Message = "Interface {InterfaceType} was already exposed by {ExistingConcreteType}, now overridden by {NewConcreteType}")] - public static partial void ExposeOverride(this ILogger logger, string InterfaceType, string ExistingConcreteType, string NewConcreteType); - - [LoggerMessage(EventId = 3002, Level = LogLevel.Debug, Message = "Exposed interface {InterfaceType} → {ConcreteType}")] - public static partial void ExposedInterface(this ILogger logger, string InterfaceType, string ConcreteType); - - [LoggerMessage(EventId = 3003, Level = LogLevel.Warning, Message = "Interface {InterfaceType} deserialization was already mapped to {ExistingConcreteType}, now overridden by {NewConcreteType}")] - public static partial void DeserializeMappingOverride(this ILogger logger, string InterfaceType, string ExistingConcreteType, string NewConcreteType); - - [LoggerMessage(EventId = 3004, Level = LogLevel.Debug, Message = "Interface deserialization mapping: {InterfaceType} → {ConcreteType}")] - public static partial void DeserializeMapping(this ILogger logger, string InterfaceType, string ConcreteType); - - [LoggerMessage(EventId = 3005, Level = LogLevel.Information, Message = "Built exposure registry with {ExposureCount} DI mappings and {DeserializationCount} deserialization mappings")] - public static partial void BuiltExposureRegistry(this ILogger logger, int ExposureCount, int DeserializationCount); -} - -internal sealed class ExposureRegistry -{ - private FrozenDictionary _interfaceToConcreteMap = FrozenDictionary.Empty; - private FrozenDictionary _deserializationMap = FrozenDictionary.Empty; - private readonly ILogger _logger; - private readonly ConfigManagerCapabilityScope _capabilityScope; - - public ExposureRegistry(IEnumerable bindings, ILogger logger, ConfigManagerCapabilityScope capabilityScope) - { - _logger = logger; - _capabilityScope = capabilityScope; - BuildMappingTables(bindings); - } - - public bool TryGetConcreteType(Type interfaceType, out Type concreteType) => _interfaceToConcreteMap.TryGetValue(interfaceType, out concreteType!); - - public IReadOnlyDictionary InterfaceToConcreteMap => _interfaceToConcreteMap; - - /// - /// Gets the deserialization mappings for interfaces to concrete types. - /// This is separate from the DI exposure mappings and is specifically for JSON deserialization. - /// - public IReadOnlyDictionary DeserializationMap => _deserializationMap; - - - private void BuildMappingTables(IEnumerable bindings) - { - var interfaceToConcreteMap = new Dictionary(); - var deserializationMap = new Dictionary(); - - foreach (var configureSpec in bindings) - { - - if(!_capabilityScope.Compositions.TryGet(configureSpec, out var bag)){ - continue; - } - - - if (!bag.TryGetPrimaryAs(out var typeCapability)) - { - _logger.MissingPrimaryTypeCapability(); - continue; - } - - var primaryType = typeCapability!.SelectedType; - if (primaryType.IsClass) - { - var concreteType = primaryType; - - bag.GetAll>().ForEach(exposeAs => - { - var interfaceType = exposeAs.ContractType; - - - if (interfaceToConcreteMap.TryGetValue(interfaceType, out var existingConcrete)) - { - _logger.ExposeOverride(interfaceType.Name, existingConcrete.Name, concreteType.Name); - } - - interfaceToConcreteMap[interfaceType] = concreteType; - - _logger.ExposedInterface(interfaceType.Name, concreteType.Name); - }); - } - if (primaryType.IsInterface) - { - var interfaceType = primaryType; - - var deserializeCaps = bag.GetAll>(); - var deserializeToCapability = deserializeCaps.Count != 0 ? deserializeCaps[0] : null; - if (deserializeToCapability != null) - { - var concreteType = deserializeToCapability.ConcreteType; - - if (deserializationMap.TryGetValue(interfaceType, out var existingConcrete)) - { - _logger.DeserializeMappingOverride(interfaceType.Name, existingConcrete.Name, concreteType.Name); - } - - deserializationMap[interfaceType] = concreteType; - - _logger.DeserializeMapping(interfaceType.Name, concreteType.Name); - } - } - } - - _interfaceToConcreteMap = interfaceToConcreteMap.ToFrozenDictionary(); - _deserializationMap = deserializationMap.ToFrozenDictionary(); - - _logger.BuiltExposureRegistry(_interfaceToConcreteMap.Count, _deserializationMap.Count); - } -} +using Microsoft.Extensions.Logging; +using System.Collections.Frozen; +using Cocoar.Capabilities; +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.Infrastructure; + + +internal static partial class ExposureRegistryLog +{ + [LoggerMessage(EventId = 3000, Level = LogLevel.Debug, Message = "ConfigureSpec does not have a valid primary type capability, skipping")] + public static partial void MissingPrimaryTypeCapability(this ILogger logger); + + [LoggerMessage(EventId = 3001, Level = LogLevel.Warning, Message = "Interface {InterfaceType} was already exposed by {ExistingConcreteType}, now overridden by {NewConcreteType}")] + public static partial void ExposeOverride(this ILogger logger, string InterfaceType, string ExistingConcreteType, string NewConcreteType); + + [LoggerMessage(EventId = 3002, Level = LogLevel.Debug, Message = "Exposed interface {InterfaceType} → {ConcreteType}")] + public static partial void ExposedInterface(this ILogger logger, string InterfaceType, string ConcreteType); + + [LoggerMessage(EventId = 3003, Level = LogLevel.Warning, Message = "Interface {InterfaceType} deserialization was already mapped to {ExistingConcreteType}, now overridden by {NewConcreteType}")] + public static partial void DeserializeMappingOverride(this ILogger logger, string InterfaceType, string ExistingConcreteType, string NewConcreteType); + + [LoggerMessage(EventId = 3004, Level = LogLevel.Debug, Message = "Interface deserialization mapping: {InterfaceType} → {ConcreteType}")] + public static partial void DeserializeMapping(this ILogger logger, string InterfaceType, string ConcreteType); + + [LoggerMessage(EventId = 3005, Level = LogLevel.Information, Message = "Built exposure registry with {ExposureCount} DI mappings and {DeserializationCount} deserialization mappings")] + public static partial void BuiltExposureRegistry(this ILogger logger, int ExposureCount, int DeserializationCount); +} + +internal sealed class ExposureRegistry +{ + private FrozenDictionary _interfaceToConcreteMap = FrozenDictionary.Empty; + private FrozenDictionary _deserializationMap = FrozenDictionary.Empty; + private readonly ILogger _logger; + private readonly ConfigManagerCapabilityScope _capabilityScope; + + public ExposureRegistry(IEnumerable bindings, ILogger logger, ConfigManagerCapabilityScope capabilityScope) + { + _logger = logger; + _capabilityScope = capabilityScope; + BuildMappingTables(bindings); + } + + public bool TryGetConcreteType(Type interfaceType, out Type concreteType) => _interfaceToConcreteMap.TryGetValue(interfaceType, out concreteType!); + + public IReadOnlyDictionary InterfaceToConcreteMap => _interfaceToConcreteMap; + + /// + /// Gets the deserialization mappings for interfaces to concrete types. + /// This is separate from the DI exposure mappings and is specifically for JSON deserialization. + /// + public IReadOnlyDictionary DeserializationMap => _deserializationMap; + + + private void BuildMappingTables(IEnumerable bindings) + { + var interfaceToConcreteMap = new Dictionary(); + var deserializationMap = new Dictionary(); + + foreach (var configureSpec in bindings) + { + + if(!_capabilityScope.Compositions.TryGet(configureSpec, out var bag)){ + continue; + } + + + if (!bag.TryGetPrimaryAs(out var typeCapability)) + { + _logger.MissingPrimaryTypeCapability(); + continue; + } + + var primaryType = typeCapability!.SelectedType; + if (primaryType.IsClass) + { + var concreteType = primaryType; + + bag.GetAll>().ForEach(exposeAs => + { + var interfaceType = exposeAs.ContractType; + + + if (interfaceToConcreteMap.TryGetValue(interfaceType, out var existingConcrete)) + { + _logger.ExposeOverride(interfaceType.Name, existingConcrete.Name, concreteType.Name); + } + + interfaceToConcreteMap[interfaceType] = concreteType; + + _logger.ExposedInterface(interfaceType.Name, concreteType.Name); + }); + } + if (primaryType.IsInterface) + { + var interfaceType = primaryType; + + var deserializeCaps = bag.GetAll>(); + var deserializeToCapability = deserializeCaps.Count != 0 ? deserializeCaps[0] : null; + if (deserializeToCapability != null) + { + var concreteType = deserializeToCapability.ConcreteType; + + if (deserializationMap.TryGetValue(interfaceType, out var existingConcrete)) + { + _logger.DeserializeMappingOverride(interfaceType.Name, existingConcrete.Name, concreteType.Name); + } + + deserializationMap[interfaceType] = concreteType; + + _logger.DeserializeMapping(interfaceType.Name, concreteType.Name); + } + } + } + + _interfaceToConcreteMap = interfaceToConcreteMap.ToFrozenDictionary(); + _deserializationMap = deserializationMap.ToFrozenDictionary(); + + _logger.BuiltExposureRegistry(_interfaceToConcreteMap.Count, _deserializationMap.Count); + } +} diff --git a/src/Cocoar.Configuration/Infrastructure/ProviderRegistry.cs b/src/Cocoar.Configuration/Infrastructure/ProviderRegistry.cs index daf1612..2c60847 100644 --- a/src/Cocoar.Configuration/Infrastructure/ProviderRegistry.cs +++ b/src/Cocoar.Configuration/Infrastructure/ProviderRegistry.cs @@ -1,187 +1,187 @@ -using System.Collections.Concurrent; -using Cocoar.Configuration.Providers.Abstractions; -using Cocoar.Configuration.Utilities; -using Microsoft.Extensions.Logging; - -namespace Cocoar.Configuration.Infrastructure; - -internal static partial class ProviderRegistryLog -{ - [LoggerMessage(EventId = 1000, Level = LogLevel.Debug, Message = "ProviderRegistry: created non-reusable {Provider} (null key)")] - public static partial void ProviderCreatedNonReusable(this ILogger logger, string Provider); - - [LoggerMessage(EventId = 1001, Level = LogLevel.Debug, Message = "ProviderRegistry: created {Provider} with key {Key}")] - public static partial void ProviderCreatedWithKey(this ILogger logger, string Provider, string Key); - - [LoggerMessage(EventId = 1002, Level = LogLevel.Debug, Message = "ProviderRegistry: acquire {Provider} {Key} -> RefCount={RefCount}")] - public static partial void ProviderAcquire(this ILogger logger, string Provider, string Key, int RefCount); - - [LoggerMessage(EventId = 1003, Level = LogLevel.Debug, Message = "ProviderRegistry: release {Provider} {Key} -> RefCount={RefCount}")] - public static partial void ProviderRelease(this ILogger logger, string Provider, string Key, int RefCount); - - [LoggerMessage(EventId = 1004, Level = LogLevel.Debug, Message = "ProviderRegistry: disposing {Provider} {Key}")] - public static partial void ProviderDisposing(this ILogger logger, string Provider, string Key); -} - -internal sealed class ProviderRegistry( - ILogger? logger = null, - bool enableDiagnostics = false, - Func? factory = null) -{ - private readonly ConcurrentDictionary<(Type type, string key), Entry> _entries = new(); - private readonly ILogger _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; - - internal sealed class Entry - { - public required ConfigurationProvider Provider { get; init; } - public int RefCount; - } - - public sealed class ProviderHandle : IDisposable - { - private readonly ProviderRegistry? _owner; - private readonly (Type type, string key)? _id; - private Entry? _entry; - private readonly bool _isReusable; - - private ProviderHandle(ProviderRegistry owner, (Type type, string key) id, Entry entry) - { - _owner = owner; - _id = id; - _entry = entry; - _isReusable = true; - } - - private ProviderHandle(Entry entry) - { - _owner = null; - _id = null; - _entry = entry; - _isReusable = false; - } - - internal static ProviderHandle Create(ProviderRegistry owner, (Type type, string key) id, Entry entry) => - new(owner, id, entry); - - internal static ProviderHandle CreateNonReusable(ProviderRegistry owner, Entry entry) => new(entry); - - public ConfigurationProvider Provider - => _entry?.Provider ?? throw new ObjectDisposedException(nameof(ProviderHandle)); - - public void Dispose() - { - var e = Interlocked.Exchange(ref _entry, null); - if (e is null) - { - return; - } - - if (_isReusable && _owner is not null && _id.HasValue) - { - _owner.Release(_id.Value, e); - } - else - { - if (e.Provider is IDisposable disp) - { - Safety.DisposeQuietly(disp); - } - } - } - } - - public ProviderHandle Acquire(Type providerType, IProviderConfiguration options) - { - var key = options.GenerateProviderKey(); - - if (key is null) - { - var provider = CreateProvider(providerType, options); - if (enableDiagnostics) - { - _logger.ProviderCreatedNonReusable(providerType.Name); - } - - var nonReusableEntry = new Entry - { - Provider = provider, - RefCount = 1 - }; - - return ProviderHandle.CreateNonReusable(this, nonReusableEntry); - } - - var id = (providerType, key); - var isNewEntry = false; - var entry = _entries.GetOrAdd(id, _ => - { - isNewEntry = true; - var created = new Entry - { - Provider = CreateProvider(providerType, options), - RefCount = 1 // Start at 1 to prevent race condition - }; - if (enableDiagnostics) - { - _logger.ProviderCreatedWithKey(providerType.Name, key); - } - - return created; - }); - var newCount = isNewEntry ? 1 : Interlocked.Increment(ref entry.RefCount); - if (enableDiagnostics) - { - _logger.ProviderAcquire(providerType.Name, key, newCount); - } - - return ProviderHandle.Create(this, id, entry); - } - - private void Release((Type type, string key) id, Entry entry) - { - var count = Interlocked.Decrement(ref entry.RefCount); - if (enableDiagnostics) - { - _logger.ProviderRelease(id.type.Name, id.key, count); - } - - if (count != 0) - { - return; - } - - if (!_entries.TryRemove(id, out var removed) || !ReferenceEquals(removed, entry)) - { - return; - } - - if (removed.Provider is not IDisposable disp) - { - return; - } - - if (enableDiagnostics) - { - _logger.ProviderDisposing(id.type.Name, id.key); - } - - Safety.DisposeQuietly(disp); - } - - private ConfigurationProvider CreateProvider(Type providerType, IProviderConfiguration options) - { - if (factory is not null) - { - var inst = factory(providerType, options); - if (inst == null) - { - throw new InvalidOperationException("Factory produced null provider instance."); - } - - return inst; - } - var instance = Activator.CreateInstance(providerType, options) as ConfigurationProvider - ?? throw new InvalidOperationException($"Could not create provider {providerType.Name}."); - return instance; - } -} +using System.Collections.Concurrent; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.Configuration.Utilities; +using Microsoft.Extensions.Logging; + +namespace Cocoar.Configuration.Infrastructure; + +internal static partial class ProviderRegistryLog +{ + [LoggerMessage(EventId = 1000, Level = LogLevel.Debug, Message = "ProviderRegistry: created non-reusable {Provider} (null key)")] + public static partial void ProviderCreatedNonReusable(this ILogger logger, string Provider); + + [LoggerMessage(EventId = 1001, Level = LogLevel.Debug, Message = "ProviderRegistry: created {Provider} with key {Key}")] + public static partial void ProviderCreatedWithKey(this ILogger logger, string Provider, string Key); + + [LoggerMessage(EventId = 1002, Level = LogLevel.Debug, Message = "ProviderRegistry: acquire {Provider} {Key} -> RefCount={RefCount}")] + public static partial void ProviderAcquire(this ILogger logger, string Provider, string Key, int RefCount); + + [LoggerMessage(EventId = 1003, Level = LogLevel.Debug, Message = "ProviderRegistry: release {Provider} {Key} -> RefCount={RefCount}")] + public static partial void ProviderRelease(this ILogger logger, string Provider, string Key, int RefCount); + + [LoggerMessage(EventId = 1004, Level = LogLevel.Debug, Message = "ProviderRegistry: disposing {Provider} {Key}")] + public static partial void ProviderDisposing(this ILogger logger, string Provider, string Key); +} + +internal sealed class ProviderRegistry( + ILogger? logger = null, + bool enableDiagnostics = false, + Func? factory = null) +{ + private readonly ConcurrentDictionary<(Type type, string key), Entry> _entries = new(); + private readonly ILogger _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + + internal sealed class Entry + { + public required ConfigurationProvider Provider { get; init; } + public int RefCount; + } + + public sealed class ProviderHandle : IDisposable + { + private readonly ProviderRegistry? _owner; + private readonly (Type type, string key)? _id; + private Entry? _entry; + private readonly bool _isReusable; + + private ProviderHandle(ProviderRegistry owner, (Type type, string key) id, Entry entry) + { + _owner = owner; + _id = id; + _entry = entry; + _isReusable = true; + } + + private ProviderHandle(Entry entry) + { + _owner = null; + _id = null; + _entry = entry; + _isReusable = false; + } + + internal static ProviderHandle Create(ProviderRegistry owner, (Type type, string key) id, Entry entry) => + new(owner, id, entry); + + internal static ProviderHandle CreateNonReusable(ProviderRegistry owner, Entry entry) => new(entry); + + public ConfigurationProvider Provider + => _entry?.Provider ?? throw new ObjectDisposedException(nameof(ProviderHandle)); + + public void Dispose() + { + var e = Interlocked.Exchange(ref _entry, null); + if (e is null) + { + return; + } + + if (_isReusable && _owner is not null && _id.HasValue) + { + _owner.Release(_id.Value, e); + } + else + { + if (e.Provider is IDisposable disp) + { + Safety.DisposeQuietly(disp); + } + } + } + } + + public ProviderHandle Acquire(Type providerType, IProviderConfiguration options) + { + var key = options.GenerateProviderKey(); + + if (key is null) + { + var provider = CreateProvider(providerType, options); + if (enableDiagnostics) + { + _logger.ProviderCreatedNonReusable(providerType.Name); + } + + var nonReusableEntry = new Entry + { + Provider = provider, + RefCount = 1 + }; + + return ProviderHandle.CreateNonReusable(this, nonReusableEntry); + } + + var id = (providerType, key); + var isNewEntry = false; + var entry = _entries.GetOrAdd(id, _ => + { + isNewEntry = true; + var created = new Entry + { + Provider = CreateProvider(providerType, options), + RefCount = 1 // Start at 1 to prevent race condition + }; + if (enableDiagnostics) + { + _logger.ProviderCreatedWithKey(providerType.Name, key); + } + + return created; + }); + var newCount = isNewEntry ? 1 : Interlocked.Increment(ref entry.RefCount); + if (enableDiagnostics) + { + _logger.ProviderAcquire(providerType.Name, key, newCount); + } + + return ProviderHandle.Create(this, id, entry); + } + + private void Release((Type type, string key) id, Entry entry) + { + var count = Interlocked.Decrement(ref entry.RefCount); + if (enableDiagnostics) + { + _logger.ProviderRelease(id.type.Name, id.key, count); + } + + if (count != 0) + { + return; + } + + if (!_entries.TryRemove(id, out var removed) || !ReferenceEquals(removed, entry)) + { + return; + } + + if (removed.Provider is not IDisposable disp) + { + return; + } + + if (enableDiagnostics) + { + _logger.ProviderDisposing(id.type.Name, id.key); + } + + Safety.DisposeQuietly(disp); + } + + private ConfigurationProvider CreateProvider(Type providerType, IProviderConfiguration options) + { + if (factory is not null) + { + var inst = factory(providerType, options); + if (inst == null) + { + throw new InvalidOperationException("Factory produced null provider instance."); + } + + return inst; + } + var instance = Activator.CreateInstance(providerType, options) as ConfigurationProvider + ?? throw new InvalidOperationException($"Could not create provider {providerType.Name}."); + return instance; + } +} diff --git a/src/Cocoar.Configuration/Infrastructure/RecomputeCoalescer.cs b/src/Cocoar.Configuration/Infrastructure/RecomputeCoalescer.cs index 5c126a4..d97fb10 100644 --- a/src/Cocoar.Configuration/Infrastructure/RecomputeCoalescer.cs +++ b/src/Cocoar.Configuration/Infrastructure/RecomputeCoalescer.cs @@ -1,215 +1,215 @@ -using Microsoft.Extensions.Logging; - -namespace Cocoar.Configuration.Infrastructure; - -internal static partial class RecomputeCoalescerLog -{ - [LoggerMessage(EventId = 4000, Level = LogLevel.Error, Message = "Recompute failed from initial debounce trigger")] - public static partial void InitialDebounceFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = 4001, Level = LogLevel.Error, Message = "Recompute failed from trailing trigger")] - public static partial void TrailingTriggerFailed(this ILogger logger, Exception exception); -} - -/// -/// Coalesces many incoming change signals into minimal recompute invocations while -/// preserving earliest-index semantics and providing an initial debounce plus trailing pass. -/// -internal sealed class RecomputeCoalescer : IDisposable -{ - private readonly ILogger _logger; - private readonly Action _invoke; - private readonly int _initialDebounceMs; - private readonly int _trailingMs; - - private int _earliestPending = int.MaxValue; - private int _earliestDuringRun = int.MaxValue; - // 0 = false, 1 = true. Int is used (not bool) to support Interlocked.Exchange/CompareExchange atomics. - private int _running; - - // Timers are created once and reused (stopped/started) to avoid GC pressure - // under high-frequency file changes (P-03). - private readonly System.Timers.Timer? _initialTimer; - private readonly System.Timers.Timer? _trailingTimer; -#if NET9_0_OR_GREATER - private readonly Lock _lock = new(); -#else - private readonly object _lock = new(); -#endif - - public RecomputeCoalescer(ILogger logger, Action invoke, int initialDebounceMs, int trailingMs) - { - _logger = logger; - _invoke = invoke; - _initialDebounceMs = Math.Max(0, initialDebounceMs); - _trailingMs = Math.Max(0, trailingMs); - - if (_initialDebounceMs > 0) - { - _initialTimer = new(_initialDebounceMs) { AutoReset = false }; - _initialTimer.Elapsed += (_, _) => - { - try - { - if (Volatile.Read(ref _running) == 0) - { - var startIdx = Interlocked.Exchange(ref _earliestPending, int.MaxValue); - if (startIdx != int.MaxValue) - { - StartPass(startIdx); - } - } - } - catch (Exception ex) - { - _logger.InitialDebounceFailed(ex); - } - }; - } - - if (_trailingMs > 0) - { - _trailingTimer = new(_trailingMs) { AutoReset = false }; - _trailingTimer.Elapsed += (_, _) => - { - try - { - if (Volatile.Read(ref _running) == 0) - { - var idx = Interlocked.Exchange(ref _earliestPending, int.MaxValue); - if (idx != int.MaxValue) - { - StartPass(idx); - } - } - } - catch (Exception ex) - { - _logger.TrailingTriggerFailed(ex); - } - }; - } - } - - public void Signal(int index) - { - if (Volatile.Read(ref _running) == 1) - { - int current; - do - { - current = Volatile.Read(ref _earliestDuringRun); - if (index >= current) - { - return; - } - } while (Interlocked.CompareExchange(ref _earliestDuringRun, index, current) != current); - - return; - } - - int pending; - do - { - pending = Volatile.Read(ref _earliestPending); - if (pending == int.MaxValue) - { - if (Interlocked.CompareExchange(ref _earliestPending, index, int.MaxValue) == int.MaxValue) - { - ScheduleInitialOrImmediate(); - return; - } - } - else - { - if (index >= pending) - { - break; - } - } - } while (Interlocked.CompareExchange(ref _earliestPending, index, pending) != pending); - - ScheduleTrailing(); - } - - private void ScheduleInitialOrImmediate() - { - if (_initialTimer == null) - { - var idx = Interlocked.Exchange(ref _earliestPending, int.MaxValue); - if (idx != int.MaxValue) - { - StartPass(idx); - } - - return; - } - - lock (_lock) - { - _initialTimer.Stop(); - _initialTimer.Start(); - } - } - - private void ScheduleTrailing() - { - if (_trailingTimer == null) - { - var idx = Interlocked.Exchange(ref _earliestPending, int.MaxValue); - if (idx != int.MaxValue) - { - StartPass(idx); - } - - return; - } - - lock (_lock) - { - _trailingTimer.Stop(); - _trailingTimer.Start(); - } - } - - private void StartPass(int idx) - { - // Critical: Set running state before clearing earliestDuringRun to prevent race condition - // where Signal() might miss events during the state transition - Interlocked.Exchange(ref _running, 1); - Interlocked.Exchange(ref _earliestDuringRun, int.MaxValue); - - try - { - _invoke(idx); - } - finally - { - Interlocked.Exchange(ref _running, 0); - var during = Interlocked.Exchange(ref _earliestDuringRun, int.MaxValue); - if (during != int.MaxValue) - { - int current; - do - { - current = Volatile.Read(ref _earliestPending); - if (during >= current) - { - break; - } - } while (Interlocked.CompareExchange(ref _earliestPending, during, current) != current); - - ScheduleTrailing(); - } - } - } - - public void Dispose() - { - lock (_lock) - { - _initialTimer?.Dispose(); - _trailingTimer?.Dispose(); - } - } -} +using Microsoft.Extensions.Logging; + +namespace Cocoar.Configuration.Infrastructure; + +internal static partial class RecomputeCoalescerLog +{ + [LoggerMessage(EventId = 4000, Level = LogLevel.Error, Message = "Recompute failed from initial debounce trigger")] + public static partial void InitialDebounceFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = 4001, Level = LogLevel.Error, Message = "Recompute failed from trailing trigger")] + public static partial void TrailingTriggerFailed(this ILogger logger, Exception exception); +} + +/// +/// Coalesces many incoming change signals into minimal recompute invocations while +/// preserving earliest-index semantics and providing an initial debounce plus trailing pass. +/// +internal sealed class RecomputeCoalescer : IDisposable +{ + private readonly ILogger _logger; + private readonly Action _invoke; + private readonly int _initialDebounceMs; + private readonly int _trailingMs; + + private int _earliestPending = int.MaxValue; + private int _earliestDuringRun = int.MaxValue; + // 0 = false, 1 = true. Int is used (not bool) to support Interlocked.Exchange/CompareExchange atomics. + private int _running; + + // Timers are created once and reused (stopped/started) to avoid GC pressure + // under high-frequency file changes (P-03). + private readonly System.Timers.Timer? _initialTimer; + private readonly System.Timers.Timer? _trailingTimer; +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + + public RecomputeCoalescer(ILogger logger, Action invoke, int initialDebounceMs, int trailingMs) + { + _logger = logger; + _invoke = invoke; + _initialDebounceMs = Math.Max(0, initialDebounceMs); + _trailingMs = Math.Max(0, trailingMs); + + if (_initialDebounceMs > 0) + { + _initialTimer = new(_initialDebounceMs) { AutoReset = false }; + _initialTimer.Elapsed += (_, _) => + { + try + { + if (Volatile.Read(ref _running) == 0) + { + var startIdx = Interlocked.Exchange(ref _earliestPending, int.MaxValue); + if (startIdx != int.MaxValue) + { + StartPass(startIdx); + } + } + } + catch (Exception ex) + { + _logger.InitialDebounceFailed(ex); + } + }; + } + + if (_trailingMs > 0) + { + _trailingTimer = new(_trailingMs) { AutoReset = false }; + _trailingTimer.Elapsed += (_, _) => + { + try + { + if (Volatile.Read(ref _running) == 0) + { + var idx = Interlocked.Exchange(ref _earliestPending, int.MaxValue); + if (idx != int.MaxValue) + { + StartPass(idx); + } + } + } + catch (Exception ex) + { + _logger.TrailingTriggerFailed(ex); + } + }; + } + } + + public void Signal(int index) + { + if (Volatile.Read(ref _running) == 1) + { + int current; + do + { + current = Volatile.Read(ref _earliestDuringRun); + if (index >= current) + { + return; + } + } while (Interlocked.CompareExchange(ref _earliestDuringRun, index, current) != current); + + return; + } + + int pending; + do + { + pending = Volatile.Read(ref _earliestPending); + if (pending == int.MaxValue) + { + if (Interlocked.CompareExchange(ref _earliestPending, index, int.MaxValue) == int.MaxValue) + { + ScheduleInitialOrImmediate(); + return; + } + } + else + { + if (index >= pending) + { + break; + } + } + } while (Interlocked.CompareExchange(ref _earliestPending, index, pending) != pending); + + ScheduleTrailing(); + } + + private void ScheduleInitialOrImmediate() + { + if (_initialTimer == null) + { + var idx = Interlocked.Exchange(ref _earliestPending, int.MaxValue); + if (idx != int.MaxValue) + { + StartPass(idx); + } + + return; + } + + lock (_lock) + { + _initialTimer.Stop(); + _initialTimer.Start(); + } + } + + private void ScheduleTrailing() + { + if (_trailingTimer == null) + { + var idx = Interlocked.Exchange(ref _earliestPending, int.MaxValue); + if (idx != int.MaxValue) + { + StartPass(idx); + } + + return; + } + + lock (_lock) + { + _trailingTimer.Stop(); + _trailingTimer.Start(); + } + } + + private void StartPass(int idx) + { + // Critical: Set running state before clearing earliestDuringRun to prevent race condition + // where Signal() might miss events during the state transition + Interlocked.Exchange(ref _running, 1); + Interlocked.Exchange(ref _earliestDuringRun, int.MaxValue); + + try + { + _invoke(idx); + } + finally + { + Interlocked.Exchange(ref _running, 0); + var during = Interlocked.Exchange(ref _earliestDuringRun, int.MaxValue); + if (during != int.MaxValue) + { + int current; + do + { + current = Volatile.Read(ref _earliestPending); + if (during >= current) + { + break; + } + } while (Interlocked.CompareExchange(ref _earliestPending, during, current) != current); + + ScheduleTrailing(); + } + } + } + + public void Dispose() + { + lock (_lock) + { + _initialTimer?.Dispose(); + _trailingTimer?.Dispose(); + } + } +} diff --git a/src/Cocoar.Configuration/Properties/AssemblyInfo.cs b/src/Cocoar.Configuration/Properties/AssemblyInfo.cs index 0de3212..b9835a0 100644 --- a/src/Cocoar.Configuration/Properties/AssemblyInfo.cs +++ b/src/Cocoar.Configuration/Properties/AssemblyInfo.cs @@ -1,16 +1,16 @@ -using System.Runtime.CompilerServices; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Reactive; - -[assembly: InternalsVisibleTo("Cocoar.Configuration.Tests")] -[assembly: InternalsVisibleTo("Cocoar.Configuration.Core.Tests")] -[assembly: InternalsVisibleTo("Cocoar.Configuration.DI")] -[assembly: InternalsVisibleTo("Cocoar.Configuration.Flags.Tests")] -[assembly: InternalsVisibleTo("Cocoar.Configuration.Secrets.Tests")] -[assembly: InternalsVisibleTo("Cocoar.Configuration.AspNetCore")] -[assembly: InternalsVisibleTo("Cocoar.Configuration.Http")] -[assembly: InternalsVisibleTo("Cocoar.Configuration.Providers.Tests")] - -// Type forwarding for types moved to Cocoar.Configuration.Abstractions -[assembly: TypeForwardedTo(typeof(IConfigurationAccessor))] -[assembly: TypeForwardedTo(typeof(IReactiveConfig<>))] +using System.Runtime.CompilerServices; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Reactive; + +[assembly: InternalsVisibleTo("Cocoar.Configuration.Tests")] +[assembly: InternalsVisibleTo("Cocoar.Configuration.Core.Tests")] +[assembly: InternalsVisibleTo("Cocoar.Configuration.DI")] +[assembly: InternalsVisibleTo("Cocoar.Configuration.Flags.Tests")] +[assembly: InternalsVisibleTo("Cocoar.Configuration.Secrets.Tests")] +[assembly: InternalsVisibleTo("Cocoar.Configuration.AspNetCore")] +[assembly: InternalsVisibleTo("Cocoar.Configuration.Http")] +[assembly: InternalsVisibleTo("Cocoar.Configuration.Providers.Tests")] + +// Type forwarding for types moved to Cocoar.Configuration.Abstractions +[assembly: TypeForwardedTo(typeof(IConfigurationAccessor))] +[assembly: TypeForwardedTo(typeof(IReactiveConfig<>))] diff --git a/src/Cocoar.Configuration/Providers/Abstractions/ConfigurationProvider.Generic.cs b/src/Cocoar.Configuration/Providers/Abstractions/ConfigurationProvider.Generic.cs index b156c6c..d13ddd2 100644 --- a/src/Cocoar.Configuration/Providers/Abstractions/ConfigurationProvider.Generic.cs +++ b/src/Cocoar.Configuration/Providers/Abstractions/ConfigurationProvider.Generic.cs @@ -1,42 +1,42 @@ -using System.Text.Json; - -namespace Cocoar.Configuration.Providers.Abstractions; - -public abstract class ConfigurationProvider(TProviderConfiguration options) - : ConfigurationProvider - where TProviderConfiguration : IProviderConfiguration -{ - protected TProviderConfiguration ProviderOptions { get; } = options; - - /// - /// Fetches configuration as raw UTF-8 JSON bytes for the typed query. - /// Override this to provide your provider's implementation. - /// - public abstract Task FetchConfigurationBytesAsync(TProviderQuery query, CancellationToken ct = default); - - /// - /// Observes configuration changes as raw UTF-8 JSON bytes for the typed query. - /// Override this to provide your provider's implementation. - /// - public abstract IObservable ChangesAsBytes(TProviderQuery query); - - public override Task FetchConfigurationBytesAsync(IProviderQuery query, CancellationToken ct = default) - { - if (query is not TProviderQuery typedQuery) - { - throw new ArgumentException($"Expected query of type {typeof(TProviderQuery).FullName}, but received {query.GetType().FullName}", nameof(query)); - } - - return FetchConfigurationBytesAsync(typedQuery, ct); - } - - public override IObservable ChangesAsBytes(IProviderQuery query) - { - if (query is not TProviderQuery typedQuery) - { - throw new ArgumentException($"Expected query of type {typeof(TProviderQuery).FullName}, but received {query.GetType().FullName}", nameof(query)); - } - - return ChangesAsBytes(typedQuery); - } -} +using System.Text.Json; + +namespace Cocoar.Configuration.Providers.Abstractions; + +public abstract class ConfigurationProvider(TProviderConfiguration options) + : ConfigurationProvider + where TProviderConfiguration : IProviderConfiguration +{ + protected TProviderConfiguration ProviderOptions { get; } = options; + + /// + /// Fetches configuration as raw UTF-8 JSON bytes for the typed query. + /// Override this to provide your provider's implementation. + /// + public abstract Task FetchConfigurationBytesAsync(TProviderQuery query, CancellationToken ct = default); + + /// + /// Observes configuration changes as raw UTF-8 JSON bytes for the typed query. + /// Override this to provide your provider's implementation. + /// + public abstract IObservable ChangesAsBytes(TProviderQuery query); + + public override Task FetchConfigurationBytesAsync(IProviderQuery query, CancellationToken ct = default) + { + if (query is not TProviderQuery typedQuery) + { + throw new ArgumentException($"Expected query of type {typeof(TProviderQuery).FullName}, but received {query.GetType().FullName}", nameof(query)); + } + + return FetchConfigurationBytesAsync(typedQuery, ct); + } + + public override IObservable ChangesAsBytes(IProviderQuery query) + { + if (query is not TProviderQuery typedQuery) + { + throw new ArgumentException($"Expected query of type {typeof(TProviderQuery).FullName}, but received {query.GetType().FullName}", nameof(query)); + } + + return ChangesAsBytes(typedQuery); + } +} diff --git a/src/Cocoar.Configuration/Providers/Abstractions/ConfigurationProvider.cs b/src/Cocoar.Configuration/Providers/Abstractions/ConfigurationProvider.cs index c8717fb..015aebb 100644 --- a/src/Cocoar.Configuration/Providers/Abstractions/ConfigurationProvider.cs +++ b/src/Cocoar.Configuration/Providers/Abstractions/ConfigurationProvider.cs @@ -1,19 +1,19 @@ -using System.Text.Json; -using Cocoar.Configuration.Helper; - -namespace Cocoar.Configuration.Providers.Abstractions; - -public abstract class ConfigurationProvider -{ - /// - /// Fetches configuration as raw UTF-8 JSON bytes, avoiding string allocations. - /// This is more secure for sensitive data as bytes can be zeroed after use. - /// - public abstract Task FetchConfigurationBytesAsync(IProviderQuery query, CancellationToken ct = default); - - /// - /// Observes configuration changes as raw UTF-8 JSON bytes, avoiding string allocations. - /// This is more secure for sensitive data as bytes can be zeroed after use. - /// - public abstract IObservable ChangesAsBytes(IProviderQuery query); -} +using System.Text.Json; +using Cocoar.Configuration.Helper; + +namespace Cocoar.Configuration.Providers.Abstractions; + +public abstract class ConfigurationProvider +{ + /// + /// Fetches configuration as raw UTF-8 JSON bytes, avoiding string allocations. + /// This is more secure for sensitive data as bytes can be zeroed after use. + /// + public abstract Task FetchConfigurationBytesAsync(IProviderQuery query, CancellationToken ct = default); + + /// + /// Observes configuration changes as raw UTF-8 JSON bytes, avoiding string allocations. + /// This is more secure for sensitive data as bytes can be zeroed after use. + /// + public abstract IObservable ChangesAsBytes(IProviderQuery query); +} diff --git a/src/Cocoar.Configuration/Providers/Abstractions/IProviderConfiguration.cs b/src/Cocoar.Configuration/Providers/Abstractions/IProviderConfiguration.cs index 0e781b3..434c123 100644 --- a/src/Cocoar.Configuration/Providers/Abstractions/IProviderConfiguration.cs +++ b/src/Cocoar.Configuration/Providers/Abstractions/IProviderConfiguration.cs @@ -1,21 +1,21 @@ -using System.Text.Json; - -namespace Cocoar.Configuration.Providers.Abstractions; - -public interface IProviderConfiguration -{ - private static readonly JsonSerializerOptions ProviderKeyOptions = new() - { - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never, - PropertyNamingPolicy = null, - WriteIndented = false - }; - - /// Generates a provider key for instance sharing. - /// Return null to indicate this provider should never be reused/shared. - /// Return the same key to share provider instances with the same key. - string? GenerateProviderKey() - { - return JsonSerializer.Serialize(this, GetType(), ProviderKeyOptions); - } -} +using System.Text.Json; + +namespace Cocoar.Configuration.Providers.Abstractions; + +public interface IProviderConfiguration +{ + private static readonly JsonSerializerOptions ProviderKeyOptions = new() + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never, + PropertyNamingPolicy = null, + WriteIndented = false + }; + + /// Generates a provider key for instance sharing. + /// Return null to indicate this provider should never be reused/shared. + /// Return the same key to share provider instances with the same key. + string? GenerateProviderKey() + { + return JsonSerializer.Serialize(this, GetType(), ProviderKeyOptions); + } +} diff --git a/src/Cocoar.Configuration/Providers/Abstractions/IProviderQuery.cs b/src/Cocoar.Configuration/Providers/Abstractions/IProviderQuery.cs index d210b5f..47ee689 100644 --- a/src/Cocoar.Configuration/Providers/Abstractions/IProviderQuery.cs +++ b/src/Cocoar.Configuration/Providers/Abstractions/IProviderQuery.cs @@ -1,5 +1,5 @@ -namespace Cocoar.Configuration.Providers.Abstractions; - -public interface IProviderQuery -{ -} +namespace Cocoar.Configuration.Providers.Abstractions; + +public interface IProviderQuery +{ +} diff --git a/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineArgumentProvider.cs b/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineArgumentProvider.cs index 6b05b0c..fa48da7 100644 --- a/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineArgumentProvider.cs +++ b/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineArgumentProvider.cs @@ -1,138 +1,138 @@ -using System.Text.Json; -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.Providers; - -public sealed class CommandLineArgumentProvider(CommandLineProviderOptions options) - : ConfigurationProvider(options) -{ - public override Task FetchConfigurationBytesAsync(CommandLineProviderQueryOptions queryOptions, CancellationToken ct = default) - { - var args = queryOptions.Args ?? Environment.GetCommandLineArgs().Skip(1).ToArray(); - var switchPrefixes = queryOptions.SwitchPrefixes ?? ["--"]; - // Sort by length descending to match longest prefixes first (e.g., "--" before "-") - var sortedPrefixes = switchPrefixes.OrderByDescending(p => p.Length).ToArray(); - var prefix = queryOptions.Prefix; - var parsedArgs = ParseBasicPosixArguments(args, sortedPrefixes); - - var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var kvp in parsedArgs) - { - var key = kvp.Key; - if (!string.IsNullOrEmpty(prefix)) - { - if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - key = key[prefix.Length..]; - } - - AddToNestedDict(dict, key, kvp.Value); - } - - var bytes = JsonSerializer.SerializeToUtf8Bytes(dict); - return Task.FromResult(bytes); - } - - private static Dictionary ParseBasicPosixArguments(string[] args, string[] switchPrefixes) - { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - - string? matchedPrefix = null; - foreach (var prefix in switchPrefixes) - { - if (arg.StartsWith(prefix, StringComparison.Ordinal)) - { - matchedPrefix = prefix; - break; - } - } - - if (matchedPrefix == null) - { - continue; - } - - var key = arg[matchedPrefix.Length..]; - - var equalsIndex = key.IndexOf('='); - if (equalsIndex > 0) - { - var keyPart = key[..equalsIndex]; - var valuePart = key[(equalsIndex + 1)..]; - result[keyPart] = valuePart; - } - else if (i + 1 < args.Length && !StartsWithAnyPrefix(args[i + 1], switchPrefixes)) - { - result[key] = args[i + 1]; - i++; - } - else - { - result[key] = "true"; - } - } - - return result; - } - - private static bool StartsWithAnyPrefix(string arg, string[] prefixes) - { - foreach (var prefix in prefixes) - { - if (arg.StartsWith(prefix, StringComparison.Ordinal)) - { - return true; - } - } - return false; - } - - private static void AddToNestedDict(IDictionary dict, string key, object? value) - { - if (string.IsNullOrWhiteSpace(key)) - { - return; - } - - var parts = key.Split([":", "__"], StringSplitOptions.RemoveEmptyEntries); - if (parts.Length == 0) - { - return; - } - - var current = dict; - for (var i = 0; i < parts.Length - 1; i++) - { - var seg = parts[i]; - if (!current.TryGetValue(seg, out var next) || next is not IDictionary nextDict) - { - nextDict = new Dictionary(StringComparer.OrdinalIgnoreCase); - current[seg] = nextDict; - } - - current = nextDict; - } - - current[parts[^1]] = value; - } - - public static Rules.ConfigRule CreateRule(string[]? args = null, string[]? switchPrefixes = null, string? prefix = null, bool required = false) - { - return Rules.ConfigRule.Create( - _ => new(), - _ => new(args, switchPrefixes, prefix), - typeof(T), - new(Required: required, UseWhen: null) - ); - } - - public override IObservable ChangesAsBytes(CommandLineProviderQueryOptions queryOptions) - => ObservableHelpers.Never(); -} +using System.Text.Json; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public sealed class CommandLineArgumentProvider(CommandLineProviderOptions options) + : ConfigurationProvider(options) +{ + public override Task FetchConfigurationBytesAsync(CommandLineProviderQueryOptions queryOptions, CancellationToken ct = default) + { + var args = queryOptions.Args ?? Environment.GetCommandLineArgs().Skip(1).ToArray(); + var switchPrefixes = queryOptions.SwitchPrefixes ?? ["--"]; + // Sort by length descending to match longest prefixes first (e.g., "--" before "-") + var sortedPrefixes = switchPrefixes.OrderByDescending(p => p.Length).ToArray(); + var prefix = queryOptions.Prefix; + var parsedArgs = ParseBasicPosixArguments(args, sortedPrefixes); + + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in parsedArgs) + { + var key = kvp.Key; + if (!string.IsNullOrEmpty(prefix)) + { + if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + key = key[prefix.Length..]; + } + + AddToNestedDict(dict, key, kvp.Value); + } + + var bytes = JsonSerializer.SerializeToUtf8Bytes(dict); + return Task.FromResult(bytes); + } + + private static Dictionary ParseBasicPosixArguments(string[] args, string[] switchPrefixes) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + + string? matchedPrefix = null; + foreach (var prefix in switchPrefixes) + { + if (arg.StartsWith(prefix, StringComparison.Ordinal)) + { + matchedPrefix = prefix; + break; + } + } + + if (matchedPrefix == null) + { + continue; + } + + var key = arg[matchedPrefix.Length..]; + + var equalsIndex = key.IndexOf('='); + if (equalsIndex > 0) + { + var keyPart = key[..equalsIndex]; + var valuePart = key[(equalsIndex + 1)..]; + result[keyPart] = valuePart; + } + else if (i + 1 < args.Length && !StartsWithAnyPrefix(args[i + 1], switchPrefixes)) + { + result[key] = args[i + 1]; + i++; + } + else + { + result[key] = "true"; + } + } + + return result; + } + + private static bool StartsWithAnyPrefix(string arg, string[] prefixes) + { + foreach (var prefix in prefixes) + { + if (arg.StartsWith(prefix, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + + private static void AddToNestedDict(IDictionary dict, string key, object? value) + { + if (string.IsNullOrWhiteSpace(key)) + { + return; + } + + var parts = key.Split([":", "__"], StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + { + return; + } + + var current = dict; + for (var i = 0; i < parts.Length - 1; i++) + { + var seg = parts[i]; + if (!current.TryGetValue(seg, out var next) || next is not IDictionary nextDict) + { + nextDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + current[seg] = nextDict; + } + + current = nextDict; + } + + current[parts[^1]] = value; + } + + public static Rules.ConfigRule CreateRule(string[]? args = null, string[]? switchPrefixes = null, string? prefix = null, bool required = false) + { + return Rules.ConfigRule.Create( + _ => new(), + _ => new(args, switchPrefixes, prefix), + typeof(T), + new(Required: required, UseWhen: null) + ); + } + + public override IObservable ChangesAsBytes(CommandLineProviderQueryOptions queryOptions) + => ObservableHelpers.Never(); +} diff --git a/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineArgumentRulesExtensions.cs b/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineArgumentRulesExtensions.cs index c1b85d4..ac2b3f9 100644 --- a/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineArgumentRulesExtensions.cs +++ b/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineArgumentRulesExtensions.cs @@ -1,38 +1,38 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; - -namespace Cocoar.Configuration.Providers; - -public static class CommandLineArgumentRulesExtensions -{ - public static - ProviderRuleBuilder FromCommandLine(this TypedProviderBuilder builder, string prefix, string[]? switchPrefixes = null) - where T : class - => new( - cm => new(), - cm => new(null, switchPrefixes, prefix), - typeof(T) - ); - - public static - ProviderRuleBuilder FromCommandLine(this TypedProviderBuilder builder, string[] switchPrefixes) - where T : class - => new( - cm => new(), - cm => new(null, switchPrefixes, null), - typeof(T) - ); - - public static - ProviderRuleBuilder FromCommandLine(this TypedProviderBuilder builder, - Func optionsFactory) - where T : class - => new( - cm => new(), - cm => { var opts = optionsFactory(cm); return new CommandLineProviderQueryOptions(opts.Args, opts.SwitchPrefixes, opts.Prefix); }, - typeof(T) - ); -} +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; + +namespace Cocoar.Configuration.Providers; + +public static class CommandLineArgumentRulesExtensions +{ + public static + ProviderRuleBuilder FromCommandLine(this TypedProviderBuilder builder, string prefix, string[]? switchPrefixes = null) + where T : class + => new( + cm => new(), + cm => new(null, switchPrefixes, prefix), + typeof(T) + ); + + public static + ProviderRuleBuilder FromCommandLine(this TypedProviderBuilder builder, string[] switchPrefixes) + where T : class + => new( + cm => new(), + cm => new(null, switchPrefixes, null), + typeof(T) + ); + + public static + ProviderRuleBuilder FromCommandLine(this TypedProviderBuilder builder, + Func optionsFactory) + where T : class + => new( + cm => new(), + cm => { var opts = optionsFactory(cm); return new CommandLineProviderQueryOptions(opts.Args, opts.SwitchPrefixes, opts.Prefix); }, + typeof(T) + ); +} diff --git a/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineOptions.cs b/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineOptions.cs index ec79fa0..c0349ca 100644 --- a/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineOptions.cs +++ b/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineOptions.cs @@ -1,18 +1,18 @@ -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.Providers; - -public record CommandLineRuleOptions( - string[]? Args = null, - string[]? SwitchPrefixes = null, - string? Prefix = null); - -public record CommandLineProviderQueryOptions( - string[]? Args, - string[]? SwitchPrefixes = null, - string? Prefix = null) : IProviderQuery; - -public record CommandLineProviderOptions() : IProviderConfiguration -{ - public string? GenerateProviderKey() => "CommandLine:Global"; -} +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public record CommandLineRuleOptions( + string[]? Args = null, + string[]? SwitchPrefixes = null, + string? Prefix = null); + +public record CommandLineProviderQueryOptions( + string[]? Args, + string[]? SwitchPrefixes = null, + string? Prefix = null) : IProviderQuery; + +public record CommandLineProviderOptions() : IProviderConfiguration +{ + public string? GenerateProviderKey() => "CommandLine:Global"; +} diff --git a/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md b/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md index 3cc23cc..7bebefa 100644 --- a/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md +++ b/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md @@ -1,272 +1,272 @@ -# CommandLine Provider - -The CommandLine provider allows you to load configuration from command-line arguments with flexible, configurable argument parsing. - -## Features - -- **Flexible switch prefixes**: Support any prefix style (`--`, `-`, `/`, `@`, `#`, `%`, etc.) - even multiple at once! -- **Multiple formats**: Supports `--key=value`, `--key value`, and boolean flags -- **Nested configuration**: Use `:` or `__` for hierarchical keys (e.g., `--database:host=localhost`) -- **Prefix filtering**: Filter arguments by prefix to map to specific configuration types -- **Automatic fallback**: Uses `Environment.GetCommandLineArgs()` when args not explicitly provided - -## Basic Usage - -### Simple usage (default `--` prefix) - -```csharp -builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromCommandLine() -])); -``` - -Command line: -```bash -dotnet run --host=localhost --port=8080 --verbose -``` - -Maps to: -```csharp -public class AppConfig -{ - public string Host { get; set; } // "localhost" - public int Port { get; set; } // 8080 - public bool Verbose { get; set; } // true -} -``` - -### Custom switch prefixes - -Use any prefix style you prefer: - -```csharp -// Single dash (Unix-style) -rule.For().FromCommandLine(["-"]) - -// Forward slash (Windows-style) -rule.For().FromCommandLine(["/"]) - -// Custom prefixes for semantic clarity -rule.For().FromCommandLine(["@", "#"]) -``` - -**...or literally any string you want** 😏 - -### Multiple switch prefixes - -Accept multiple prefix styles simultaneously: - -```csharp -rule.For().FromCommandLine(["--", "-", "/"]) -``` - -Command line can now mix styles: -```bash -dotnet run --host=localhost -port=8080 /verbose -``` - -**Note:** Prefixes are matched longest-first, so `--` is checked before `-` to avoid ambiguity. - -## Advanced Usage - -### Nested Configuration - -Use `:` or `__` to create hierarchical configuration: - -```bash -dotnet run --database:host=localhost --database:port=5432 -``` - -Maps to: -```csharp -public class AppConfig -{ - public DatabaseConfig Database { get; set; } -} - -public class DatabaseConfig -{ - public string Host { get; set; } // "localhost" - public int Port { get; set; } // 5432 -} -``` - -### Using Prefix to Map Multiple Types - -You can use prefixes to map different command-line arguments to different configuration types: - -```csharp -builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromCommandLine("app_"), - rule.For().FromCommandLine("db_") -])); -``` - -Command line: -```bash -dotnet run --app_host=localhost --db_connectionstring="Server=localhost" -``` - -This maps: -- `--app_host=localhost` → `AppConfig.Host` (prefix stripped, becomes `host`) -- `--db_connectionstring=...` → `DatabaseConfig.ConnectionString` (prefix stripped, becomes `connectionstring`) - -### Combining Prefix Filtering and Custom Switch Prefixes - -Mix semantic prefixes with custom switch styles: - -```csharp -builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromCommandLine("target_", ["@"]), - rule.For().FromCommandLine("issue_", ["#"]) -])); -``` - -Command line: -```bash -invoke.exe @target_host=10.10.10.10 #issue_id=123 -``` - -### Dynamic Configuration with Config-Aware Rules - -```csharp -builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromFile("tenant.json"), - - rule.For().FromCommandLine(accessor => - { - var tenant = accessor.GetRequiredConfig(); - return new CommandLineRuleOptions - { - Prefix = $"{tenant.Name}_", - SwitchPrefixes = ["--", "-"] - }; - }) -])); -``` - -## Argument Format Support - -The provider supports multiple argument formats: - -| Format | Example | Result | -|--------|---------|--------| -| `--key=value` | `--host=localhost` | `{ "host": "localhost" }` | -| `--key value` | `--host localhost` | `{ "host": "localhost" }` | -| `--flag` | `--verbose` | `{ "verbose": "true" }` | -| `--nested:key` | `--db:host=localhost` | `{ "db": { "host": "localhost" } }` | -| `--nested__key` | `--db__host=localhost` | `{ "db": { "host": "localhost" } }` | - -**Custom prefixes:** All formats work with any configured switch prefix (`-`, `/`, `@`, etc.) - -## Configuration Options - -### Simple API - -```csharp -// Default (-- prefix, no filtering) -.FromCommandLine() - -// With prefix filtering only -.FromCommandLine("app_") - -// With custom switch prefix -.FromCommandLine(["-"]) - -// With multiple switch prefixes -.FromCommandLine(["--", "-", "/"]) - -// Prefix filtering + custom switches -.FromCommandLine("app_", ["@", "#"]) -``` - -### Factory API (for testing/advanced scenarios) - -```csharp -.FromCommandLine(cm => new CommandLineRuleOptions -{ - Args = testArgs, // For testing; defaults to Environment.GetCommandLineArgs() - SwitchPrefixes = ["@", "#"], // Custom switch prefixes; defaults to ["--"] - Prefix = "app_" // Prefix filter; defaults to null (no filtering) -}) -``` - -## Type Conversion - -The ConfigurationManager automatically converts string values to the target property types: - -```bash -dotnet run --port=8080 --timeout=30.5 --enabled=true -``` - -```csharp -public class AppConfig -{ - public int Port { get; set; } // Converted to int: 8080 - public double Timeout { get; set; } // Converted to double: 30.5 - public bool Enabled { get; set; } // Converted to bool: true -} -``` - -## Layering with Other Providers - -Command-line arguments are typically used as the highest-priority layer to override file and environment-based configuration: - -```csharp -builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromFile("appsettings.json"), // Base - rule.For().FromEnvironment("APP_"), // Override - rule.For().FromCommandLine() // Final override -])); -``` - -Command line: -```bash -dotnet run --port=9000 -``` - -This overrides the port from both the file and environment variables. - -## Creative Use Cases - -### Semantic Prefixes for Self-Documenting CLIs - -```csharp -rule.For().FromCommandLine(["@"]), -rule.For().FromCommandLine(["#"]), -rule.For().FromCommandLine(["%"]) -``` - -```bash -invoke.exe @host=10.10.10.10 #ticket=456 %env=prod -``` - -### Mixed Unix/Windows Style - -Accept both Unix and Windows conventions: - -```csharp -rule.For().FromCommandLine(["--", "/"]) -``` - -```bash -dotnet run --host=localhost /port=8080 -``` - -## Limitations - -- **No reactive updates**: Command-line arguments are static; they don't change during application lifetime -- **Basic parsing only**: No support for subcommands, argument validation, or complex parsing rules -- **String-based**: All values are initially strings and must be convertible to target property types - -## Use Cases - -- **Development overrides**: Quickly override configuration during development -- **Container/deployment**: Pass environment-specific values at runtime (e.g., `docker run ... --port=8080`) -- **Testing**: Inject test configuration without modifying environment or files -- **Self-documenting CLIs**: Use semantic prefixes (`@host`, `#issue`) for clarity - -## See Also - -- [Environment Variable Provider](../EnvironmentVariableProvider/README.md) -- [File Source Provider](../FileSourceProvider/README.md) +# CommandLine Provider + +The CommandLine provider allows you to load configuration from command-line arguments with flexible, configurable argument parsing. + +## Features + +- **Flexible switch prefixes**: Support any prefix style (`--`, `-`, `/`, `@`, `#`, `%`, etc.) - even multiple at once! +- **Multiple formats**: Supports `--key=value`, `--key value`, and boolean flags +- **Nested configuration**: Use `:` or `__` for hierarchical keys (e.g., `--database:host=localhost`) +- **Prefix filtering**: Filter arguments by prefix to map to specific configuration types +- **Automatic fallback**: Uses `Environment.GetCommandLineArgs()` when args not explicitly provided + +## Basic Usage + +### Simple usage (default `--` prefix) + +```csharp +builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + rule.For().FromCommandLine() +])); +``` + +Command line: +```bash +dotnet run --host=localhost --port=8080 --verbose +``` + +Maps to: +```csharp +public class AppConfig +{ + public string Host { get; set; } // "localhost" + public int Port { get; set; } // 8080 + public bool Verbose { get; set; } // true +} +``` + +### Custom switch prefixes + +Use any prefix style you prefer: + +```csharp +// Single dash (Unix-style) +rule.For().FromCommandLine(["-"]) + +// Forward slash (Windows-style) +rule.For().FromCommandLine(["/"]) + +// Custom prefixes for semantic clarity +rule.For().FromCommandLine(["@", "#"]) +``` + +**...or literally any string you want** 😏 + +### Multiple switch prefixes + +Accept multiple prefix styles simultaneously: + +```csharp +rule.For().FromCommandLine(["--", "-", "/"]) +``` + +Command line can now mix styles: +```bash +dotnet run --host=localhost -port=8080 /verbose +``` + +**Note:** Prefixes are matched longest-first, so `--` is checked before `-` to avoid ambiguity. + +## Advanced Usage + +### Nested Configuration + +Use `:` or `__` to create hierarchical configuration: + +```bash +dotnet run --database:host=localhost --database:port=5432 +``` + +Maps to: +```csharp +public class AppConfig +{ + public DatabaseConfig Database { get; set; } +} + +public class DatabaseConfig +{ + public string Host { get; set; } // "localhost" + public int Port { get; set; } // 5432 +} +``` + +### Using Prefix to Map Multiple Types + +You can use prefixes to map different command-line arguments to different configuration types: + +```csharp +builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + rule.For().FromCommandLine("app_"), + rule.For().FromCommandLine("db_") +])); +``` + +Command line: +```bash +dotnet run --app_host=localhost --db_connectionstring="Server=localhost" +``` + +This maps: +- `--app_host=localhost` → `AppConfig.Host` (prefix stripped, becomes `host`) +- `--db_connectionstring=...` → `DatabaseConfig.ConnectionString` (prefix stripped, becomes `connectionstring`) + +### Combining Prefix Filtering and Custom Switch Prefixes + +Mix semantic prefixes with custom switch styles: + +```csharp +builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + rule.For().FromCommandLine("target_", ["@"]), + rule.For().FromCommandLine("issue_", ["#"]) +])); +``` + +Command line: +```bash +invoke.exe @target_host=10.10.10.10 #issue_id=123 +``` + +### Dynamic Configuration with Config-Aware Rules + +```csharp +builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + rule.For().FromFile("tenant.json"), + + rule.For().FromCommandLine(accessor => + { + var tenant = accessor.GetRequiredConfig(); + return new CommandLineRuleOptions + { + Prefix = $"{tenant.Name}_", + SwitchPrefixes = ["--", "-"] + }; + }) +])); +``` + +## Argument Format Support + +The provider supports multiple argument formats: + +| Format | Example | Result | +|--------|---------|--------| +| `--key=value` | `--host=localhost` | `{ "host": "localhost" }` | +| `--key value` | `--host localhost` | `{ "host": "localhost" }` | +| `--flag` | `--verbose` | `{ "verbose": "true" }` | +| `--nested:key` | `--db:host=localhost` | `{ "db": { "host": "localhost" } }` | +| `--nested__key` | `--db__host=localhost` | `{ "db": { "host": "localhost" } }` | + +**Custom prefixes:** All formats work with any configured switch prefix (`-`, `/`, `@`, etc.) + +## Configuration Options + +### Simple API + +```csharp +// Default (-- prefix, no filtering) +.FromCommandLine() + +// With prefix filtering only +.FromCommandLine("app_") + +// With custom switch prefix +.FromCommandLine(["-"]) + +// With multiple switch prefixes +.FromCommandLine(["--", "-", "/"]) + +// Prefix filtering + custom switches +.FromCommandLine("app_", ["@", "#"]) +``` + +### Factory API (for testing/advanced scenarios) + +```csharp +.FromCommandLine(cm => new CommandLineRuleOptions +{ + Args = testArgs, // For testing; defaults to Environment.GetCommandLineArgs() + SwitchPrefixes = ["@", "#"], // Custom switch prefixes; defaults to ["--"] + Prefix = "app_" // Prefix filter; defaults to null (no filtering) +}) +``` + +## Type Conversion + +The ConfigurationManager automatically converts string values to the target property types: + +```bash +dotnet run --port=8080 --timeout=30.5 --enabled=true +``` + +```csharp +public class AppConfig +{ + public int Port { get; set; } // Converted to int: 8080 + public double Timeout { get; set; } // Converted to double: 30.5 + public bool Enabled { get; set; } // Converted to bool: true +} +``` + +## Layering with Other Providers + +Command-line arguments are typically used as the highest-priority layer to override file and environment-based configuration: + +```csharp +builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + rule.For().FromFile("appsettings.json"), // Base + rule.For().FromEnvironment("APP_"), // Override + rule.For().FromCommandLine() // Final override +])); +``` + +Command line: +```bash +dotnet run --port=9000 +``` + +This overrides the port from both the file and environment variables. + +## Creative Use Cases + +### Semantic Prefixes for Self-Documenting CLIs + +```csharp +rule.For().FromCommandLine(["@"]), +rule.For().FromCommandLine(["#"]), +rule.For().FromCommandLine(["%"]) +``` + +```bash +invoke.exe @host=10.10.10.10 #ticket=456 %env=prod +``` + +### Mixed Unix/Windows Style + +Accept both Unix and Windows conventions: + +```csharp +rule.For().FromCommandLine(["--", "/"]) +``` + +```bash +dotnet run --host=localhost /port=8080 +``` + +## Limitations + +- **No reactive updates**: Command-line arguments are static; they don't change during application lifetime +- **Basic parsing only**: No support for subcommands, argument validation, or complex parsing rules +- **String-based**: All values are initially strings and must be convertible to target property types + +## Use Cases + +- **Development overrides**: Quickly override configuration during development +- **Container/deployment**: Pass environment-specific values at runtime (e.g., `docker run ... --port=8080`) +- **Testing**: Inject test configuration without modifying environment or files +- **Self-documenting CLIs**: Use semantic prefixes (`@host`, `#issue`) for clarity + +## See Also + +- [Environment Variable Provider](../EnvironmentVariableProvider/README.md) +- [File Source Provider](../FileSourceProvider/README.md) diff --git a/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableOptions.cs b/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableOptions.cs index ecca855..e4ab597 100644 --- a/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableOptions.cs +++ b/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableOptions.cs @@ -1,13 +1,13 @@ -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.Providers; - -public record EnvironmentVariableRuleOptions(string? EnvironmentPrefix = null); - -public record EnvironmentVariableProviderQueryOptions(string? EnvironmentPrefix = null) : IProviderQuery; - -public record EnvironmentVariableProviderOptions() : IProviderConfiguration -{ - public string GenerateProviderKey() => "Environment:Global"; -} - +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public record EnvironmentVariableRuleOptions(string? EnvironmentPrefix = null); + +public record EnvironmentVariableProviderQueryOptions(string? EnvironmentPrefix = null) : IProviderQuery; + +public record EnvironmentVariableProviderOptions() : IProviderConfiguration +{ + public string GenerateProviderKey() => "Environment:Global"; +} + diff --git a/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableProvider.cs b/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableProvider.cs index c94b753..325abbf 100644 --- a/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableProvider.cs +++ b/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableProvider.cs @@ -1,160 +1,160 @@ -using System.Text; -using System.Text.Json; -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.Providers; - -public sealed class EnvironmentVariableProvider(EnvironmentVariableProviderOptions options) - : ConfigurationProvider(options) -{ - public override Task FetchConfigurationBytesAsync(EnvironmentVariableProviderQueryOptions queryOptions, - CancellationToken ct = default) - { - var prefix = queryOptions.EnvironmentPrefix; - var variables = Environment.GetEnvironmentVariables(); - var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var keyObj in variables.Keys) - { - var key = keyObj.ToString()!; - if (!string.IsNullOrEmpty(prefix)) - { - if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - AddToNestedDict(dict, key.Substring(prefix.Length), variables[keyObj]); - } - else - { - AddToNestedDict(dict, key, variables[keyObj]); - } - } - - var bytes = JsonSerializer.SerializeToUtf8Bytes(dict); - return Task.FromResult(bytes); - } - - private static void AddToNestedDict(IDictionary dict, string key, object? value) - { - if (string.IsNullOrWhiteSpace(key)) - { - return; - } - - // Trim a single leading separator (for prefix cases like "MYAPP" + "_FOO") - key = TrimSingleLeadingSeparator(key); - - var parts = SplitEnvKey(key).ToArray(); - if (parts.Length == 0) - { - return; - } - - var current = dict; - for (var i = 0; i < parts.Length - 1; i++) - { - var seg = parts[i]; - if (!current.TryGetValue(seg, out var next) || next is not IDictionary nextDict) - { - nextDict = new Dictionary(StringComparer.OrdinalIgnoreCase); - current[seg] = nextDict; - } - - current = nextDict; - } - - current[parts[^1]] = value; - } - - // Split using .NET convention: "__" is a nesting separator (like ':'), and '.' is also treated as a separator. - // Single '_' is literal and NOT a separator. - private static IEnumerable SplitEnvKey(string key) - { - var sb = new StringBuilder(); - for (var i = 0; i < key.Length;) - { - var c = key[i]; - - // Colon is a nesting separator (Microsoft convention) - if (c == ':') - { - if (sb.Length > 0) - { - yield return sb.ToString(); - sb.Clear(); - } - - i++; - continue; - } - - // Double underscore (or run of >=2 underscores) is a separator - if (c == '_' && i + 1 < key.Length && key[i + 1] == '_') - { - // Consume the entire run of underscores - var j = i; - while (j < key.Length && key[j] == '_') j++; - if (j - i >= 2) - { - if (sb.Length > 0) - { - yield return sb.ToString(); - sb.Clear(); - } - - i = j; - continue; - } - } - - // Otherwise, literal character - sb.Append(c); - i++; - } - - if (sb.Length > 0) - { - yield return sb.ToString(); - } - } - - private static string TrimSingleLeadingSeparator(string s) - { - if (string.IsNullOrEmpty(s)) - { - return s; - } - - // If starts with double underscore, treat as delimiter and remove it. - if (s.Length >= 2 && s[0] == '_' && s[1] == '_') - { - return s[2..]; - } - - // Otherwise, trim a single leading ':' or '_' if present - if (s[0] == ':' || s[0] == '_') - { - return s[1..]; - } - - return s; - } - - /// - /// Helper method to create an environment variable configuration rule for testing purposes. - /// - public static Rules.ConfigRule CreateRule(string? prefix = null, bool required = false) - { - return Rules.ConfigRule.Create( - _ => new EnvironmentVariableProviderOptions(), - _ => new EnvironmentVariableProviderQueryOptions(prefix), - typeof(T), - new Rules.ConfigRuleOptions(Required: required, UseWhen: null) - ); - } - - public override IObservable ChangesAsBytes(EnvironmentVariableProviderQueryOptions queryOptions) - => ObservableHelpers.Never(); -} +using System.Text; +using System.Text.Json; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public sealed class EnvironmentVariableProvider(EnvironmentVariableProviderOptions options) + : ConfigurationProvider(options) +{ + public override Task FetchConfigurationBytesAsync(EnvironmentVariableProviderQueryOptions queryOptions, + CancellationToken ct = default) + { + var prefix = queryOptions.EnvironmentPrefix; + var variables = Environment.GetEnvironmentVariables(); + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var keyObj in variables.Keys) + { + var key = keyObj.ToString()!; + if (!string.IsNullOrEmpty(prefix)) + { + if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + AddToNestedDict(dict, key.Substring(prefix.Length), variables[keyObj]); + } + else + { + AddToNestedDict(dict, key, variables[keyObj]); + } + } + + var bytes = JsonSerializer.SerializeToUtf8Bytes(dict); + return Task.FromResult(bytes); + } + + private static void AddToNestedDict(IDictionary dict, string key, object? value) + { + if (string.IsNullOrWhiteSpace(key)) + { + return; + } + + // Trim a single leading separator (for prefix cases like "MYAPP" + "_FOO") + key = TrimSingleLeadingSeparator(key); + + var parts = SplitEnvKey(key).ToArray(); + if (parts.Length == 0) + { + return; + } + + var current = dict; + for (var i = 0; i < parts.Length - 1; i++) + { + var seg = parts[i]; + if (!current.TryGetValue(seg, out var next) || next is not IDictionary nextDict) + { + nextDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + current[seg] = nextDict; + } + + current = nextDict; + } + + current[parts[^1]] = value; + } + + // Split using .NET convention: "__" is a nesting separator (like ':'), and '.' is also treated as a separator. + // Single '_' is literal and NOT a separator. + private static IEnumerable SplitEnvKey(string key) + { + var sb = new StringBuilder(); + for (var i = 0; i < key.Length;) + { + var c = key[i]; + + // Colon is a nesting separator (Microsoft convention) + if (c == ':') + { + if (sb.Length > 0) + { + yield return sb.ToString(); + sb.Clear(); + } + + i++; + continue; + } + + // Double underscore (or run of >=2 underscores) is a separator + if (c == '_' && i + 1 < key.Length && key[i + 1] == '_') + { + // Consume the entire run of underscores + var j = i; + while (j < key.Length && key[j] == '_') j++; + if (j - i >= 2) + { + if (sb.Length > 0) + { + yield return sb.ToString(); + sb.Clear(); + } + + i = j; + continue; + } + } + + // Otherwise, literal character + sb.Append(c); + i++; + } + + if (sb.Length > 0) + { + yield return sb.ToString(); + } + } + + private static string TrimSingleLeadingSeparator(string s) + { + if (string.IsNullOrEmpty(s)) + { + return s; + } + + // If starts with double underscore, treat as delimiter and remove it. + if (s.Length >= 2 && s[0] == '_' && s[1] == '_') + { + return s[2..]; + } + + // Otherwise, trim a single leading ':' or '_' if present + if (s[0] == ':' || s[0] == '_') + { + return s[1..]; + } + + return s; + } + + /// + /// Helper method to create an environment variable configuration rule for testing purposes. + /// + public static Rules.ConfigRule CreateRule(string? prefix = null, bool required = false) + { + return Rules.ConfigRule.Create( + _ => new EnvironmentVariableProviderOptions(), + _ => new EnvironmentVariableProviderQueryOptions(prefix), + typeof(T), + new Rules.ConfigRuleOptions(Required: required, UseWhen: null) + ); + } + + public override IObservable ChangesAsBytes(EnvironmentVariableProviderQueryOptions queryOptions) + => ObservableHelpers.Never(); +} diff --git a/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableRulesExtensions.cs b/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableRulesExtensions.cs index 598890d..0aee9ca 100644 --- a/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableRulesExtensions.cs +++ b/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableRulesExtensions.cs @@ -1,34 +1,34 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; - -namespace Cocoar.Configuration.Providers; - -public static class EnvironmentVariableRulesExtensions -{ - /// - /// Creates an environment variable configuration rule with an optional prefix. - /// - public static - ProviderRuleBuilder FromEnvironment(this TypedProviderBuilder builder, string? environmentPrefix = null) - where T : class - => new( - cm => new(), - cm => new(environmentPrefix), - typeof(T) - ); - - /// - /// Creates an environment variable configuration rule with custom options. - /// - public static - ProviderRuleBuilder FromEnvironment(this TypedProviderBuilder builder, - Func optionsFactory) - where T : class - => new( - cm => new(), - cm => new(optionsFactory(cm).EnvironmentPrefix), - typeof(T) - ); -} +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; + +namespace Cocoar.Configuration.Providers; + +public static class EnvironmentVariableRulesExtensions +{ + /// + /// Creates an environment variable configuration rule with an optional prefix. + /// + public static + ProviderRuleBuilder FromEnvironment(this TypedProviderBuilder builder, string? environmentPrefix = null) + where T : class + => new( + cm => new(), + cm => new(environmentPrefix), + typeof(T) + ); + + /// + /// Creates an environment variable configuration rule with custom options. + /// + public static + ProviderRuleBuilder FromEnvironment(this TypedProviderBuilder builder, + Func optionsFactory) + where T : class + => new( + cm => new(), + cm => new(optionsFactory(cm).EnvironmentPrefix), + typeof(T) + ); +} diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProvider.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProvider.cs index b8e5ed0..a0b2b63 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProvider.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProvider.cs @@ -1,228 +1,228 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using Cocoar.Configuration.Providers.Abstractions; -using Cocoar.FileSystem; - -namespace Cocoar.Configuration.Providers; - -public sealed class FileSourceProvider : ConfigurationProvider, IDisposable -{ - private readonly ConcurrentDictionary> _changeBytesStreams = new(); - - private readonly SimpleSubject _changeSubject = new(); - private readonly ResilientFileSystemMonitor _monitor; - private readonly CancellationTokenSource _cts = new(); - private bool _disposed; - - public FileSourceProvider(FileSourceProviderOptions options) : base(options) - { - _monitor = ResilientFileSystemMonitor - .Watch(options.Directory, "*") - .WithPollingFallback(options.PollingInterval) - .Build(); - - // Background task for event processing — observe faults so they don't go unnoticed - var monitorTask = Task.Run(async () => await ProcessFileSystemEventsAsync(_cts.Token).ConfigureAwait(false)); - monitorTask.ContinueWith( - static t => System.Diagnostics.Debug.Fail( - $"FileSourceProvider: file monitoring task faulted: {t.Exception?.GetBaseException().Message}"), - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); - } - - private async Task ProcessFileSystemEventsAsync(CancellationToken ct) - { - try - { - await foreach (var evt in _monitor.Events.ReadAllAsync(ct).ConfigureAwait(false)) - { - var changeType = evt.Kind switch - { - FileSystemEventKind.Created => FileSystemChangeType.Created, - FileSystemEventKind.Changed => FileSystemChangeType.Changed, - FileSystemEventKind.Deleted => FileSystemChangeType.Deleted, - FileSystemEventKind.Renamed => FileSystemChangeType.Renamed, - _ => (FileSystemChangeType?)null - }; - - if (changeType.HasValue) - { - _changeSubject.OnNext(new(changeType.Value, evt.FullPath, evt.OldFullPath)); - } - } - } - catch (OperationCanceledException) - { - } - } - - public override Task FetchConfigurationBytesAsync(FileSourceProviderQueryOptions queryOptions, - CancellationToken ct = default) - { - var filename = queryOptions.Filename; - var bytes = LoadFileBytes(filename); - return Task.FromResult(bytes); - } - - public override IObservable ChangesAsBytes(FileSourceProviderQueryOptions queryOptions) - { - var filename = queryOptions.Filename; - return _changeBytesStreams.GetOrAdd(filename, fn => - { - IObservable filtered = ((IObservable)_changeSubject) - .Where(ev => Path.GetFileName(ev.Path).Equals(fn, StringComparison.OrdinalIgnoreCase) || - (ev.OldPath != null && Path.GetFileName(ev.OldPath) - .Equals(fn, StringComparison.OrdinalIgnoreCase))); - - // Apply per-query debounce if provided; default is no debounce - if (queryOptions.DebounceTime is { } d && d > TimeSpan.Zero) - { - filtered = new ThrottleObservable(filtered, d); - } - - return filtered.Select(_ => - { - byte[] newBytes; - try - { - newBytes = LoadFileBytes(fn); - } - catch - { - newBytes = "{}"u8.ToArray(); - } - return newBytes; - }); - }); - } - - private byte[] LoadFileBytes(string filename) - { - var fullPath = Path.GetFullPath(Path.Combine(ProviderOptions.Directory, filename)); - - // Prevent path traversal attacks - ensure resolved path is within configured directory. - // Append trailing separator so "config_backup/../" can't escape a "config" base dir. - var baseDir = Path.GetFullPath(ProviderOptions.Directory); - var baseDirWithSep = baseDir.EndsWith(Path.DirectorySeparatorChar) - ? baseDir - : baseDir + Path.DirectorySeparatorChar; - if (!fullPath.StartsWith(baseDirWithSep, StringComparison.OrdinalIgnoreCase) - && !string.Equals(fullPath, baseDir, StringComparison.OrdinalIgnoreCase)) - { - throw new UnauthorizedAccessException( - $"Path traversal detected: filename '{filename}' resolves outside configured directory. " + - $"Resolved: {fullPath}, Expected base: {baseDir}"); - } - - // Throw specific exceptions so ConfigManager can handle Required vs Optional rules appropriately - if (!Directory.Exists(ProviderOptions.Directory)) - { - throw new DirectoryNotFoundException( - $"Config directory doesn't exist: {ProviderOptions.Directory}. " + - $"Check your path or mark the rule as Optional if this directory might not exist yet."); - } - - if (!File.Exists(fullPath)) - { - throw new FileNotFoundException( - $"Config file not found: {fullPath}. " + - $"If this file is created later at runtime, mark the rule as Optional.", fullPath); - } - - // Reject symlinks / reparse points to prevent symlink escape attacks - var fileInfo = new FileInfo(fullPath); - if ((fileInfo.Attributes & FileAttributes.ReparsePoint) != 0) - { - throw new UnauthorizedAccessException( - $"Symlinks are not allowed for config files: {fullPath}"); - } - - // Use FileReader for secure file reading with shared access and BOM handling - return FileReader.ReadAllBytes(fullPath, stripUtf8Bom: true); - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - - _cts?.Cancel(); - _cts?.Dispose(); - _monitor?.Dispose(); - _changeSubject?.Dispose(); - } - - /// - /// Trailing-edge throttle (debounce): emits the last received value after a quiet period. - /// - private sealed class ThrottleObservable(IObservable source, TimeSpan dueTime) : IObservable - { - public IDisposable Subscribe(IObserver observer) - { - var state = new ThrottleState(observer, dueTime); - var sub = source.Subscribe(state); - return DisposableHelpers.Create(() => - { - sub.Dispose(); - state.Dispose(); - }); - } - - private sealed class ThrottleState(IObserver target, TimeSpan dueTime) : IObserver, IDisposable - { -#if NET9_0_OR_GREATER - private readonly Lock _lock = new(); -#else - private readonly object _lock = new(); -#endif - private Timer? _timer; - private T? _latestValue; - private bool _hasValue; - private bool _disposed; - - public void OnNext(T value) - { - lock (_lock) - { - if (_disposed) return; - _latestValue = value; - _hasValue = true; - if (_timer is null) - _timer = new Timer(Tick, null, dueTime, Timeout.InfiniteTimeSpan); - else - _timer.Change(dueTime, Timeout.InfiniteTimeSpan); - } - } - - public void OnError(Exception error) => target.OnError(error); - public void OnCompleted() => target.OnCompleted(); - - private void Tick(object? _) - { - T value; - lock (_lock) - { - if (!_hasValue || _disposed) return; - value = _latestValue!; - _hasValue = false; - } - - target.OnNext(value); - } - - public void Dispose() - { - lock (_lock) - { - _disposed = true; - _hasValue = false; - _timer?.Dispose(); - _timer = null; - } - } - } - } -} +using System.Collections.Concurrent; +using System.Text.Json; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.FileSystem; + +namespace Cocoar.Configuration.Providers; + +public sealed class FileSourceProvider : ConfigurationProvider, IDisposable +{ + private readonly ConcurrentDictionary> _changeBytesStreams = new(); + + private readonly SimpleSubject _changeSubject = new(); + private readonly ResilientFileSystemMonitor _monitor; + private readonly CancellationTokenSource _cts = new(); + private bool _disposed; + + public FileSourceProvider(FileSourceProviderOptions options) : base(options) + { + _monitor = ResilientFileSystemMonitor + .Watch(options.Directory, "*") + .WithPollingFallback(options.PollingInterval) + .Build(); + + // Background task for event processing — observe faults so they don't go unnoticed + var monitorTask = Task.Run(async () => await ProcessFileSystemEventsAsync(_cts.Token).ConfigureAwait(false)); + monitorTask.ContinueWith( + static t => System.Diagnostics.Debug.Fail( + $"FileSourceProvider: file monitoring task faulted: {t.Exception?.GetBaseException().Message}"), + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); + } + + private async Task ProcessFileSystemEventsAsync(CancellationToken ct) + { + try + { + await foreach (var evt in _monitor.Events.ReadAllAsync(ct).ConfigureAwait(false)) + { + var changeType = evt.Kind switch + { + FileSystemEventKind.Created => FileSystemChangeType.Created, + FileSystemEventKind.Changed => FileSystemChangeType.Changed, + FileSystemEventKind.Deleted => FileSystemChangeType.Deleted, + FileSystemEventKind.Renamed => FileSystemChangeType.Renamed, + _ => (FileSystemChangeType?)null + }; + + if (changeType.HasValue) + { + _changeSubject.OnNext(new(changeType.Value, evt.FullPath, evt.OldFullPath)); + } + } + } + catch (OperationCanceledException) + { + } + } + + public override Task FetchConfigurationBytesAsync(FileSourceProviderQueryOptions queryOptions, + CancellationToken ct = default) + { + var filename = queryOptions.Filename; + var bytes = LoadFileBytes(filename); + return Task.FromResult(bytes); + } + + public override IObservable ChangesAsBytes(FileSourceProviderQueryOptions queryOptions) + { + var filename = queryOptions.Filename; + return _changeBytesStreams.GetOrAdd(filename, fn => + { + IObservable filtered = ((IObservable)_changeSubject) + .Where(ev => Path.GetFileName(ev.Path).Equals(fn, StringComparison.OrdinalIgnoreCase) || + (ev.OldPath != null && Path.GetFileName(ev.OldPath) + .Equals(fn, StringComparison.OrdinalIgnoreCase))); + + // Apply per-query debounce if provided; default is no debounce + if (queryOptions.DebounceTime is { } d && d > TimeSpan.Zero) + { + filtered = new ThrottleObservable(filtered, d); + } + + return filtered.Select(_ => + { + byte[] newBytes; + try + { + newBytes = LoadFileBytes(fn); + } + catch + { + newBytes = "{}"u8.ToArray(); + } + return newBytes; + }); + }); + } + + private byte[] LoadFileBytes(string filename) + { + var fullPath = Path.GetFullPath(Path.Combine(ProviderOptions.Directory, filename)); + + // Prevent path traversal attacks - ensure resolved path is within configured directory. + // Append trailing separator so "config_backup/../" can't escape a "config" base dir. + var baseDir = Path.GetFullPath(ProviderOptions.Directory); + var baseDirWithSep = baseDir.EndsWith(Path.DirectorySeparatorChar) + ? baseDir + : baseDir + Path.DirectorySeparatorChar; + if (!fullPath.StartsWith(baseDirWithSep, StringComparison.OrdinalIgnoreCase) + && !string.Equals(fullPath, baseDir, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException( + $"Path traversal detected: filename '{filename}' resolves outside configured directory. " + + $"Resolved: {fullPath}, Expected base: {baseDir}"); + } + + // Throw specific exceptions so ConfigManager can handle Required vs Optional rules appropriately + if (!Directory.Exists(ProviderOptions.Directory)) + { + throw new DirectoryNotFoundException( + $"Config directory doesn't exist: {ProviderOptions.Directory}. " + + $"Check your path or mark the rule as Optional if this directory might not exist yet."); + } + + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException( + $"Config file not found: {fullPath}. " + + $"If this file is created later at runtime, mark the rule as Optional.", fullPath); + } + + // Reject symlinks / reparse points to prevent symlink escape attacks + var fileInfo = new FileInfo(fullPath); + if ((fileInfo.Attributes & FileAttributes.ReparsePoint) != 0) + { + throw new UnauthorizedAccessException( + $"Symlinks are not allowed for config files: {fullPath}"); + } + + // Use FileReader for secure file reading with shared access and BOM handling + return FileReader.ReadAllBytes(fullPath, stripUtf8Bom: true); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + _cts?.Cancel(); + _cts?.Dispose(); + _monitor?.Dispose(); + _changeSubject?.Dispose(); + } + + /// + /// Trailing-edge throttle (debounce): emits the last received value after a quiet period. + /// + private sealed class ThrottleObservable(IObservable source, TimeSpan dueTime) : IObservable + { + public IDisposable Subscribe(IObserver observer) + { + var state = new ThrottleState(observer, dueTime); + var sub = source.Subscribe(state); + return DisposableHelpers.Create(() => + { + sub.Dispose(); + state.Dispose(); + }); + } + + private sealed class ThrottleState(IObserver target, TimeSpan dueTime) : IObserver, IDisposable + { +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + private Timer? _timer; + private T? _latestValue; + private bool _hasValue; + private bool _disposed; + + public void OnNext(T value) + { + lock (_lock) + { + if (_disposed) return; + _latestValue = value; + _hasValue = true; + if (_timer is null) + _timer = new Timer(Tick, null, dueTime, Timeout.InfiniteTimeSpan); + else + _timer.Change(dueTime, Timeout.InfiniteTimeSpan); + } + } + + public void OnError(Exception error) => target.OnError(error); + public void OnCompleted() => target.OnCompleted(); + + private void Tick(object? _) + { + T value; + lock (_lock) + { + if (!_hasValue || _disposed) return; + value = _latestValue!; + _hasValue = false; + } + + target.OnNext(value); + } + + public void Dispose() + { + lock (_lock) + { + _disposed = true; + _hasValue = false; + _timer?.Dispose(); + _timer = null; + } + } + } + } +} diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProviderOptions.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProviderOptions.cs index 990961b..cf86ac9 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProviderOptions.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProviderOptions.cs @@ -1,15 +1,15 @@ -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.Providers; - -public class FileSourceProviderOptions(string directory, TimeSpan? pollingInterval = null) - : IProviderConfiguration -{ - public string Directory { get; } = - Path.IsPathRooted(directory) ? directory : Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, directory)); - - public TimeSpan PollingInterval { get; } = pollingInterval ?? TimeSpan.FromSeconds(10); - - public string GenerateProviderKey() - => $"{Directory}|{PollingInterval.TotalMilliseconds}"; -} +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public class FileSourceProviderOptions(string directory, TimeSpan? pollingInterval = null) + : IProviderConfiguration +{ + public string Directory { get; } = + Path.IsPathRooted(directory) ? directory : Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, directory)); + + public TimeSpan PollingInterval { get; } = pollingInterval ?? TimeSpan.FromSeconds(10); + + public string GenerateProviderKey() + => $"{Directory}|{PollingInterval.TotalMilliseconds}"; +} diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProviderQueryOptions.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProviderQueryOptions.cs index ebb4765..0e14d5a 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProviderQueryOptions.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProviderQueryOptions.cs @@ -1,8 +1,8 @@ -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.Providers; - -public record FileSourceProviderQueryOptions( - string Filename, - TimeSpan? DebounceTime = null -) : IProviderQuery; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public record FileSourceProviderQueryOptions( + string Filename, + TimeSpan? DebounceTime = null +) : IProviderQuery; diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRuleOptions.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRuleOptions.cs index 3446e59..41183df 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRuleOptions.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRuleOptions.cs @@ -1,51 +1,51 @@ -namespace Cocoar.Configuration.Providers; - -public sealed class FileSourceRuleOptions -{ - public string Directory { get; } - public string Filename { get; } - public TimeSpan? DebounceTime { get; } - public TimeSpan? PollingInterval { get; } - - public FileSourceRuleOptions(string directory, string filename, TimeSpan? debounceTime = null, TimeSpan? pollingInterval = null) - { - if (string.IsNullOrWhiteSpace(directory)) - { - throw new ArgumentException("directory is required", nameof(directory)); - } - - if (string.IsNullOrWhiteSpace(filename)) - { - throw new ArgumentException("filename is required", nameof(filename)); - } - - Directory = directory; - Filename = filename; - DebounceTime = debounceTime; - PollingInterval = pollingInterval; - } - - public static FileSourceRuleOptions FromFilePath(string filePath, TimeSpan? debounceTime = null, TimeSpan? pollingInterval = null) - { - if (string.IsNullOrWhiteSpace(filePath)) - { - throw new ArgumentException("filePath is required", nameof(filePath)); - } - - var directory = Path.GetDirectoryName(filePath) ?? string.Empty; - if (string.IsNullOrWhiteSpace(directory)) - { - directory = "."; - } - var filename = Path.GetFileName(filePath); - if (string.IsNullOrWhiteSpace(filename)) - { - throw new ArgumentException("filePath must include a filename", nameof(filePath)); - } - - return new(directory, filename, debounceTime, pollingInterval); - } - - public FileSourceProviderOptions ToProviderOptions() => new(Directory, PollingInterval); - public FileSourceProviderQueryOptions ToQueryOptions() => new(Filename, DebounceTime); -} +namespace Cocoar.Configuration.Providers; + +public sealed class FileSourceRuleOptions +{ + public string Directory { get; } + public string Filename { get; } + public TimeSpan? DebounceTime { get; } + public TimeSpan? PollingInterval { get; } + + public FileSourceRuleOptions(string directory, string filename, TimeSpan? debounceTime = null, TimeSpan? pollingInterval = null) + { + if (string.IsNullOrWhiteSpace(directory)) + { + throw new ArgumentException("directory is required", nameof(directory)); + } + + if (string.IsNullOrWhiteSpace(filename)) + { + throw new ArgumentException("filename is required", nameof(filename)); + } + + Directory = directory; + Filename = filename; + DebounceTime = debounceTime; + PollingInterval = pollingInterval; + } + + public static FileSourceRuleOptions FromFilePath(string filePath, TimeSpan? debounceTime = null, TimeSpan? pollingInterval = null) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("filePath is required", nameof(filePath)); + } + + var directory = Path.GetDirectoryName(filePath) ?? string.Empty; + if (string.IsNullOrWhiteSpace(directory)) + { + directory = "."; + } + var filename = Path.GetFileName(filePath); + if (string.IsNullOrWhiteSpace(filename)) + { + throw new ArgumentException("filePath must include a filename", nameof(filePath)); + } + + return new(directory, filename, debounceTime, pollingInterval); + } + + public FileSourceProviderOptions ToProviderOptions() => new(Directory, PollingInterval); + public FileSourceProviderQueryOptions ToQueryOptions() => new(Filename, DebounceTime); +} diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRulesExtensions.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRulesExtensions.cs index 9fef232..3323a38 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRulesExtensions.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRulesExtensions.cs @@ -1,44 +1,44 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; - -namespace Cocoar.Configuration.Providers; - -public static class FileSourceRulesExtensions -{ - /// - /// Creates a file-based configuration rule from a file path. - /// - public static ProviderRuleBuilder - FromFile(this TypedProviderBuilder builder, string filePath) - where T : class - => new( - cm => FileSourceRuleOptions.FromFilePath(filePath).ToProviderOptions(), - cm => FileSourceRuleOptions.FromFilePath(filePath).ToQueryOptions(), - typeof(T) - ); - - /// - /// Creates a file-based configuration rule with custom options. - /// - public static ProviderRuleBuilder - FromFile(this TypedProviderBuilder builder, Func optionsFactory) - where T : class - => new( - cm => optionsFactory(cm).ToProviderOptions(), - cm => optionsFactory(cm).ToQueryOptions(), - typeof(T) - ); - - /// - /// Creates a file-based configuration rule from a config-aware file path — e.g. a per-tenant path - /// a => $"tenants/{a.Tenant}/db.json". The path is resolved from the accessor on each recompute. - /// - public static ProviderRuleBuilder - FromFile(this TypedProviderBuilder builder, Func pathFactory) - where T : class - => new( - cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToProviderOptions(), - cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToQueryOptions(), - typeof(T) - ); -} +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; + +namespace Cocoar.Configuration.Providers; + +public static class FileSourceRulesExtensions +{ + /// + /// Creates a file-based configuration rule from a file path. + /// + public static ProviderRuleBuilder + FromFile(this TypedProviderBuilder builder, string filePath) + where T : class + => new( + cm => FileSourceRuleOptions.FromFilePath(filePath).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(filePath).ToQueryOptions(), + typeof(T) + ); + + /// + /// Creates a file-based configuration rule with custom options. + /// + public static ProviderRuleBuilder + FromFile(this TypedProviderBuilder builder, Func optionsFactory) + where T : class + => new( + cm => optionsFactory(cm).ToProviderOptions(), + cm => optionsFactory(cm).ToQueryOptions(), + typeof(T) + ); + + /// + /// Creates a file-based configuration rule from a config-aware file path — e.g. a per-tenant path + /// a => $"tenants/{a.Tenant}/db.json". The path is resolved from the accessor on each recompute. + /// + public static ProviderRuleBuilder + FromFile(this TypedProviderBuilder builder, Func pathFactory) + where T : class + => new( + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToQueryOptions(), + typeof(T) + ); +} diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/Observable/FileSystemChange.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/Observable/FileSystemChange.cs index 881b159..c96d99b 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/Observable/FileSystemChange.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/Observable/FileSystemChange.cs @@ -1,6 +1,6 @@ -namespace Cocoar.Configuration.Providers; - -public sealed record FileSystemChange( - FileSystemChangeType ChangeType, - string Path, - string? OldPath = null); +namespace Cocoar.Configuration.Providers; + +public sealed record FileSystemChange( + FileSystemChangeType ChangeType, + string Path, + string? OldPath = null); diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/Observable/FileSystemChangeType.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/Observable/FileSystemChangeType.cs index 1e17dc0..724f686 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/Observable/FileSystemChangeType.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/Observable/FileSystemChangeType.cs @@ -1,9 +1,9 @@ -namespace Cocoar.Configuration.Providers; - -public enum FileSystemChangeType -{ - Created, - Changed, - Deleted, - Renamed -} +namespace Cocoar.Configuration.Providers; + +public enum FileSystemChangeType +{ + Created, + Changed, + Deleted, + Renamed +} diff --git a/src/Cocoar.Configuration/Providers/ObservableProvider/ObservableProvider.cs b/src/Cocoar.Configuration/Providers/ObservableProvider/ObservableProvider.cs index acf9e06..171a759 100644 --- a/src/Cocoar.Configuration/Providers/ObservableProvider/ObservableProvider.cs +++ b/src/Cocoar.Configuration/Providers/ObservableProvider/ObservableProvider.cs @@ -1,95 +1,95 @@ -using System.Text.Json; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.Providers; - -public sealed class ObservableProvider(ObservableProviderOptions options) - : ConfigurationProvider, ObservableProviderQuery>(options) -{ - public override async Task FetchConfigurationBytesAsync(ObservableProviderQuery query, CancellationToken ct = default) - { - var tcs = new TaskCompletionSource(); - using var ctr = ct.Register(() => tcs.TrySetCanceled(ct)); - IDisposable? sub = null; - sub = ProviderOptions.Observable.Subscribe( - value => { tcs.TrySetResult(ConvertToBytes(value)); sub?.Dispose(); }, - ex => { tcs.TrySetException(ex); sub?.Dispose(); }); - return await tcs.Task.ConfigureAwait(false); - } - - public override IObservable ChangesAsBytes(ObservableProviderQuery query) - { - return ProviderOptions.Observable.Select(ConvertToBytes); - } - - private byte[] ConvertToBytes(T value) - { - if (typeof(T) == typeof(string) && value is string jsonString) - { - return System.Text.Encoding.UTF8.GetBytes(jsonString); - } - - return JsonSerializer.SerializeToUtf8Bytes(value); - } -} - -public record ObservableProviderOptions(IObservable Observable) : IProviderConfiguration -{ - public string? GenerateProviderKey() => null; -} - -public class ObservableProviderQuery : IProviderQuery -{ - public static readonly ObservableProviderQuery Default = new(); -} - - -public static class ObservableRulesExtensions -{ - /// - /// Creates an observable configuration rule from an observable stream. - /// - public static ProviderRuleBuilder, ObservableProviderOptions, ObservableProviderQuery> - FromObservable(this TypedProviderBuilder builder, IObservable observable) - where T : class - { - return new( - _ => new ObservableProviderOptions(observable), - _ => ObservableProviderQuery.Default, - typeof(T) - ); - } - - /// - /// Creates an observable configuration rule from a JSON string observable. - /// - public static ProviderRuleBuilder, ObservableProviderOptions, ObservableProviderQuery> - FromObservable(this TypedProviderBuilder builder, IObservable jsonObservable) - where T : class - { - return new( - _ => new ObservableProviderOptions(jsonObservable), - _ => ObservableProviderQuery.Default, - typeof(T) - ); - } - - /// - /// Creates an observable configuration rule from an initial JSON string. - /// - public static ProviderRuleBuilder, ObservableProviderOptions, ObservableProviderQuery> - FromObservable(this TypedProviderBuilder builder, string initialJsonString) - where T : class - { - using var document = JsonDocument.Parse(initialJsonString); - - var subject = new SimpleBehaviorSubject(initialJsonString); - - return new( - _ => new ObservableProviderOptions(subject), - _ => ObservableProviderQuery.Default, - typeof(T) - ); - } -} +using System.Text.Json; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public sealed class ObservableProvider(ObservableProviderOptions options) + : ConfigurationProvider, ObservableProviderQuery>(options) +{ + public override async Task FetchConfigurationBytesAsync(ObservableProviderQuery query, CancellationToken ct = default) + { + var tcs = new TaskCompletionSource(); + using var ctr = ct.Register(() => tcs.TrySetCanceled(ct)); + IDisposable? sub = null; + sub = ProviderOptions.Observable.Subscribe( + value => { tcs.TrySetResult(ConvertToBytes(value)); sub?.Dispose(); }, + ex => { tcs.TrySetException(ex); sub?.Dispose(); }); + return await tcs.Task.ConfigureAwait(false); + } + + public override IObservable ChangesAsBytes(ObservableProviderQuery query) + { + return ProviderOptions.Observable.Select(ConvertToBytes); + } + + private byte[] ConvertToBytes(T value) + { + if (typeof(T) == typeof(string) && value is string jsonString) + { + return System.Text.Encoding.UTF8.GetBytes(jsonString); + } + + return JsonSerializer.SerializeToUtf8Bytes(value); + } +} + +public record ObservableProviderOptions(IObservable Observable) : IProviderConfiguration +{ + public string? GenerateProviderKey() => null; +} + +public class ObservableProviderQuery : IProviderQuery +{ + public static readonly ObservableProviderQuery Default = new(); +} + + +public static class ObservableRulesExtensions +{ + /// + /// Creates an observable configuration rule from an observable stream. + /// + public static ProviderRuleBuilder, ObservableProviderOptions, ObservableProviderQuery> + FromObservable(this TypedProviderBuilder builder, IObservable observable) + where T : class + { + return new( + _ => new ObservableProviderOptions(observable), + _ => ObservableProviderQuery.Default, + typeof(T) + ); + } + + /// + /// Creates an observable configuration rule from a JSON string observable. + /// + public static ProviderRuleBuilder, ObservableProviderOptions, ObservableProviderQuery> + FromObservable(this TypedProviderBuilder builder, IObservable jsonObservable) + where T : class + { + return new( + _ => new ObservableProviderOptions(jsonObservable), + _ => ObservableProviderQuery.Default, + typeof(T) + ); + } + + /// + /// Creates an observable configuration rule from an initial JSON string. + /// + public static ProviderRuleBuilder, ObservableProviderOptions, ObservableProviderQuery> + FromObservable(this TypedProviderBuilder builder, string initialJsonString) + where T : class + { + using var document = JsonDocument.Parse(initialJsonString); + + var subject = new SimpleBehaviorSubject(initialJsonString); + + return new( + _ => new ObservableProviderOptions(subject), + _ => ObservableProviderQuery.Default, + typeof(T) + ); + } +} diff --git a/src/Cocoar.Configuration/Providers/StaticJsonProvider/StaticJsonProvider.cs b/src/Cocoar.Configuration/Providers/StaticJsonProvider/StaticJsonProvider.cs index d32a468..20cb7a6 100644 --- a/src/Cocoar.Configuration/Providers/StaticJsonProvider/StaticJsonProvider.cs +++ b/src/Cocoar.Configuration/Providers/StaticJsonProvider/StaticJsonProvider.cs @@ -1,26 +1,26 @@ -using System.Text.Json; -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.Providers; - -public sealed class StaticJsonProvider(StaticJsonProviderOptions options) - : ConfigurationProvider(options) -{ - private readonly byte[] _cachedBytes = SerializeToBytes(options.Value); - - private static byte[] SerializeToBytes(JsonElement value) - { - return value.ValueKind == JsonValueKind.Undefined - ? "{}"u8.ToArray() - : JsonSerializer.SerializeToUtf8Bytes(value); - } - - public override Task FetchConfigurationBytesAsync(StaticJsonProviderQueryOptions query, - CancellationToken ct = default) - { - return Task.FromResult(_cachedBytes); - } - - public override IObservable ChangesAsBytes(StaticJsonProviderQueryOptions queryOptions) - => ObservableHelpers.Empty(); -} +using System.Text.Json; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public sealed class StaticJsonProvider(StaticJsonProviderOptions options) + : ConfigurationProvider(options) +{ + private readonly byte[] _cachedBytes = SerializeToBytes(options.Value); + + private static byte[] SerializeToBytes(JsonElement value) + { + return value.ValueKind == JsonValueKind.Undefined + ? "{}"u8.ToArray() + : JsonSerializer.SerializeToUtf8Bytes(value); + } + + public override Task FetchConfigurationBytesAsync(StaticJsonProviderQueryOptions query, + CancellationToken ct = default) + { + return Task.FromResult(_cachedBytes); + } + + public override IObservable ChangesAsBytes(StaticJsonProviderQueryOptions queryOptions) + => ObservableHelpers.Empty(); +} diff --git a/src/Cocoar.Configuration/Providers/StaticJsonProvider/StaticJsonProviderOptions.cs b/src/Cocoar.Configuration/Providers/StaticJsonProvider/StaticJsonProviderOptions.cs index aa38bb8..ad63a9b 100644 --- a/src/Cocoar.Configuration/Providers/StaticJsonProvider/StaticJsonProviderOptions.cs +++ b/src/Cocoar.Configuration/Providers/StaticJsonProvider/StaticJsonProviderOptions.cs @@ -1,12 +1,12 @@ -using System.Text.Json; -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.Providers; - - -public record StaticJsonProviderQueryOptions() : IProviderQuery; - -public record StaticJsonProviderOptions(JsonElement Value) : IProviderConfiguration -{ - public string? GenerateProviderKey() => null; -} +using System.Text.Json; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + + +public record StaticJsonProviderQueryOptions() : IProviderQuery; + +public record StaticJsonProviderOptions(JsonElement Value) : IProviderConfiguration +{ + public string? GenerateProviderKey() => null; +} diff --git a/src/Cocoar.Configuration/Providers/StaticJsonProvider/StaticRulesExtensions.cs b/src/Cocoar.Configuration/Providers/StaticJsonProvider/StaticRulesExtensions.cs index ab5e4d0..6f119d3 100644 --- a/src/Cocoar.Configuration/Providers/StaticJsonProvider/StaticRulesExtensions.cs +++ b/src/Cocoar.Configuration/Providers/StaticJsonProvider/StaticRulesExtensions.cs @@ -1,57 +1,57 @@ -using System.Text.Json; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Testing; - -namespace Cocoar.Configuration.Providers; - -public static class StaticRulesExtensions -{ - /// - /// Creates a static configuration rule from a JSON string. - /// - public static ProviderRuleBuilder< - StaticJsonProvider, - StaticJsonProviderOptions, - StaticJsonProviderQueryOptions - > FromStaticJson(this TypedProviderBuilder builder, string jsonString) - where T : class - { - using var document = JsonDocument.Parse(jsonString); - var jsonElement = document.RootElement.Clone(); - - return new( - _ => new(jsonElement), - _ => new(), - typeof(T) - ); - } - - /// - /// Creates a static configuration rule from a factory function. - /// - public static ProviderRuleBuilder< - StaticJsonProvider, - StaticJsonProviderOptions, - StaticJsonProviderQueryOptions - > FromStatic(this TypedProviderBuilder builder, Func factory) - where T : class - { - return new( - cm => - { - var obj = factory(cm)!; - var options = GetSerializerOptions(); - return new(JsonSerializer.SerializeToElement(obj, options)); - }, - _ => new(), - typeof(T) - ); - } - - private static JsonSerializerOptions? GetSerializerOptions() - { - // Only use custom serialization in test context with registered options - return CocoarTestConfiguration.Current?.SerializerOptions; - } -} +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Testing; + +namespace Cocoar.Configuration.Providers; + +public static class StaticRulesExtensions +{ + /// + /// Creates a static configuration rule from a JSON string. + /// + public static ProviderRuleBuilder< + StaticJsonProvider, + StaticJsonProviderOptions, + StaticJsonProviderQueryOptions + > FromStaticJson(this TypedProviderBuilder builder, string jsonString) + where T : class + { + using var document = JsonDocument.Parse(jsonString); + var jsonElement = document.RootElement.Clone(); + + return new( + _ => new(jsonElement), + _ => new(), + typeof(T) + ); + } + + /// + /// Creates a static configuration rule from a factory function. + /// + public static ProviderRuleBuilder< + StaticJsonProvider, + StaticJsonProviderOptions, + StaticJsonProviderQueryOptions + > FromStatic(this TypedProviderBuilder builder, Func factory) + where T : class + { + return new( + cm => + { + var obj = factory(cm)!; + var options = GetSerializerOptions(); + return new(JsonSerializer.SerializeToElement(obj, options)); + }, + _ => new(), + typeof(T) + ); + } + + private static JsonSerializerOptions? GetSerializerOptions() + { + // Only use custom serialization in test context with registered options + return CocoarTestConfiguration.Current?.SerializerOptions; + } +} diff --git a/src/Cocoar.Configuration/Reactive/Internal/DisposableHelpers.cs b/src/Cocoar.Configuration/Reactive/Internal/DisposableHelpers.cs index c4aa69f..f93d05a 100644 --- a/src/Cocoar.Configuration/Reactive/Internal/DisposableHelpers.cs +++ b/src/Cocoar.Configuration/Reactive/Internal/DisposableHelpers.cs @@ -1,19 +1,19 @@ -namespace Cocoar.Configuration.Reactive.Internal; - -internal static class DisposableHelpers -{ - public static readonly IDisposable Empty = new EmptyDisposable(); - - public static IDisposable Create(Action dispose) => new ActionDisposable(dispose); - - private sealed class EmptyDisposable : IDisposable - { - public void Dispose() { } - } - - private sealed class ActionDisposable(Action dispose) : IDisposable - { - private Action? _dispose = dispose; - public void Dispose() => Interlocked.Exchange(ref _dispose, null)?.Invoke(); - } -} +namespace Cocoar.Configuration.Reactive.Internal; + +internal static class DisposableHelpers +{ + public static readonly IDisposable Empty = new EmptyDisposable(); + + public static IDisposable Create(Action dispose) => new ActionDisposable(dispose); + + private sealed class EmptyDisposable : IDisposable + { + public void Dispose() { } + } + + private sealed class ActionDisposable(Action dispose) : IDisposable + { + private Action? _dispose = dispose; + public void Dispose() => Interlocked.Exchange(ref _dispose, null)?.Invoke(); + } +} diff --git a/src/Cocoar.Configuration/Reactive/Internal/ObservableExtensions.cs b/src/Cocoar.Configuration/Reactive/Internal/ObservableExtensions.cs index ba6a270..736ba83 100644 --- a/src/Cocoar.Configuration/Reactive/Internal/ObservableExtensions.cs +++ b/src/Cocoar.Configuration/Reactive/Internal/ObservableExtensions.cs @@ -1,127 +1,127 @@ -namespace Cocoar.Configuration.Reactive.Internal; - -internal static class ObservableExtensions -{ - public static IDisposable Subscribe(this IObservable source, Action onNext) - => source.Subscribe(new ActionObserver(onNext, null, null)); - - public static IDisposable Subscribe(this IObservable source, Action onNext, Action onError) - => SubscribeSafe(source, new ActionObserver(onNext, onError, null), onError); - - public static IDisposable Subscribe(this IObservable source, Action onNext, Action onError, Action onCompleted) - => SubscribeSafe(source, new ActionObserver(onNext, onError, onCompleted), onError); - - /// - /// Mirrors Rx's SubscribeSafe: catches exceptions thrown during Subscribe - /// (e.g. BehaviorSubject initial replay) and routes them to onError. - /// - private static IDisposable SubscribeSafe(IObservable source, IObserver observer, Action? onError) - { - try - { - return source.Subscribe(observer); - } - catch (Exception ex) - { - onError?.Invoke(ex); - return DisposableHelpers.Empty; - } - } - - public static IObservable Select(this IObservable source, Func selector) - => new SelectObservable(source, selector); - - public static IObservable Where(this IObservable source, Func predicate) - => new WhereObservable(source, predicate); - - public static IObservable DistinctUntilChanged(this IObservable source, IEqualityComparer comparer) - => new DistinctUntilChangedObservable(source, comparer); - - private sealed class ActionObserver(Action? onNext, Action? onError, Action? onCompleted) : IObserver - { - public void OnNext(T value) - { - try - { - onNext?.Invoke(value); - } - catch (Exception ex) when (onError != null) - { - onError(ex); - } - } - - public void OnError(Exception error) => onError?.Invoke(error); - public void OnCompleted() => onCompleted?.Invoke(); - } - - /// - /// Mirrors Rx's source.SubscribeSafe(observer): if source.Subscribe throws - /// (e.g. during BehaviorSubject initial replay), routes to observer.OnError. - /// - private static IDisposable SubscribeToSource(IObservable source, IObserver observer) - { - try - { - return source.Subscribe(observer); - } - catch (Exception ex) - { - observer.OnError(ex); - return DisposableHelpers.Empty; - } - } - - private sealed class SelectObservable(IObservable source, Func selector) : IObservable - { - public IDisposable Subscribe(IObserver observer) - => SubscribeToSource(source, new SelectObserver(observer, selector)); - - private sealed class SelectObserver(IObserver target, Func selector) : IObserver - { - public void OnNext(T value) => target.OnNext(selector(value)); - public void OnError(Exception error) => target.OnError(error); - public void OnCompleted() => target.OnCompleted(); - } - } - - private sealed class WhereObservable(IObservable source, Func predicate) : IObservable - { - public IDisposable Subscribe(IObserver observer) - => SubscribeToSource(source, new WhereObserver(observer, predicate)); - - private sealed class WhereObserver(IObserver target, Func predicate) : IObserver - { - public void OnNext(T value) - { - if (predicate(value)) target.OnNext(value); - } - - public void OnError(Exception error) => target.OnError(error); - public void OnCompleted() => target.OnCompleted(); - } - } - - private sealed class DistinctUntilChangedObservable(IObservable source, IEqualityComparer comparer) : IObservable - { - public IDisposable Subscribe(IObserver observer) - => SubscribeToSource(source, new DistinctObserver(observer, comparer)); - - private sealed class DistinctObserver(IObserver target, IEqualityComparer comparer) : IObserver - { - private bool _hasValue; - private T? _lastValue; - - public void OnNext(T value) - { - if (_hasValue && comparer.Equals(_lastValue!, value)) return; - _hasValue = true; - _lastValue = value; - target.OnNext(value); - } - - public void OnError(Exception error) => target.OnError(error); - public void OnCompleted() => target.OnCompleted(); - } - } -} +namespace Cocoar.Configuration.Reactive.Internal; + +internal static class ObservableExtensions +{ + public static IDisposable Subscribe(this IObservable source, Action onNext) + => source.Subscribe(new ActionObserver(onNext, null, null)); + + public static IDisposable Subscribe(this IObservable source, Action onNext, Action onError) + => SubscribeSafe(source, new ActionObserver(onNext, onError, null), onError); + + public static IDisposable Subscribe(this IObservable source, Action onNext, Action onError, Action onCompleted) + => SubscribeSafe(source, new ActionObserver(onNext, onError, onCompleted), onError); + + /// + /// Mirrors Rx's SubscribeSafe: catches exceptions thrown during Subscribe + /// (e.g. BehaviorSubject initial replay) and routes them to onError. + /// + private static IDisposable SubscribeSafe(IObservable source, IObserver observer, Action? onError) + { + try + { + return source.Subscribe(observer); + } + catch (Exception ex) + { + onError?.Invoke(ex); + return DisposableHelpers.Empty; + } + } + + public static IObservable Select(this IObservable source, Func selector) + => new SelectObservable(source, selector); + + public static IObservable Where(this IObservable source, Func predicate) + => new WhereObservable(source, predicate); + + public static IObservable DistinctUntilChanged(this IObservable source, IEqualityComparer comparer) + => new DistinctUntilChangedObservable(source, comparer); + + private sealed class ActionObserver(Action? onNext, Action? onError, Action? onCompleted) : IObserver + { + public void OnNext(T value) + { + try + { + onNext?.Invoke(value); + } + catch (Exception ex) when (onError != null) + { + onError(ex); + } + } + + public void OnError(Exception error) => onError?.Invoke(error); + public void OnCompleted() => onCompleted?.Invoke(); + } + + /// + /// Mirrors Rx's source.SubscribeSafe(observer): if source.Subscribe throws + /// (e.g. during BehaviorSubject initial replay), routes to observer.OnError. + /// + private static IDisposable SubscribeToSource(IObservable source, IObserver observer) + { + try + { + return source.Subscribe(observer); + } + catch (Exception ex) + { + observer.OnError(ex); + return DisposableHelpers.Empty; + } + } + + private sealed class SelectObservable(IObservable source, Func selector) : IObservable + { + public IDisposable Subscribe(IObserver observer) + => SubscribeToSource(source, new SelectObserver(observer, selector)); + + private sealed class SelectObserver(IObserver target, Func selector) : IObserver + { + public void OnNext(T value) => target.OnNext(selector(value)); + public void OnError(Exception error) => target.OnError(error); + public void OnCompleted() => target.OnCompleted(); + } + } + + private sealed class WhereObservable(IObservable source, Func predicate) : IObservable + { + public IDisposable Subscribe(IObserver observer) + => SubscribeToSource(source, new WhereObserver(observer, predicate)); + + private sealed class WhereObserver(IObserver target, Func predicate) : IObserver + { + public void OnNext(T value) + { + if (predicate(value)) target.OnNext(value); + } + + public void OnError(Exception error) => target.OnError(error); + public void OnCompleted() => target.OnCompleted(); + } + } + + private sealed class DistinctUntilChangedObservable(IObservable source, IEqualityComparer comparer) : IObservable + { + public IDisposable Subscribe(IObserver observer) + => SubscribeToSource(source, new DistinctObserver(observer, comparer)); + + private sealed class DistinctObserver(IObserver target, IEqualityComparer comparer) : IObserver + { + private bool _hasValue; + private T? _lastValue; + + public void OnNext(T value) + { + if (_hasValue && comparer.Equals(_lastValue!, value)) return; + _hasValue = true; + _lastValue = value; + target.OnNext(value); + } + + public void OnError(Exception error) => target.OnError(error); + public void OnCompleted() => target.OnCompleted(); + } + } +} diff --git a/src/Cocoar.Configuration/Reactive/Internal/ObservableHelpers.cs b/src/Cocoar.Configuration/Reactive/Internal/ObservableHelpers.cs index 17f0fd8..5547382 100644 --- a/src/Cocoar.Configuration/Reactive/Internal/ObservableHelpers.cs +++ b/src/Cocoar.Configuration/Reactive/Internal/ObservableHelpers.cs @@ -1,34 +1,34 @@ -namespace Cocoar.Configuration.Reactive.Internal; - -internal static class ObservableHelpers -{ - public static IObservable Empty() => EmptyObservable.Instance; - - public static IObservable Never() => NeverObservable.Instance; - - public static IObservable Create(Func, IDisposable> subscribeFactory) - => new CreateObservable(subscribeFactory); - - private sealed class EmptyObservable : IObservable - { - public static readonly EmptyObservable Instance = new(); - - public IDisposable Subscribe(IObserver observer) - { - observer.OnCompleted(); - return DisposableHelpers.Empty; - } - } - - private sealed class NeverObservable : IObservable - { - public static readonly NeverObservable Instance = new(); - - public IDisposable Subscribe(IObserver observer) => DisposableHelpers.Empty; - } - - private sealed class CreateObservable(Func, IDisposable> factory) : IObservable - { - public IDisposable Subscribe(IObserver observer) => factory(observer); - } -} +namespace Cocoar.Configuration.Reactive.Internal; + +internal static class ObservableHelpers +{ + public static IObservable Empty() => EmptyObservable.Instance; + + public static IObservable Never() => NeverObservable.Instance; + + public static IObservable Create(Func, IDisposable> subscribeFactory) + => new CreateObservable(subscribeFactory); + + private sealed class EmptyObservable : IObservable + { + public static readonly EmptyObservable Instance = new(); + + public IDisposable Subscribe(IObserver observer) + { + observer.OnCompleted(); + return DisposableHelpers.Empty; + } + } + + private sealed class NeverObservable : IObservable + { + public static readonly NeverObservable Instance = new(); + + public IDisposable Subscribe(IObserver observer) => DisposableHelpers.Empty; + } + + private sealed class CreateObservable(Func, IDisposable> factory) : IObservable + { + public IDisposable Subscribe(IObserver observer) => factory(observer); + } +} diff --git a/src/Cocoar.Configuration/Reactive/Internal/SimpleBehaviorSubject.cs b/src/Cocoar.Configuration/Reactive/Internal/SimpleBehaviorSubject.cs index 53206ae..ca1a4e7 100644 --- a/src/Cocoar.Configuration/Reactive/Internal/SimpleBehaviorSubject.cs +++ b/src/Cocoar.Configuration/Reactive/Internal/SimpleBehaviorSubject.cs @@ -1,95 +1,95 @@ -namespace Cocoar.Configuration.Reactive.Internal; - -internal sealed class SimpleBehaviorSubject : IObservable, IObserver, IDisposable -{ -#if NET9_0_OR_GREATER - private readonly Lock _lock = new(); -#else - private readonly object _lock = new(); -#endif - private IObserver[] _observers = []; - private T _value; - private bool _disposed; - - public SimpleBehaviorSubject(T initialValue) - { - _value = initialValue; - } - - public T Value - { - get - { - lock (_lock) - { - ObjectDisposedException.ThrowIf(_disposed, this); - return _value; - } - } - } - - public IDisposable Subscribe(IObserver observer) - { - ArgumentNullException.ThrowIfNull(observer); - T currentValue; - lock (_lock) - { - ObjectDisposedException.ThrowIf(_disposed, this); -#if NET9_0_OR_GREATER - _observers = [.. _observers, observer]; -#else - _observers = _observers.Append(observer).ToArray(); -#endif - currentValue = _value; - } - - observer.OnNext(currentValue); - return DisposableHelpers.Create(() => RemoveObserver(observer)); - } - - public void OnNext(T value) - { - IObserver[] snapshot; - lock (_lock) - { - _value = value; - snapshot = _observers; - } - - foreach (var observer in snapshot) - observer.OnNext(value); - } - - public void OnError(Exception error) - { - IObserver[] snapshot; - lock (_lock) { snapshot = _observers; } - foreach (var observer in snapshot) - observer.OnError(error); - } - - public void OnCompleted() - { - IObserver[] snapshot; - lock (_lock) { snapshot = _observers; } - foreach (var observer in snapshot) - observer.OnCompleted(); - } - - private void RemoveObserver(IObserver observer) - { - lock (_lock) - { - _observers = _observers.Where(o => !ReferenceEquals(o, observer)).ToArray(); - } - } - - public void Dispose() - { - lock (_lock) - { - _disposed = true; - _observers = []; - } - } -} +namespace Cocoar.Configuration.Reactive.Internal; + +internal sealed class SimpleBehaviorSubject : IObservable, IObserver, IDisposable +{ +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + private IObserver[] _observers = []; + private T _value; + private bool _disposed; + + public SimpleBehaviorSubject(T initialValue) + { + _value = initialValue; + } + + public T Value + { + get + { + lock (_lock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _value; + } + } + } + + public IDisposable Subscribe(IObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + T currentValue; + lock (_lock) + { + ObjectDisposedException.ThrowIf(_disposed, this); +#if NET9_0_OR_GREATER + _observers = [.. _observers, observer]; +#else + _observers = _observers.Append(observer).ToArray(); +#endif + currentValue = _value; + } + + observer.OnNext(currentValue); + return DisposableHelpers.Create(() => RemoveObserver(observer)); + } + + public void OnNext(T value) + { + IObserver[] snapshot; + lock (_lock) + { + _value = value; + snapshot = _observers; + } + + foreach (var observer in snapshot) + observer.OnNext(value); + } + + public void OnError(Exception error) + { + IObserver[] snapshot; + lock (_lock) { snapshot = _observers; } + foreach (var observer in snapshot) + observer.OnError(error); + } + + public void OnCompleted() + { + IObserver[] snapshot; + lock (_lock) { snapshot = _observers; } + foreach (var observer in snapshot) + observer.OnCompleted(); + } + + private void RemoveObserver(IObserver observer) + { + lock (_lock) + { + _observers = _observers.Where(o => !ReferenceEquals(o, observer)).ToArray(); + } + } + + public void Dispose() + { + lock (_lock) + { + _disposed = true; + _observers = []; + } + } +} diff --git a/src/Cocoar.Configuration/Reactive/Internal/SimpleSubject.cs b/src/Cocoar.Configuration/Reactive/Internal/SimpleSubject.cs index 141cf04..fbbe03b 100644 --- a/src/Cocoar.Configuration/Reactive/Internal/SimpleSubject.cs +++ b/src/Cocoar.Configuration/Reactive/Internal/SimpleSubject.cs @@ -1,69 +1,69 @@ -namespace Cocoar.Configuration.Reactive.Internal; - -internal sealed class SimpleSubject : IObservable, IObserver, IDisposable -{ -#if NET9_0_OR_GREATER - private readonly Lock _lock = new(); -#else - private readonly object _lock = new(); -#endif - private IObserver[] _observers = []; - private bool _disposed; - - public IDisposable Subscribe(IObserver observer) - { - ArgumentNullException.ThrowIfNull(observer); - lock (_lock) - { - ObjectDisposedException.ThrowIf(_disposed, this); -#if NET9_0_OR_GREATER - _observers = [.. _observers, observer]; -#else - _observers = _observers.Append(observer).ToArray(); -#endif - } - - return DisposableHelpers.Create(() => RemoveObserver(observer)); - } - - public void OnNext(T value) - { - IObserver[] snapshot; - lock (_lock) { snapshot = _observers; } - foreach (var observer in snapshot) - observer.OnNext(value); - } - - public void OnError(Exception error) - { - IObserver[] snapshot; - lock (_lock) { snapshot = _observers; } - foreach (var observer in snapshot) - observer.OnError(error); - } - - public void OnCompleted() - { - IObserver[] snapshot; - lock (_lock) { snapshot = _observers; } - foreach (var observer in snapshot) - observer.OnCompleted(); - } - - private void RemoveObserver(IObserver observer) - { - lock (_lock) - { - _observers = _observers.Where(o => !ReferenceEquals(o, observer)).ToArray(); - } - } - - public void Dispose() - { - lock (_lock) - { - _disposed = true; - _observers = []; - } - } -} +namespace Cocoar.Configuration.Reactive.Internal; + +internal sealed class SimpleSubject : IObservable, IObserver, IDisposable +{ +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + private IObserver[] _observers = []; + private bool _disposed; + + public IDisposable Subscribe(IObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + lock (_lock) + { + ObjectDisposedException.ThrowIf(_disposed, this); +#if NET9_0_OR_GREATER + _observers = [.. _observers, observer]; +#else + _observers = _observers.Append(observer).ToArray(); +#endif + } + + return DisposableHelpers.Create(() => RemoveObserver(observer)); + } + + public void OnNext(T value) + { + IObserver[] snapshot; + lock (_lock) { snapshot = _observers; } + foreach (var observer in snapshot) + observer.OnNext(value); + } + + public void OnError(Exception error) + { + IObserver[] snapshot; + lock (_lock) { snapshot = _observers; } + foreach (var observer in snapshot) + observer.OnError(error); + } + + public void OnCompleted() + { + IObserver[] snapshot; + lock (_lock) { snapshot = _observers; } + foreach (var observer in snapshot) + observer.OnCompleted(); + } + + private void RemoveObserver(IObserver observer) + { + lock (_lock) + { + _observers = _observers.Where(o => !ReferenceEquals(o, observer)).ToArray(); + } + } + + public void Dispose() + { + lock (_lock) + { + _disposed = true; + _observers = []; + } + } +} diff --git a/src/Cocoar.Configuration/Reactive/ReactiveConfigManager.cs b/src/Cocoar.Configuration/Reactive/ReactiveConfigManager.cs index 4413765..0c4b22d 100644 --- a/src/Cocoar.Configuration/Reactive/ReactiveConfigManager.cs +++ b/src/Cocoar.Configuration/Reactive/ReactiveConfigManager.cs @@ -1,106 +1,106 @@ -using System.Collections.Concurrent; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Infrastructure; -using Microsoft.Extensions.Logging; - -namespace Cocoar.Configuration.Reactive; - -internal static partial class ReactiveConfigManagerLog -{ - [LoggerMessage(EventId = 6000, Level = LogLevel.Information, Message = "Recreating dead observable for configuration type {Type}")] - public static partial void RecreatingDeadObservable(this ILogger logger, Type Type); - - [LoggerMessage(EventId = 6001, Level = LogLevel.Warning, Message = "Failed to get initial config for type {Type}, using default value")] - public static partial void GetInitialConfigFailed(this ILogger logger, Exception exception, Type Type); - - [LoggerMessage(EventId = 6006, Level = LogLevel.Debug, Message = "Created reactive config wrapper for type {Type}")] - public static partial void CreatedReactiveConfig(this ILogger logger, Type type); -} - -/// -/// Manages reactive configuration access using the MasterBackplane. -/// Provides type-safe projections of the configuration snapshot stream. -/// -internal sealed class ReactiveConfigManager : IDisposable -{ - private readonly ILogger _logger; - private readonly ExposureRegistry _bindingRegistry; - private readonly ConcurrentDictionary _reactiveConfigs = new(); - private MasterBackplane? _backplane; - private bool _disposed; - - public ReactiveConfigManager(ILogger logger, ExposureRegistry bindingRegistry) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _bindingRegistry = bindingRegistry ?? throw new ArgumentNullException(nameof(bindingRegistry)); - } - - /// - /// Sets the MasterBackplane for this manager. - /// Must be called before GetReactiveConfig. - /// - internal void SetBackplane(MasterBackplane backplane) - { - _backplane = backplane ?? throw new ArgumentNullException(nameof(backplane)); - } - - /// - /// Gets a reactive configuration wrapper for the specified type. - /// Uses the MasterBackplane's type projection for efficient change detection. - /// - public IReactiveConfig GetReactiveConfig(Func fallbackAccessor) where T : class - { - if (_backplane == null) - { - throw new InvalidOperationException("MasterBackplane not initialized. Ensure InitializeBackplane is called first."); - } - - var type = typeof(T); - return (IReactiveConfig)_reactiveConfigs.GetOrAdd(type, _ => - { - _logger.CreatedReactiveConfig(type); - return new BackplaneReactiveConfig(_backplane); - }); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - foreach (var config in _reactiveConfigs.Values) - { - if (config is IDisposable disposable) - { - try { disposable.Dispose(); } - catch { /* ignore */ } - } - } - - _reactiveConfigs.Clear(); - } - - /// - /// IReactiveConfig implementation that uses the MasterBackplane for values. - /// - private sealed class BackplaneReactiveConfig : IReactiveConfig, IDisposable where T : class - { - private readonly MasterBackplane _backplane; - private readonly IObservable _observable; - - public BackplaneReactiveConfig(MasterBackplane backplane) - { - _backplane = backplane; - _observable = backplane.GetTypeProjection(); - } - - public T CurrentValue => _backplane.GetConfig() ?? throw new InvalidOperationException($"No configuration available for type {typeof(T).Name}."); - - public IDisposable Subscribe(IObserver observer) => _observable.Subscribe(observer); - - public void Dispose() - { - // No resources to dispose - backplane and observable are shared - } - } -} +using System.Collections.Concurrent; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Infrastructure; +using Microsoft.Extensions.Logging; + +namespace Cocoar.Configuration.Reactive; + +internal static partial class ReactiveConfigManagerLog +{ + [LoggerMessage(EventId = 6000, Level = LogLevel.Information, Message = "Recreating dead observable for configuration type {Type}")] + public static partial void RecreatingDeadObservable(this ILogger logger, Type Type); + + [LoggerMessage(EventId = 6001, Level = LogLevel.Warning, Message = "Failed to get initial config for type {Type}, using default value")] + public static partial void GetInitialConfigFailed(this ILogger logger, Exception exception, Type Type); + + [LoggerMessage(EventId = 6006, Level = LogLevel.Debug, Message = "Created reactive config wrapper for type {Type}")] + public static partial void CreatedReactiveConfig(this ILogger logger, Type type); +} + +/// +/// Manages reactive configuration access using the MasterBackplane. +/// Provides type-safe projections of the configuration snapshot stream. +/// +internal sealed class ReactiveConfigManager : IDisposable +{ + private readonly ILogger _logger; + private readonly ExposureRegistry _bindingRegistry; + private readonly ConcurrentDictionary _reactiveConfigs = new(); + private MasterBackplane? _backplane; + private bool _disposed; + + public ReactiveConfigManager(ILogger logger, ExposureRegistry bindingRegistry) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _bindingRegistry = bindingRegistry ?? throw new ArgumentNullException(nameof(bindingRegistry)); + } + + /// + /// Sets the MasterBackplane for this manager. + /// Must be called before GetReactiveConfig. + /// + internal void SetBackplane(MasterBackplane backplane) + { + _backplane = backplane ?? throw new ArgumentNullException(nameof(backplane)); + } + + /// + /// Gets a reactive configuration wrapper for the specified type. + /// Uses the MasterBackplane's type projection for efficient change detection. + /// + public IReactiveConfig GetReactiveConfig(Func fallbackAccessor) where T : class + { + if (_backplane == null) + { + throw new InvalidOperationException("MasterBackplane not initialized. Ensure InitializeBackplane is called first."); + } + + var type = typeof(T); + return (IReactiveConfig)_reactiveConfigs.GetOrAdd(type, _ => + { + _logger.CreatedReactiveConfig(type); + return new BackplaneReactiveConfig(_backplane); + }); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + foreach (var config in _reactiveConfigs.Values) + { + if (config is IDisposable disposable) + { + try { disposable.Dispose(); } + catch { /* ignore */ } + } + } + + _reactiveConfigs.Clear(); + } + + /// + /// IReactiveConfig implementation that uses the MasterBackplane for values. + /// + private sealed class BackplaneReactiveConfig : IReactiveConfig, IDisposable where T : class + { + private readonly MasterBackplane _backplane; + private readonly IObservable _observable; + + public BackplaneReactiveConfig(MasterBackplane backplane) + { + _backplane = backplane; + _observable = backplane.GetTypeProjection(); + } + + public T CurrentValue => _backplane.GetConfig() ?? throw new InvalidOperationException($"No configuration available for type {typeof(T).Name}."); + + public IDisposable Subscribe(IObserver observer) => _observable.Subscribe(observer); + + public void Dispose() + { + // No resources to dispose - backplane and observable are shared + } + } +} diff --git a/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs b/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs index e685f25..524d30a 100644 --- a/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs +++ b/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs @@ -1,290 +1,290 @@ -using Microsoft.Extensions.Logging; -using System.Reflection; -using Cocoar.Configuration.Rules; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Infrastructure; - -namespace Cocoar.Configuration.Reactive; - -internal static partial class ReactiveConfigurationFactoryLog -{ - [LoggerMessage(EventId = 6400, Level = LogLevel.Warning, Message = "Failed to locate GetReactiveConfig for type {Type}")] - public static partial void MissingGetReactiveConfig(this ILogger logger, Type Type); - - [LoggerMessage(EventId = 6401, Level = LogLevel.Warning, Message = "Failed to locate GetConfig for type {Type}")] - public static partial void MissingGetConfig(this ILogger logger, Type Type); - - [LoggerMessage(EventId = 6402, Level = LogLevel.Warning, Message = "Failed to prime reactive configuration for tuple element {Type}")] - public static partial void PrimeReactiveConfigFailed(this ILogger logger, Exception exception, Type Type); - - [LoggerMessage(EventId = 6403, Level = LogLevel.Warning, Message = "Type {Type} is not a class, skipping reactive priming")] - public static partial void SkippingNonClassType(this ILogger logger, Type Type); -} - -// `accessor` + backplaneAccessor (instead of a concrete ConfigManager) so a tenant pipeline builds its -// reactive configs over ITS OWN accessor/backplane (ADR-005 §7). The global pipeline passes the owning -// ConfigManager as the accessor and that manager's backplane — byte-identical to before. -// NOTE: this field is intentionally NOT named `configAccessor` — several methods below take a local -// `Func configAccessor` (the value closure), which would shadow it and silently mis-bind the delegate. -internal class ReactiveConfigurationFactory( - ReactiveConfigManager reactiveConfigManager, - List rules, - ILogger logger, - IConfigurationAccessor accessor, - Func backplaneAccessor, - ExposureRegistry bindingRegistry) -{ - private static readonly MethodInfo _getReactiveConfigMethod = - typeof(ReactiveConfigManager).GetMethod(nameof(ReactiveConfigManager.GetReactiveConfig))!; - private static readonly MethodInfo _getConfigMethod = - typeof(IConfigurationAccessor).GetMethod(nameof(IConfigurationAccessor.GetConfig), Type.EmptyTypes)!; - - public IReactiveConfig GetReactiveConfig(Func configAccessor) - { - var t = typeof(T); - if (IsValueTupleType(t)) - { - return (IReactiveConfig)CreateTupleReactiveConfig(t); - } - - // In the GLOBAL pipeline (no tenant), a type whose EVERY rule is .TenantScoped() has no global value — - // its rules skip when there is no tenant. Surface that precisely (point at the per-tenant API) instead - // of the generic "No configuration available" thrown later by the backplane reader. Mirrors the tuple - // guard in CreateTupleReactiveConfig and the sync guard in ConfigurationAccessor.NoConfigurationFor. - if (string.IsNullOrEmpty(accessor.Tenant)) - { - var concrete = t; - if (t.IsInterface && bindingRegistry.TryGetConcreteType(t, out var ct)) - { - concrete = ct; - } - - var typeRules = rules.Where(r => r.ConcreteType == concrete).ToList(); - if (typeRules.Count > 0 && typeRules.All(r => r.Options?.TenantScoped == true)) - { - throw new InvalidOperationException( - $"Cannot create IReactiveConfig<{t.Name}> in the global pipeline: type {t.Name} has only " + - $".TenantScoped() rules, so it has no global value. " + - $"Use GetReactiveConfigForTenant<{t.Name}>(tenantId) instead."); - } - } - - // For interfaces, look up the concrete type from the binding registry - if (t.IsInterface) - { - if (!bindingRegistry.TryGetConcreteType(t, out var concreteType)) - { - throw new InvalidOperationException( - $"GetReactiveConfig<{t.Name}> requires the interface to be exposed via " + - $"setup.ConcreteType().ExposeAs<{t.Name}>(). No concrete type mapping found."); - } - - // Use the concrete type for the reactive config, but wrap the accessor - return CreateReactiveConfigForConcreteType(concreteType, configAccessor); - } - - // For non-tuple, non-interface types, must be a class for the backplane - if (!t.IsClass) - { - throw new InvalidOperationException( - $"GetReactiveConfig<{t.Name}> is only supported for class types, interfaces (with ExposeAs), or ValueTuple types. " + - $"Configuration types should be classes, not structs."); - } - - // Use reflection to call the generic method with class constraint - var method = _getReactiveConfigMethod.MakeGenericMethod(t); - - var funcType = typeof(Func<>).MakeGenericType(t); - return (IReactiveConfig)method.Invoke(reactiveConfigManager, [configAccessor])!; - } - - /// - /// Creates a reactive config for an interface type by using the concrete type's reactive config. - /// The concrete type implements the interface, so we can cast safely. - /// - private IReactiveConfig CreateReactiveConfigForConcreteType(Type concreteType, Func configAccessor) - { - // Create an accessor for the concrete type that returns TInterface (which the concrete type implements) - var concreteAccessorMethod = _getConfigMethod.MakeGenericMethod(concreteType); - - var concreteFuncType = typeof(Func<>).MakeGenericType(concreteType); - var concreteAccessor = Delegate.CreateDelegate(concreteFuncType, accessor, concreteAccessorMethod); - - // Get the reactive config for the concrete type - var reactiveMethod = _getReactiveConfigMethod.MakeGenericMethod(concreteType); - - var concreteReactiveConfig = reactiveMethod.Invoke(reactiveConfigManager, [concreteAccessor])!; - - // Wrap the concrete reactive config in an interface adapter - var adapterType = typeof(InterfaceReactiveConfigAdapter<,>).MakeGenericType(typeof(TInterface), concreteType); - return (IReactiveConfig)Activator.CreateInstance(adapterType, concreteReactiveConfig)!; - } - - private static bool IsValueTupleType(Type t) => - t is { IsValueType: true, FullName: not null } && t.FullName.StartsWith("System.ValueTuple", StringComparison.Ordinal); - - private object CreateTupleReactiveConfig(Type tupleType) - { - var elementTypes = FlattenTuple(tupleType).ToArray(); - if (elementTypes.Length == 0) - { - throw new InvalidOperationException($"Type {tupleType.Name} is not a non-empty ValueTuple"); - } - - var allowedConcrete = new HashSet(rules.Select(r => r.ConcreteType)); - - var invalid = new List(); - - foreach (var et in elementTypes) - { - if (et.IsInterface) - { - if (!bindingRegistry.TryGetConcreteType(et, out _)) - { - invalid.Add(et.Name + " (interface not exposed)"); - } - } - else - { - if (!allowedConcrete.Contains(et)) - { - invalid.Add(et.Name + " (not a configured type)"); - } - } - } - - if (invalid.Count > 0) - { - throw new InvalidOperationException($"Cannot create IReactiveConfig<{tupleType.Name}>. The following tuple element types are not configured/exposed: {string.Join(", ", invalid)}"); - } - - // In the GLOBAL pipeline (no tenant), a type whose EVERY rule is .TenantScoped() has no global value — - // its rules skip when there is no tenant. Surface that precisely instead of the generic "Missing - // configuration" from the tuple ctor; the fix is to read the tuple per tenant. (Mixed-scope tuples are - // otherwise fully supported: each element comes from this pipeline's snapshot.) - if (string.IsNullOrEmpty(accessor.Tenant)) - { - var tenantScopedOnly = new List(); - foreach (var et in elementTypes.Distinct()) - { - var concreteEt = et; - if (et.IsInterface && bindingRegistry.TryGetConcreteType(et, out var ct)) - { - concreteEt = ct; - } - - var typeRules = rules.Where(r => r.ConcreteType == concreteEt).ToList(); - if (typeRules.Count > 0 && typeRules.All(r => r.Options?.TenantScoped == true)) - { - tenantScopedOnly.Add(et.Name); - } - } - - if (tenantScopedOnly.Count > 0) - { - throw new InvalidOperationException( - $"Cannot create IReactiveConfig<{tupleType.Name}> in the global pipeline: type(s) " + - $"{string.Join(", ", tenantScopedOnly)} have only .TenantScoped() rules, so they have no " + - $"global value. Use GetReactiveConfigForTenant<{tupleType.Name}>(tenantId) instead."); - } - } - - // Prime each distinct element type's reactive config - foreach (var et in elementTypes.Distinct()) - { - // For interfaces, resolve to concrete type for priming - var typeToPrime = et; - if (et.IsInterface) - { - if (bindingRegistry.TryGetConcreteType(et, out var concreteType)) - { - typeToPrime = concreteType; - } - else - { - logger.SkippingNonClassType(et); - continue; - } - } - else if (!et.IsClass) - { - // Skip non-class, non-interface types (structs) - logger.SkippingNonClassType(et); - continue; - } - - try - { - var reactiveMethod = _getReactiveConfigMethod.MakeGenericMethod(typeToPrime); - var accessorMethod = _getConfigMethod.MakeGenericMethod(typeToPrime); - - var funcType = typeof(Func<>).MakeGenericType(typeToPrime); - var accessorDelegate = Delegate.CreateDelegate(funcType, accessor, accessorMethod); - - _ = reactiveMethod.Invoke(reactiveConfigManager, [accessorDelegate]); - } - catch (Exception ex) - { - logger.PrimeReactiveConfigFailed(ex, typeToPrime); - } - } - - var generic = typeof(ReactiveTupleConfig<>).MakeGenericType(tupleType); - return Activator.CreateInstance(generic, accessor, backplaneAccessor(), reactiveConfigManager, logger, bindingRegistry)!; - } - - private static IEnumerable FlattenTuple(Type t) - { - if (!(t is { IsValueType: true, FullName: not null } && t.FullName.StartsWith("System.ValueTuple", StringComparison.Ordinal))) - { - yield break; - } - - var fields = t.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); - foreach (var f in fields) - { - if (f is { Name: "Rest", FieldType.FullName: not null } && f.FieldType.FullName.StartsWith("System.ValueTuple", StringComparison.Ordinal)) - { - foreach (var inner in FlattenTuple(f.FieldType)) - { - yield return inner; - } - } - else - { - yield return f.FieldType; - } - } - } -} - -/// -/// Adapts an IReactiveConfig of a concrete type to an IReactiveConfig of an interface type. -/// Used when injecting IReactiveConfig<IInterface> where IInterface is exposed via ExposeAs. -/// -internal sealed class InterfaceReactiveConfigAdapter : IReactiveConfig - where TConcrete : class, TInterface -{ - private readonly IReactiveConfig _inner; - - public InterfaceReactiveConfigAdapter(IReactiveConfig inner) - { - _inner = inner; - } - - public TInterface CurrentValue => _inner.CurrentValue; - - public IDisposable Subscribe(IObserver observer) - { - // Adapt the observer to accept TConcrete (which is assignable to TInterface) - return _inner.Subscribe(new CastingObserver(observer)); - } - - private sealed class CastingObserver(IObserver inner) : IObserver - where TIn : TOut - { - public void OnCompleted() => inner.OnCompleted(); - public void OnError(Exception error) => inner.OnError(error); - public void OnNext(TIn value) => inner.OnNext(value); - } -} +using Microsoft.Extensions.Logging; +using System.Reflection; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Infrastructure; + +namespace Cocoar.Configuration.Reactive; + +internal static partial class ReactiveConfigurationFactoryLog +{ + [LoggerMessage(EventId = 6400, Level = LogLevel.Warning, Message = "Failed to locate GetReactiveConfig for type {Type}")] + public static partial void MissingGetReactiveConfig(this ILogger logger, Type Type); + + [LoggerMessage(EventId = 6401, Level = LogLevel.Warning, Message = "Failed to locate GetConfig for type {Type}")] + public static partial void MissingGetConfig(this ILogger logger, Type Type); + + [LoggerMessage(EventId = 6402, Level = LogLevel.Warning, Message = "Failed to prime reactive configuration for tuple element {Type}")] + public static partial void PrimeReactiveConfigFailed(this ILogger logger, Exception exception, Type Type); + + [LoggerMessage(EventId = 6403, Level = LogLevel.Warning, Message = "Type {Type} is not a class, skipping reactive priming")] + public static partial void SkippingNonClassType(this ILogger logger, Type Type); +} + +// `accessor` + backplaneAccessor (instead of a concrete ConfigManager) so a tenant pipeline builds its +// reactive configs over ITS OWN accessor/backplane (ADR-005 §7). The global pipeline passes the owning +// ConfigManager as the accessor and that manager's backplane — byte-identical to before. +// NOTE: this field is intentionally NOT named `configAccessor` — several methods below take a local +// `Func configAccessor` (the value closure), which would shadow it and silently mis-bind the delegate. +internal class ReactiveConfigurationFactory( + ReactiveConfigManager reactiveConfigManager, + List rules, + ILogger logger, + IConfigurationAccessor accessor, + Func backplaneAccessor, + ExposureRegistry bindingRegistry) +{ + private static readonly MethodInfo _getReactiveConfigMethod = + typeof(ReactiveConfigManager).GetMethod(nameof(ReactiveConfigManager.GetReactiveConfig))!; + private static readonly MethodInfo _getConfigMethod = + typeof(IConfigurationAccessor).GetMethod(nameof(IConfigurationAccessor.GetConfig), Type.EmptyTypes)!; + + public IReactiveConfig GetReactiveConfig(Func configAccessor) + { + var t = typeof(T); + if (IsValueTupleType(t)) + { + return (IReactiveConfig)CreateTupleReactiveConfig(t); + } + + // In the GLOBAL pipeline (no tenant), a type whose EVERY rule is .TenantScoped() has no global value — + // its rules skip when there is no tenant. Surface that precisely (point at the per-tenant API) instead + // of the generic "No configuration available" thrown later by the backplane reader. Mirrors the tuple + // guard in CreateTupleReactiveConfig and the sync guard in ConfigurationAccessor.NoConfigurationFor. + if (string.IsNullOrEmpty(accessor.Tenant)) + { + var concrete = t; + if (t.IsInterface && bindingRegistry.TryGetConcreteType(t, out var ct)) + { + concrete = ct; + } + + var typeRules = rules.Where(r => r.ConcreteType == concrete).ToList(); + if (typeRules.Count > 0 && typeRules.All(r => r.Options?.TenantScoped == true)) + { + throw new InvalidOperationException( + $"Cannot create IReactiveConfig<{t.Name}> in the global pipeline: type {t.Name} has only " + + $".TenantScoped() rules, so it has no global value. " + + $"Use GetReactiveConfigForTenant<{t.Name}>(tenantId) instead."); + } + } + + // For interfaces, look up the concrete type from the binding registry + if (t.IsInterface) + { + if (!bindingRegistry.TryGetConcreteType(t, out var concreteType)) + { + throw new InvalidOperationException( + $"GetReactiveConfig<{t.Name}> requires the interface to be exposed via " + + $"setup.ConcreteType().ExposeAs<{t.Name}>(). No concrete type mapping found."); + } + + // Use the concrete type for the reactive config, but wrap the accessor + return CreateReactiveConfigForConcreteType(concreteType, configAccessor); + } + + // For non-tuple, non-interface types, must be a class for the backplane + if (!t.IsClass) + { + throw new InvalidOperationException( + $"GetReactiveConfig<{t.Name}> is only supported for class types, interfaces (with ExposeAs), or ValueTuple types. " + + $"Configuration types should be classes, not structs."); + } + + // Use reflection to call the generic method with class constraint + var method = _getReactiveConfigMethod.MakeGenericMethod(t); + + var funcType = typeof(Func<>).MakeGenericType(t); + return (IReactiveConfig)method.Invoke(reactiveConfigManager, [configAccessor])!; + } + + /// + /// Creates a reactive config for an interface type by using the concrete type's reactive config. + /// The concrete type implements the interface, so we can cast safely. + /// + private IReactiveConfig CreateReactiveConfigForConcreteType(Type concreteType, Func configAccessor) + { + // Create an accessor for the concrete type that returns TInterface (which the concrete type implements) + var concreteAccessorMethod = _getConfigMethod.MakeGenericMethod(concreteType); + + var concreteFuncType = typeof(Func<>).MakeGenericType(concreteType); + var concreteAccessor = Delegate.CreateDelegate(concreteFuncType, accessor, concreteAccessorMethod); + + // Get the reactive config for the concrete type + var reactiveMethod = _getReactiveConfigMethod.MakeGenericMethod(concreteType); + + var concreteReactiveConfig = reactiveMethod.Invoke(reactiveConfigManager, [concreteAccessor])!; + + // Wrap the concrete reactive config in an interface adapter + var adapterType = typeof(InterfaceReactiveConfigAdapter<,>).MakeGenericType(typeof(TInterface), concreteType); + return (IReactiveConfig)Activator.CreateInstance(adapterType, concreteReactiveConfig)!; + } + + private static bool IsValueTupleType(Type t) => + t is { IsValueType: true, FullName: not null } && t.FullName.StartsWith("System.ValueTuple", StringComparison.Ordinal); + + private object CreateTupleReactiveConfig(Type tupleType) + { + var elementTypes = FlattenTuple(tupleType).ToArray(); + if (elementTypes.Length == 0) + { + throw new InvalidOperationException($"Type {tupleType.Name} is not a non-empty ValueTuple"); + } + + var allowedConcrete = new HashSet(rules.Select(r => r.ConcreteType)); + + var invalid = new List(); + + foreach (var et in elementTypes) + { + if (et.IsInterface) + { + if (!bindingRegistry.TryGetConcreteType(et, out _)) + { + invalid.Add(et.Name + " (interface not exposed)"); + } + } + else + { + if (!allowedConcrete.Contains(et)) + { + invalid.Add(et.Name + " (not a configured type)"); + } + } + } + + if (invalid.Count > 0) + { + throw new InvalidOperationException($"Cannot create IReactiveConfig<{tupleType.Name}>. The following tuple element types are not configured/exposed: {string.Join(", ", invalid)}"); + } + + // In the GLOBAL pipeline (no tenant), a type whose EVERY rule is .TenantScoped() has no global value — + // its rules skip when there is no tenant. Surface that precisely instead of the generic "Missing + // configuration" from the tuple ctor; the fix is to read the tuple per tenant. (Mixed-scope tuples are + // otherwise fully supported: each element comes from this pipeline's snapshot.) + if (string.IsNullOrEmpty(accessor.Tenant)) + { + var tenantScopedOnly = new List(); + foreach (var et in elementTypes.Distinct()) + { + var concreteEt = et; + if (et.IsInterface && bindingRegistry.TryGetConcreteType(et, out var ct)) + { + concreteEt = ct; + } + + var typeRules = rules.Where(r => r.ConcreteType == concreteEt).ToList(); + if (typeRules.Count > 0 && typeRules.All(r => r.Options?.TenantScoped == true)) + { + tenantScopedOnly.Add(et.Name); + } + } + + if (tenantScopedOnly.Count > 0) + { + throw new InvalidOperationException( + $"Cannot create IReactiveConfig<{tupleType.Name}> in the global pipeline: type(s) " + + $"{string.Join(", ", tenantScopedOnly)} have only .TenantScoped() rules, so they have no " + + $"global value. Use GetReactiveConfigForTenant<{tupleType.Name}>(tenantId) instead."); + } + } + + // Prime each distinct element type's reactive config + foreach (var et in elementTypes.Distinct()) + { + // For interfaces, resolve to concrete type for priming + var typeToPrime = et; + if (et.IsInterface) + { + if (bindingRegistry.TryGetConcreteType(et, out var concreteType)) + { + typeToPrime = concreteType; + } + else + { + logger.SkippingNonClassType(et); + continue; + } + } + else if (!et.IsClass) + { + // Skip non-class, non-interface types (structs) + logger.SkippingNonClassType(et); + continue; + } + + try + { + var reactiveMethod = _getReactiveConfigMethod.MakeGenericMethod(typeToPrime); + var accessorMethod = _getConfigMethod.MakeGenericMethod(typeToPrime); + + var funcType = typeof(Func<>).MakeGenericType(typeToPrime); + var accessorDelegate = Delegate.CreateDelegate(funcType, accessor, accessorMethod); + + _ = reactiveMethod.Invoke(reactiveConfigManager, [accessorDelegate]); + } + catch (Exception ex) + { + logger.PrimeReactiveConfigFailed(ex, typeToPrime); + } + } + + var generic = typeof(ReactiveTupleConfig<>).MakeGenericType(tupleType); + return Activator.CreateInstance(generic, accessor, backplaneAccessor(), reactiveConfigManager, logger, bindingRegistry)!; + } + + private static IEnumerable FlattenTuple(Type t) + { + if (!(t is { IsValueType: true, FullName: not null } && t.FullName.StartsWith("System.ValueTuple", StringComparison.Ordinal))) + { + yield break; + } + + var fields = t.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + foreach (var f in fields) + { + if (f is { Name: "Rest", FieldType.FullName: not null } && f.FieldType.FullName.StartsWith("System.ValueTuple", StringComparison.Ordinal)) + { + foreach (var inner in FlattenTuple(f.FieldType)) + { + yield return inner; + } + } + else + { + yield return f.FieldType; + } + } + } +} + +/// +/// Adapts an IReactiveConfig of a concrete type to an IReactiveConfig of an interface type. +/// Used when injecting IReactiveConfig<IInterface> where IInterface is exposed via ExposeAs. +/// +internal sealed class InterfaceReactiveConfigAdapter : IReactiveConfig + where TConcrete : class, TInterface +{ + private readonly IReactiveConfig _inner; + + public InterfaceReactiveConfigAdapter(IReactiveConfig inner) + { + _inner = inner; + } + + public TInterface CurrentValue => _inner.CurrentValue; + + public IDisposable Subscribe(IObserver observer) + { + // Adapt the observer to accept TConcrete (which is assignable to TInterface) + return _inner.Subscribe(new CastingObserver(observer)); + } + + private sealed class CastingObserver(IObserver inner) : IObserver + where TIn : TOut + { + public void OnCompleted() => inner.OnCompleted(); + public void OnError(Exception error) => inner.OnError(error); + public void OnNext(TIn value) => inner.OnNext(value); + } +} diff --git a/src/Cocoar.Configuration/Reactive/ReactiveTupleConfig.cs b/src/Cocoar.Configuration/Reactive/ReactiveTupleConfig.cs index 9c9d447..7160429 100644 --- a/src/Cocoar.Configuration/Reactive/ReactiveTupleConfig.cs +++ b/src/Cocoar.Configuration/Reactive/ReactiveTupleConfig.cs @@ -1,313 +1,313 @@ -using System.Collections.Concurrent; -using System.Linq.Expressions; -using System.Reflection; -using Microsoft.Extensions.Logging; -using Cocoar.Configuration.Core; - -namespace Cocoar.Configuration.Reactive; - -internal static partial class ReactiveTupleConfigLog -{ - [LoggerMessage(EventId = 6100, Level = LogLevel.Warning, Message = "Tuple reactive config stream error ignored to keep alive for {TupleType}")] - public static partial void TupleStreamErrorIgnored(this ILogger logger, Exception exception, string TupleType); - - [LoggerMessage(EventId = 6101, Level = LogLevel.Warning, Message = "Failed to build CurrentValue for tuple {TupleType}")] - public static partial void BuildCurrentValueFailed(this ILogger logger, Exception exception, string TupleType); - - [LoggerMessage(EventId = 6103, Level = LogLevel.Warning, Message = "Failed building tuple emission for {TupleType}")] - public static partial void BuildTupleEmissionFailed(this ILogger logger, Exception exception, string TupleType); -} - -/// -/// Provides reactive tuple configuration using the MasterBackplane. -/// Tuple updates are atomic - all elements update together when the snapshot changes. -/// -internal sealed class ReactiveTupleConfig : IReactiveConfig, IDisposable where TTuple : struct -{ - private readonly ILogger _logger; - private readonly IDisposable _subscription; - private readonly IObservable _observable; - private readonly Func _builder; - private readonly Type[] _elementTypes; - private readonly IConfigurationAccessor _configAccessor; - private readonly MasterBackplane _backplane; - private readonly Infrastructure.ExposureRegistry? _bindingRegistry; - - // Loosened from a concrete ConfigManager to (accessor, backplane) so a tenant pipeline can build a tuple - // reactive over ITS OWN accessor + backplane (ADR-005 §7). The global pipeline still binds the owning - // ConfigManager as the accessor and that manager's backplane — byte-identical to before. - public ReactiveTupleConfig( - IConfigurationAccessor configAccessor, - MasterBackplane backplane, - ReactiveConfigManager reactiveConfigManager, - ILogger logger, - Infrastructure.ExposureRegistry? bindingRegistry = null) - { - _configAccessor = configAccessor; - _backplane = backplane; - _logger = logger; - _bindingRegistry = bindingRegistry; - (_elementTypes, _builder) = TupleShapeCache.Get(typeof(TTuple)); - if (_elementTypes.Length == 0) - { - throw new InvalidOperationException($"{typeof(TTuple).Name} is not a ValueTuple with elements."); - } - - // Validate all elements are present - var missing = new List(); - for (var i = 0; i < _elementTypes.Length; i++) - { - var val = configAccessor.GetConfig(_elementTypes[i]); - if (val is null) - { - missing.Add(_elementTypes[i].Name); - } - } - - if (missing.Count > 0) - { - throw new InvalidOperationException( - $"Cannot create IReactiveConfig<{typeof(TTuple).Name}>. Missing configuration for: {string.Join(", ", missing)}"); - } - - // Create observable from the backplane's snapshot stream - // This provides atomicity - all tuple elements update together - // Source (MasterBackplane) never errors, so no Catch/Retry needed - _observable = CreateTupleObservable(backplane); - - _subscription = _observable.Subscribe(_ => { }, _ => { }); - } - - public TTuple CurrentValue - { - get - { - try - { - var values = new object?[_elementTypes.Length]; - for (var i = 0; i < _elementTypes.Length; i++) - { - values[i] = _configAccessor.GetConfig(_elementTypes[i]); - } - return (TTuple)_builder(values); - } - catch (Exception ex) - { - _logger.BuildCurrentValueFailed(ex, typeof(TTuple).Name); - return default; - } - } - } - - public IDisposable Subscribe(IObserver observer) => _observable.Subscribe(observer); - - private IObservable CreateTupleObservable(MasterBackplane backplane) - { - // Access the backplane through the state (after initialization) - // The backplane provides atomic updates for all types - return ObservableHelpers.Create(observer => - { - TTuple? previousTuple = null; - - // Subscribe to the backplane's snapshot stream - var snapshotStream = backplane.SnapshotStream; - - return snapshotStream.Subscribe(snapshot => - { - try - { - var values = new object?[_elementTypes.Length]; - var allPresent = true; - - for (var i = 0; i < _elementTypes.Length; i++) - { - values[i] = GetConfigFromSnapshot(snapshot, _elementTypes[i]); - if (values[i] == null) - { - allPresent = false; - break; - } - } - - if (!allPresent) - { - return; // Skip if any element is missing - } - - var tuple = (TTuple)_builder(values); - - // Only emit if any element changed (using reference equality) - var changed = previousTuple == null; - if (!changed && previousTuple.HasValue) - { - var prevValues = ExtractTupleValues(previousTuple.Value); - for (var i = 0; i < _elementTypes.Length; i++) - { - if (!ReferenceEquals(prevValues[i], values[i])) - { - changed = true; - break; - } - } - } - - if (changed) - { - previousTuple = tuple; - observer.OnNext(tuple); - } - } - catch (Exception ex) - { - _logger.BuildTupleEmissionFailed(ex, typeof(TTuple).Name); - } - }, observer.OnError, observer.OnCompleted); - }); - } - - /// - /// Gets a config from the snapshot, handling interface-to-concrete type resolution. - /// - private object? GetConfigFromSnapshot(ConfigSnapshot snapshot, Type type) - { - // Try direct lookup first - var result = snapshot.GetConfig(type); - if (result != null) - { - return result; - } - - // Try interface-to-concrete mapping - if (type.IsInterface && _bindingRegistry != null && - _bindingRegistry.TryGetConcreteType(type, out var concreteType)) - { - return snapshot.GetConfig(concreteType); - } - - return null; - } - - private object?[] ExtractTupleValues(TTuple tuple) - { - var values = new object?[_elementTypes.Length]; - var fields = typeof(TTuple).GetFields(BindingFlags.Public | BindingFlags.Instance); - - var index = 0; - ExtractRecursive(tuple, fields, ref index, values); - return values; - } - - private static void ExtractRecursive(object tuple, FieldInfo[] fields, ref int index, object?[] values) - { - foreach (var field in fields) - { - if (field.Name == "Rest" && field.FieldType.FullName?.StartsWith("System.ValueTuple", StringComparison.Ordinal) == true) - { - var rest = field.GetValue(tuple); - if (rest != null) - { - var restFields = field.FieldType.GetFields(BindingFlags.Public | BindingFlags.Instance); - ExtractRecursive(rest, restFields, ref index, values); - } - } - else - { - values[index++] = field.GetValue(tuple); - } - } - } - - public void Dispose() => _subscription.Dispose(); -} - -/// -/// Caches flattened element type arrays and compiled tuple builder delegates. -/// Handles nested ValueTuple (Rest) expansion. -/// -internal static class TupleShapeCache -{ - private static readonly ConcurrentDictionary Builder)> _cache = new(); - - public static (Type[] Elements, Func Builder) Get(Type tupleType) - { - return _cache.GetOrAdd(tupleType, t => - { - var elements = Flatten(t).ToArray(); - var builder = CompileBuilder(elements); - return (elements, builder); - }); - } - - private static IEnumerable Flatten(Type t) - { - if (!IsValueTuple(t)) - { - yield break; - } - - var fields = t.GetFields(BindingFlags.Public | BindingFlags.Instance); - foreach (var f in fields) - { - if (f.Name == "Rest" && IsValueTuple(f.FieldType)) - { - foreach (var nested in Flatten(f.FieldType)) - { - yield return nested; - } - } - else - { - yield return f.FieldType; - } - } - } - - private static bool IsValueTuple(Type t) => t is { IsValueType: true, FullName: not null } && t.FullName.StartsWith("System.ValueTuple", StringComparison.Ordinal); - - private static Func CompileBuilder(Type[] elements) - { - var param = Expression.Parameter(typeof(object?[]), "arr"); - - var body = Build(0); - var lambda = Expression.Lambda>(Expression.Convert(body, typeof(object)), param); - return lambda.Compile(); - - Expression Build(int start) - { - var remaining = elements.Length - start; - if (remaining <= 7) - { - var ctorTypes = elements.Skip(start).Take(remaining).ToArray(); - var ctors = GetTupleType(remaining, ctorTypes); - var args = ctorTypes.Select((t, i) => Expression.Convert(Expression.ArrayIndex(param, Expression.Constant(start + i)), t)); - return Expression.New(ctors.GetConstructors()[0], args); - } - else - { - var headTypes = elements.Skip(start).Take(7).ToArray(); - var restExpr = Build(start + 7); - var tupleTypeHead = GetTupleType(8, headTypes.Concat([restExpr.Type]).ToArray()); - var args = headTypes.Select((t, i) => Expression.Convert(Expression.ArrayIndex(param, Expression.Constant(start + i)), t)) - .Concat([restExpr]); - return Expression.New(tupleTypeHead.GetConstructors()[0], args); - } - } - } - - private static Type GetTupleType(int count, Type[] types) - { - return count switch - { - <= 0 => throw new ArgumentOutOfRangeException(nameof(count)), - 1 => typeof(ValueTuple<>).MakeGenericType(types), - 2 => typeof(ValueTuple<,>).MakeGenericType(types), - 3 => typeof(ValueTuple<,,>).MakeGenericType(types), - 4 => typeof(ValueTuple<,,,>).MakeGenericType(types), - 5 => typeof(ValueTuple<,,,,>).MakeGenericType(types), - 6 => typeof(ValueTuple<,,,,,>).MakeGenericType(types), - 7 => typeof(ValueTuple<,,,,,,>).MakeGenericType(types), - 8 => typeof(ValueTuple<,,,,,,,>).MakeGenericType(types), - _ => throw new NotSupportedException("Unsupported tuple arity segment") - }; - } -} +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.Extensions.Logging; +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.Reactive; + +internal static partial class ReactiveTupleConfigLog +{ + [LoggerMessage(EventId = 6100, Level = LogLevel.Warning, Message = "Tuple reactive config stream error ignored to keep alive for {TupleType}")] + public static partial void TupleStreamErrorIgnored(this ILogger logger, Exception exception, string TupleType); + + [LoggerMessage(EventId = 6101, Level = LogLevel.Warning, Message = "Failed to build CurrentValue for tuple {TupleType}")] + public static partial void BuildCurrentValueFailed(this ILogger logger, Exception exception, string TupleType); + + [LoggerMessage(EventId = 6103, Level = LogLevel.Warning, Message = "Failed building tuple emission for {TupleType}")] + public static partial void BuildTupleEmissionFailed(this ILogger logger, Exception exception, string TupleType); +} + +/// +/// Provides reactive tuple configuration using the MasterBackplane. +/// Tuple updates are atomic - all elements update together when the snapshot changes. +/// +internal sealed class ReactiveTupleConfig : IReactiveConfig, IDisposable where TTuple : struct +{ + private readonly ILogger _logger; + private readonly IDisposable _subscription; + private readonly IObservable _observable; + private readonly Func _builder; + private readonly Type[] _elementTypes; + private readonly IConfigurationAccessor _configAccessor; + private readonly MasterBackplane _backplane; + private readonly Infrastructure.ExposureRegistry? _bindingRegistry; + + // Loosened from a concrete ConfigManager to (accessor, backplane) so a tenant pipeline can build a tuple + // reactive over ITS OWN accessor + backplane (ADR-005 §7). The global pipeline still binds the owning + // ConfigManager as the accessor and that manager's backplane — byte-identical to before. + public ReactiveTupleConfig( + IConfigurationAccessor configAccessor, + MasterBackplane backplane, + ReactiveConfigManager reactiveConfigManager, + ILogger logger, + Infrastructure.ExposureRegistry? bindingRegistry = null) + { + _configAccessor = configAccessor; + _backplane = backplane; + _logger = logger; + _bindingRegistry = bindingRegistry; + (_elementTypes, _builder) = TupleShapeCache.Get(typeof(TTuple)); + if (_elementTypes.Length == 0) + { + throw new InvalidOperationException($"{typeof(TTuple).Name} is not a ValueTuple with elements."); + } + + // Validate all elements are present + var missing = new List(); + for (var i = 0; i < _elementTypes.Length; i++) + { + var val = configAccessor.GetConfig(_elementTypes[i]); + if (val is null) + { + missing.Add(_elementTypes[i].Name); + } + } + + if (missing.Count > 0) + { + throw new InvalidOperationException( + $"Cannot create IReactiveConfig<{typeof(TTuple).Name}>. Missing configuration for: {string.Join(", ", missing)}"); + } + + // Create observable from the backplane's snapshot stream + // This provides atomicity - all tuple elements update together + // Source (MasterBackplane) never errors, so no Catch/Retry needed + _observable = CreateTupleObservable(backplane); + + _subscription = _observable.Subscribe(_ => { }, _ => { }); + } + + public TTuple CurrentValue + { + get + { + try + { + var values = new object?[_elementTypes.Length]; + for (var i = 0; i < _elementTypes.Length; i++) + { + values[i] = _configAccessor.GetConfig(_elementTypes[i]); + } + return (TTuple)_builder(values); + } + catch (Exception ex) + { + _logger.BuildCurrentValueFailed(ex, typeof(TTuple).Name); + return default; + } + } + } + + public IDisposable Subscribe(IObserver observer) => _observable.Subscribe(observer); + + private IObservable CreateTupleObservable(MasterBackplane backplane) + { + // Access the backplane through the state (after initialization) + // The backplane provides atomic updates for all types + return ObservableHelpers.Create(observer => + { + TTuple? previousTuple = null; + + // Subscribe to the backplane's snapshot stream + var snapshotStream = backplane.SnapshotStream; + + return snapshotStream.Subscribe(snapshot => + { + try + { + var values = new object?[_elementTypes.Length]; + var allPresent = true; + + for (var i = 0; i < _elementTypes.Length; i++) + { + values[i] = GetConfigFromSnapshot(snapshot, _elementTypes[i]); + if (values[i] == null) + { + allPresent = false; + break; + } + } + + if (!allPresent) + { + return; // Skip if any element is missing + } + + var tuple = (TTuple)_builder(values); + + // Only emit if any element changed (using reference equality) + var changed = previousTuple == null; + if (!changed && previousTuple.HasValue) + { + var prevValues = ExtractTupleValues(previousTuple.Value); + for (var i = 0; i < _elementTypes.Length; i++) + { + if (!ReferenceEquals(prevValues[i], values[i])) + { + changed = true; + break; + } + } + } + + if (changed) + { + previousTuple = tuple; + observer.OnNext(tuple); + } + } + catch (Exception ex) + { + _logger.BuildTupleEmissionFailed(ex, typeof(TTuple).Name); + } + }, observer.OnError, observer.OnCompleted); + }); + } + + /// + /// Gets a config from the snapshot, handling interface-to-concrete type resolution. + /// + private object? GetConfigFromSnapshot(ConfigSnapshot snapshot, Type type) + { + // Try direct lookup first + var result = snapshot.GetConfig(type); + if (result != null) + { + return result; + } + + // Try interface-to-concrete mapping + if (type.IsInterface && _bindingRegistry != null && + _bindingRegistry.TryGetConcreteType(type, out var concreteType)) + { + return snapshot.GetConfig(concreteType); + } + + return null; + } + + private object?[] ExtractTupleValues(TTuple tuple) + { + var values = new object?[_elementTypes.Length]; + var fields = typeof(TTuple).GetFields(BindingFlags.Public | BindingFlags.Instance); + + var index = 0; + ExtractRecursive(tuple, fields, ref index, values); + return values; + } + + private static void ExtractRecursive(object tuple, FieldInfo[] fields, ref int index, object?[] values) + { + foreach (var field in fields) + { + if (field.Name == "Rest" && field.FieldType.FullName?.StartsWith("System.ValueTuple", StringComparison.Ordinal) == true) + { + var rest = field.GetValue(tuple); + if (rest != null) + { + var restFields = field.FieldType.GetFields(BindingFlags.Public | BindingFlags.Instance); + ExtractRecursive(rest, restFields, ref index, values); + } + } + else + { + values[index++] = field.GetValue(tuple); + } + } + } + + public void Dispose() => _subscription.Dispose(); +} + +/// +/// Caches flattened element type arrays and compiled tuple builder delegates. +/// Handles nested ValueTuple (Rest) expansion. +/// +internal static class TupleShapeCache +{ + private static readonly ConcurrentDictionary Builder)> _cache = new(); + + public static (Type[] Elements, Func Builder) Get(Type tupleType) + { + return _cache.GetOrAdd(tupleType, t => + { + var elements = Flatten(t).ToArray(); + var builder = CompileBuilder(elements); + return (elements, builder); + }); + } + + private static IEnumerable Flatten(Type t) + { + if (!IsValueTuple(t)) + { + yield break; + } + + var fields = t.GetFields(BindingFlags.Public | BindingFlags.Instance); + foreach (var f in fields) + { + if (f.Name == "Rest" && IsValueTuple(f.FieldType)) + { + foreach (var nested in Flatten(f.FieldType)) + { + yield return nested; + } + } + else + { + yield return f.FieldType; + } + } + } + + private static bool IsValueTuple(Type t) => t is { IsValueType: true, FullName: not null } && t.FullName.StartsWith("System.ValueTuple", StringComparison.Ordinal); + + private static Func CompileBuilder(Type[] elements) + { + var param = Expression.Parameter(typeof(object?[]), "arr"); + + var body = Build(0); + var lambda = Expression.Lambda>(Expression.Convert(body, typeof(object)), param); + return lambda.Compile(); + + Expression Build(int start) + { + var remaining = elements.Length - start; + if (remaining <= 7) + { + var ctorTypes = elements.Skip(start).Take(remaining).ToArray(); + var ctors = GetTupleType(remaining, ctorTypes); + var args = ctorTypes.Select((t, i) => Expression.Convert(Expression.ArrayIndex(param, Expression.Constant(start + i)), t)); + return Expression.New(ctors.GetConstructors()[0], args); + } + else + { + var headTypes = elements.Skip(start).Take(7).ToArray(); + var restExpr = Build(start + 7); + var tupleTypeHead = GetTupleType(8, headTypes.Concat([restExpr.Type]).ToArray()); + var args = headTypes.Select((t, i) => Expression.Convert(Expression.ArrayIndex(param, Expression.Constant(start + i)), t)) + .Concat([restExpr]); + return Expression.New(tupleTypeHead.GetConstructors()[0], args); + } + } + } + + private static Type GetTupleType(int count, Type[] types) + { + return count switch + { + <= 0 => throw new ArgumentOutOfRangeException(nameof(count)), + 1 => typeof(ValueTuple<>).MakeGenericType(types), + 2 => typeof(ValueTuple<,>).MakeGenericType(types), + 3 => typeof(ValueTuple<,,>).MakeGenericType(types), + 4 => typeof(ValueTuple<,,,>).MakeGenericType(types), + 5 => typeof(ValueTuple<,,,,>).MakeGenericType(types), + 6 => typeof(ValueTuple<,,,,,>).MakeGenericType(types), + 7 => typeof(ValueTuple<,,,,,,>).MakeGenericType(types), + 8 => typeof(ValueTuple<,,,,,,,>).MakeGenericType(types), + _ => throw new NotSupportedException("Unsupported tuple arity segment") + }; + } +} diff --git a/src/Cocoar.Configuration/Rules/ChangeSubscription.cs b/src/Cocoar.Configuration/Rules/ChangeSubscription.cs index 383672d..51c6241 100644 --- a/src/Cocoar.Configuration/Rules/ChangeSubscription.cs +++ b/src/Cocoar.Configuration/Rules/ChangeSubscription.cs @@ -1,98 +1,98 @@ -using Cocoar.Configuration.Providers.Abstractions; -using Cocoar.Configuration.Utilities; - -namespace Cocoar.Configuration.Rules; - -/// -/// Manages change subscriptions to configuration providers. -/// Handles subscription lifecycle, query key tracking, and change notifications. -/// -internal sealed class ChangeSubscription : IDisposable -{ - private readonly SimpleSubject _changes = new(); - private IDisposable? _subscription; - private string? _queryKey; - - /// - /// Observable stream of change notifications. - /// - public IObservable Changes => _changes; - - /// - /// Gets the current query key used for the subscription. - /// - public string? QueryKey => _queryKey; - - /// - /// Checks if a subscription is currently active. - /// - public bool IsSubscribed => _subscription is not null; - - /// - /// Ensures subscription is active for the given query. - /// Returns true if subscription was recreated (query key changed). - /// - public bool EnsureSubscription( - ConfigurationProvider provider, - IProviderQuery queryOptions, - string queryKey, - Action onChangeCallback) - { - if (_subscription is not null && _queryKey == queryKey) - { - return false; - } - Unsubscribe(); - _queryKey = queryKey; - - _subscription = provider - .ChangesAsBytes(queryOptions) - .Subscribe( - bytes => onChangeCallback(bytes), - _ => - { - // Provider errored — unsubscribe the dead subscription before - // notifying, so the next recompute re-subscribes cleanly. - Unsubscribe(); - PublishChangeSafely(); - }); - - return true; // Subscription was recreated - } - - /// - /// Publishes a change notification to subscribers. - /// - public void PublishChangeSafely() - { - Safety.NotifyQuietly(_changes, true); - } - - /// - /// Unsubscribes from the current provider changes. - /// - public void Unsubscribe() - { - if (_subscription is not null) - { - Safety.DisposeQuietly(_subscription); - _subscription = null; - } - } - - /// - /// Resets subscription state (used when provider changes). - /// - public void Reset() - { - Unsubscribe(); - _queryKey = null; - } - - public void Dispose() - { - Unsubscribe(); - _changes.OnCompleted(); - _changes.Dispose(); - } -} +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.Configuration.Utilities; + +namespace Cocoar.Configuration.Rules; + +/// +/// Manages change subscriptions to configuration providers. +/// Handles subscription lifecycle, query key tracking, and change notifications. +/// +internal sealed class ChangeSubscription : IDisposable +{ + private readonly SimpleSubject _changes = new(); + private IDisposable? _subscription; + private string? _queryKey; + + /// + /// Observable stream of change notifications. + /// + public IObservable Changes => _changes; + + /// + /// Gets the current query key used for the subscription. + /// + public string? QueryKey => _queryKey; + + /// + /// Checks if a subscription is currently active. + /// + public bool IsSubscribed => _subscription is not null; + + /// + /// Ensures subscription is active for the given query. + /// Returns true if subscription was recreated (query key changed). + /// + public bool EnsureSubscription( + ConfigurationProvider provider, + IProviderQuery queryOptions, + string queryKey, + Action onChangeCallback) + { + if (_subscription is not null && _queryKey == queryKey) + { + return false; + } + Unsubscribe(); + _queryKey = queryKey; + + _subscription = provider + .ChangesAsBytes(queryOptions) + .Subscribe( + bytes => onChangeCallback(bytes), + _ => + { + // Provider errored — unsubscribe the dead subscription before + // notifying, so the next recompute re-subscribes cleanly. + Unsubscribe(); + PublishChangeSafely(); + }); + + return true; // Subscription was recreated + } + + /// + /// Publishes a change notification to subscribers. + /// + public void PublishChangeSafely() + { + Safety.NotifyQuietly(_changes, true); + } + + /// + /// Unsubscribes from the current provider changes. + /// + public void Unsubscribe() + { + if (_subscription is not null) + { + Safety.DisposeQuietly(_subscription); + _subscription = null; + } + } + + /// + /// Resets subscription state (used when provider changes). + /// + public void Reset() + { + Unsubscribe(); + _queryKey = null; + } + + public void Dispose() + { + Unsubscribe(); + _changes.OnCompleted(); + _changes.Dispose(); + } +} diff --git a/src/Cocoar.Configuration/Rules/ConfigRuleOptions.cs b/src/Cocoar.Configuration/Rules/ConfigRuleOptions.cs index 359b196..6d33553 100644 --- a/src/Cocoar.Configuration/Rules/ConfigRuleOptions.cs +++ b/src/Cocoar.Configuration/Rules/ConfigRuleOptions.cs @@ -1,29 +1,29 @@ -using Cocoar.Configuration.Core; - -namespace Cocoar.Configuration.Rules; - -public sealed record ConfigRuleOptions( - bool Required = false, - Func? UseWhen = null, - string? MountPath = null, - string? SelectPath = null, - string? Name = null, - bool TenantScoped = false, - Func? ActivationGate = null) -{ - public ConfigRuleOptions WithMount(string? mountPath) - => this with { MountPath = string.IsNullOrWhiteSpace(mountPath) ? null : mountPath.Trim() }; - - public ConfigRuleOptions WithSelect(string? selectPath) - => this with { SelectPath = Normalize(selectPath) }; - - static string? Normalize(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return null; - } - - return string.Join(':', path.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); - } -} +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.Rules; + +public sealed record ConfigRuleOptions( + bool Required = false, + Func? UseWhen = null, + string? MountPath = null, + string? SelectPath = null, + string? Name = null, + bool TenantScoped = false, + Func? ActivationGate = null) +{ + public ConfigRuleOptions WithMount(string? mountPath) + => this with { MountPath = string.IsNullOrWhiteSpace(mountPath) ? null : mountPath.Trim() }; + + public ConfigRuleOptions WithSelect(string? selectPath) + => this with { SelectPath = Normalize(selectPath) }; + + static string? Normalize(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + return string.Join(':', path.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } +} diff --git a/src/Cocoar.Configuration/Rules/RuleManager.cs b/src/Cocoar.Configuration/Rules/RuleManager.cs index 5f95a95..d224223 100644 --- a/src/Cocoar.Configuration/Rules/RuleManager.cs +++ b/src/Cocoar.Configuration/Rules/RuleManager.cs @@ -1,322 +1,322 @@ -using System.Buffers; -using System.Security.Cryptography; -using System.Text.Json; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Diagnostics; -using Cocoar.Configuration.Helper; -using Cocoar.Configuration.Infrastructure; -using Cocoar.Configuration.Providers.Abstractions; -using Cocoar.Json.Mutable; -using Microsoft.Extensions.Logging; - -namespace Cocoar.Configuration.Rules; - -internal static partial class RuleManagerLog -{ - [LoggerMessage(EventId = 5000, Level = LogLevel.Warning, Message = "Selection path '{SelectPath}' failed; skipping optional rule.")] - public static partial void OptionalSelectPathFailed(this ILogger logger, Exception exception, string SelectPath); - - [LoggerMessage(EventId = 5001, Level = LogLevel.Error, Message = "Required rule failed: {Provider}->{Config}")] - public static partial void RequiredRuleFailed(this ILogger logger, Exception exception, string Provider, string Config); - - [LoggerMessage(EventId = 5002, Level = LogLevel.Warning, Message = "Optional rule failed and will be skipped: {Provider}->{Config}")] - public static partial void OptionalRuleFailed(this ILogger logger, Exception exception, string Provider, string Config); - - [LoggerMessage(EventId = 5003, Level = LogLevel.Debug, Message = "Query key hash failed for {QueryType}; falling back to JSON serialization")] - public static partial void QueryKeyHashFallback(this ILogger logger, Exception exception, string QueryType); - - [LoggerMessage(EventId = 5004, Level = LogLevel.Debug, Message = "Transform key computation failed; falling back to empty key")] - public static partial void TransformKeyFallback(this ILogger logger, Exception exception); -} - -/// -/// Coordinates rule execution: provider lifecycle, query management, caching, and change tracking. -/// Delegates lifecycle to RuleProviderLease, caching to TransformCache, and subscriptions to ChangeSubscription. -/// -internal sealed class RuleManager : IRuleManager -{ - private readonly ConfigRule _rule; - private readonly ILogger _logger; - - private readonly RuleProviderLease _providerLease; - private readonly TransformCache _cache = new(); - private readonly ChangeSubscription _changeSubscription = new(); - - public RuleExecutionOutcome LastOutcome { get; private set; } = RuleExecutionOutcome.Unknown; - public Exception? LastFailureException { get; private set; } - - public Type TypeDefinition => _rule.ConcreteType; - public bool Required => _rule.Options?.Required == true; - public IObservable Changes => _changeSubscription.Changes; - - public MutableJsonObject? LastJsonContribution { get; set; } - public string? LastSelectionHash - { - get => _cache.LastSelectionHash; - set => _cache.LastSelectionHash = value; - } - - public IReadOnlyList? SubManagers => null; - - public ConfigurationProvider? CurrentProvider => _providerLease.Provider; - - public RuleManager(ConfigRule rule, ILogger logger, ProviderRegistry registry) - { - _rule = rule; - _logger = logger; - _providerLease = new RuleProviderLease(rule.ProviderType, registry); - } - - public async Task?> ComputeAsync(IConfigurationAccessor accessor, CancellationToken ct) - { - LastFailureException = null; - - if (ShouldSkip(accessor)) - { - return null; // Skip rule - tenant-scoped without a tenant, or When condition is false - } - - var providerOptions = _rule.ResolveProviderOptions(accessor); - EnsureProvider(providerOptions); - - var queryOptions = _rule.ResolveQueryOptions(accessor); - EnsureSubscription(queryOptions); - var newTransformKey = ComputeTransformKey(_rule.Options); - _cache.UpdateTransformKey(newTransformKey); - - try - { - if (_cache.HasValidCache) - { - LastOutcome = RuleExecutionOutcome.Up; - return _cache.GetCachedBytes(); - } - if (_cache.CanReuseWithoutFetch) - { - _cache.MarkClean(); - LastOutcome = RuleExecutionOutcome.Up; - return _cache.GetCachedBytes(); - } - var bytesMemory = await _providerLease.Provider!.FetchConfigurationBytesAsync(queryOptions, ct).ConfigureAwait(false); - - try - { - var transformedBytes = JsonTransform.SelectAndMount(bytesMemory, _rule.Options?.SelectPath, _rule.Options?.MountPath); - - _cache.StoreTransformedBytes(transformedBytes); - } - catch (KeyNotFoundException ex) - { - if (!HandleSelectFailure(_rule.Options?.SelectPath ?? string.Empty, ex)) - { - return EmptyObjectResult(); // Optional rule: return empty object - } - throw; // Required path: HandleSelectFailure throws - } - finally - { - // CRITICAL: Zero provider bytes after use - if (bytesMemory != null && bytesMemory.Length > 0) - { - CryptographicOperations.ZeroMemory(bytesMemory); - } - } - - LastOutcome = RuleExecutionOutcome.Up; - return _cache.GetCachedBytes(); - } - catch (Exception ex) - { - return HandleFailure(ex); - } - } - - private bool ShouldSkip(IConfigurationAccessor accessor) - { - // A .TenantScoped() rule never runs in the global (tenant-agnostic) pipeline. Enforced via the static - // marker (not just the When predicate) so it holds regardless of how .When() and .TenantScoped() were - // ordered in the fluent chain — e.g. .TenantScoped().When(p) still skips when there is no tenant. - if (_rule.Options?.TenantScoped == true && string.IsNullOrWhiteSpace(accessor.Tenant)) - { - return MarkSkipped(); - } - - // A service-backed (Layer-2, ADR-006) rule stays dormant until the application container is built and the - // activation recompute runs. Enforced via a dedicated gate (not the user .When predicate) so it holds - // regardless of fluent ordering — a later .When() cannot clobber it (mirrors the .TenantScoped() marker). - if (_rule.Options?.ActivationGate is { } activationGate && !activationGate.Invoke(accessor)) - { - return MarkSkipped(); - } - - if (_rule.Options?.UseWhen == null) - { - return false; - } - - if (_rule.Options.UseWhen.Invoke(accessor)) - { - return false; - } - - return MarkSkipped(); - } - - private bool MarkSkipped() - { - _changeSubscription.Unsubscribe(); - LastOutcome = RuleExecutionOutcome.Skipped; - return true; - } - - private void EnsureProvider(IProviderConfiguration providerOptions) - { - _providerLease.EnsureProvider(providerOptions, OnBeforeProviderRebuild); - } - - private void OnBeforeProviderRebuild() - { - _changeSubscription.Reset(); - LastSelectionHash = null; - _cache.Invalidate(); - } - - private void EnsureSubscription(IProviderQuery queryOptions) - { - if (!_providerLease.HasProvider) - { - return; - } - - var newQueryKey = ComputeQueryKey(queryOptions); - - bool subscriptionChanged = _changeSubscription.EnsureSubscription( - _providerLease.Provider!, - queryOptions, - newQueryKey, - ProcessProviderChangeBytes); - - if (subscriptionChanged) - { - LastSelectionHash = null; - } - } - - private void ProcessProviderChangeBytes(byte[] bytesMemory) - { - bool changed = _cache.ProcessProviderChange(bytesMemory, _rule.Options?.SelectPath, _rule.Options?.MountPath); - - if (changed) - { - _changeSubscription.PublishChangeSafely(); - } - } - - private bool HandleSelectFailure(string selectPath, Exception ex) - { - if (Required) - { - throw new InvalidOperationException($"Selection path '{selectPath}' failed for provider {_rule.ProviderType.Name}", ex); - } - - _logger.OptionalSelectPathFailed(ex, selectPath); - LastOutcome = RuleExecutionOutcome.Failed; - LastFailureException = ex; - return false; - } - - private ReadOnlyMemory HandleFailure(Exception ex) - { - LastOutcome = RuleExecutionOutcome.Failed; - LastFailureException = ex; - - CocoarMetrics.ProviderErrors.Add(1, - new KeyValuePair("provider_type", _rule.ProviderType.Name), - new KeyValuePair("required", Required.ToString())); - - if (Required) - { - _logger.RequiredRuleFailed(ex, _rule.ProviderType.Name, _rule.ConcreteType.Name); - throw new InvalidOperationException($"Required rule failed for {_rule.ProviderType.Name} → {_rule.ConcreteType.Name}", ex); - } - - _logger.OptionalRuleFailed(ex, _rule.ProviderType.Name, _rule.ConcreteType.Name); - return EmptyObjectResult(); - } - - /// - /// Returns empty JSON object - used when optional rules fail but should still contribute empty data. - /// Health monitoring tracks the failure via LastFailureException. - /// - private static ReadOnlyMemory EmptyObjectResult() - { - return "{}"u8.ToArray(); - } - - private string ComputeQueryKey(IProviderQuery query) - { - try - { - using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - - var bufferWriter = new ArrayBufferWriter(); - using var writer = new Utf8JsonWriter(bufferWriter); - - JsonSerializer.Serialize(writer, query, query.GetType()); - writer.Flush(); - - var written = bufferWriter.WrittenSpan; - hash.AppendData(written); - - return Convert.ToHexString(hash.GetHashAndReset()); - } - catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException) - { - _logger.QueryKeyHashFallback(ex, query.GetType().Name); - return JsonSerializer.Serialize(query, query.GetType()); - } - } - - private string ComputeTransformKey(ConfigRuleOptions? options) - { - try - { - var select = string.IsNullOrWhiteSpace(options?.SelectPath) ? string.Empty : options!.SelectPath!; - var mount = string.IsNullOrWhiteSpace(options?.MountPath) ? string.Empty : options!.MountPath!; - var input = select + "|" + mount; - var bytes = System.Text.Encoding.UTF8.GetBytes(input); - var hash = SHA256.HashData(bytes); - return Convert.ToHexString(hash); - } - catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) - { - _logger.TransformKeyFallback(ex); - return string.Empty; - } - } - - /// - /// Clears the cached bytes (zeros them) without disposing the SecureBytes object. - /// Used to zero plaintext before replacing with encrypted bytes. - /// - public void ClearCachedBytes() - { - _cache.ClearCachedBytes(); - } - - /// - /// Updates the cached bytes with encrypted/preprocessed bytes. - /// This prevents plaintext secrets from lingering in memory. - /// - public void UpdateCachedBytes(byte[] encryptedBytes) - { - _cache.UpdateCachedBytes(encryptedBytes); - } - - public void Dispose() - { - _changeSubscription.Dispose(); - _cache.Dispose(); - _providerLease.Dispose(); - } -} +using System.Buffers; +using System.Security.Cryptography; +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Diagnostics; +using Cocoar.Configuration.Helper; +using Cocoar.Configuration.Infrastructure; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.Json.Mutable; +using Microsoft.Extensions.Logging; + +namespace Cocoar.Configuration.Rules; + +internal static partial class RuleManagerLog +{ + [LoggerMessage(EventId = 5000, Level = LogLevel.Warning, Message = "Selection path '{SelectPath}' failed; skipping optional rule.")] + public static partial void OptionalSelectPathFailed(this ILogger logger, Exception exception, string SelectPath); + + [LoggerMessage(EventId = 5001, Level = LogLevel.Error, Message = "Required rule failed: {Provider}->{Config}")] + public static partial void RequiredRuleFailed(this ILogger logger, Exception exception, string Provider, string Config); + + [LoggerMessage(EventId = 5002, Level = LogLevel.Warning, Message = "Optional rule failed and will be skipped: {Provider}->{Config}")] + public static partial void OptionalRuleFailed(this ILogger logger, Exception exception, string Provider, string Config); + + [LoggerMessage(EventId = 5003, Level = LogLevel.Debug, Message = "Query key hash failed for {QueryType}; falling back to JSON serialization")] + public static partial void QueryKeyHashFallback(this ILogger logger, Exception exception, string QueryType); + + [LoggerMessage(EventId = 5004, Level = LogLevel.Debug, Message = "Transform key computation failed; falling back to empty key")] + public static partial void TransformKeyFallback(this ILogger logger, Exception exception); +} + +/// +/// Coordinates rule execution: provider lifecycle, query management, caching, and change tracking. +/// Delegates lifecycle to RuleProviderLease, caching to TransformCache, and subscriptions to ChangeSubscription. +/// +internal sealed class RuleManager : IRuleManager +{ + private readonly ConfigRule _rule; + private readonly ILogger _logger; + + private readonly RuleProviderLease _providerLease; + private readonly TransformCache _cache = new(); + private readonly ChangeSubscription _changeSubscription = new(); + + public RuleExecutionOutcome LastOutcome { get; private set; } = RuleExecutionOutcome.Unknown; + public Exception? LastFailureException { get; private set; } + + public Type TypeDefinition => _rule.ConcreteType; + public bool Required => _rule.Options?.Required == true; + public IObservable Changes => _changeSubscription.Changes; + + public MutableJsonObject? LastJsonContribution { get; set; } + public string? LastSelectionHash + { + get => _cache.LastSelectionHash; + set => _cache.LastSelectionHash = value; + } + + public IReadOnlyList? SubManagers => null; + + public ConfigurationProvider? CurrentProvider => _providerLease.Provider; + + public RuleManager(ConfigRule rule, ILogger logger, ProviderRegistry registry) + { + _rule = rule; + _logger = logger; + _providerLease = new RuleProviderLease(rule.ProviderType, registry); + } + + public async Task?> ComputeAsync(IConfigurationAccessor accessor, CancellationToken ct) + { + LastFailureException = null; + + if (ShouldSkip(accessor)) + { + return null; // Skip rule - tenant-scoped without a tenant, or When condition is false + } + + var providerOptions = _rule.ResolveProviderOptions(accessor); + EnsureProvider(providerOptions); + + var queryOptions = _rule.ResolveQueryOptions(accessor); + EnsureSubscription(queryOptions); + var newTransformKey = ComputeTransformKey(_rule.Options); + _cache.UpdateTransformKey(newTransformKey); + + try + { + if (_cache.HasValidCache) + { + LastOutcome = RuleExecutionOutcome.Up; + return _cache.GetCachedBytes(); + } + if (_cache.CanReuseWithoutFetch) + { + _cache.MarkClean(); + LastOutcome = RuleExecutionOutcome.Up; + return _cache.GetCachedBytes(); + } + var bytesMemory = await _providerLease.Provider!.FetchConfigurationBytesAsync(queryOptions, ct).ConfigureAwait(false); + + try + { + var transformedBytes = JsonTransform.SelectAndMount(bytesMemory, _rule.Options?.SelectPath, _rule.Options?.MountPath); + + _cache.StoreTransformedBytes(transformedBytes); + } + catch (KeyNotFoundException ex) + { + if (!HandleSelectFailure(_rule.Options?.SelectPath ?? string.Empty, ex)) + { + return EmptyObjectResult(); // Optional rule: return empty object + } + throw; // Required path: HandleSelectFailure throws + } + finally + { + // CRITICAL: Zero provider bytes after use + if (bytesMemory != null && bytesMemory.Length > 0) + { + CryptographicOperations.ZeroMemory(bytesMemory); + } + } + + LastOutcome = RuleExecutionOutcome.Up; + return _cache.GetCachedBytes(); + } + catch (Exception ex) + { + return HandleFailure(ex); + } + } + + private bool ShouldSkip(IConfigurationAccessor accessor) + { + // A .TenantScoped() rule never runs in the global (tenant-agnostic) pipeline. Enforced via the static + // marker (not just the When predicate) so it holds regardless of how .When() and .TenantScoped() were + // ordered in the fluent chain — e.g. .TenantScoped().When(p) still skips when there is no tenant. + if (_rule.Options?.TenantScoped == true && string.IsNullOrWhiteSpace(accessor.Tenant)) + { + return MarkSkipped(); + } + + // A service-backed (Layer-2, ADR-006) rule stays dormant until the application container is built and the + // activation recompute runs. Enforced via a dedicated gate (not the user .When predicate) so it holds + // regardless of fluent ordering — a later .When() cannot clobber it (mirrors the .TenantScoped() marker). + if (_rule.Options?.ActivationGate is { } activationGate && !activationGate.Invoke(accessor)) + { + return MarkSkipped(); + } + + if (_rule.Options?.UseWhen == null) + { + return false; + } + + if (_rule.Options.UseWhen.Invoke(accessor)) + { + return false; + } + + return MarkSkipped(); + } + + private bool MarkSkipped() + { + _changeSubscription.Unsubscribe(); + LastOutcome = RuleExecutionOutcome.Skipped; + return true; + } + + private void EnsureProvider(IProviderConfiguration providerOptions) + { + _providerLease.EnsureProvider(providerOptions, OnBeforeProviderRebuild); + } + + private void OnBeforeProviderRebuild() + { + _changeSubscription.Reset(); + LastSelectionHash = null; + _cache.Invalidate(); + } + + private void EnsureSubscription(IProviderQuery queryOptions) + { + if (!_providerLease.HasProvider) + { + return; + } + + var newQueryKey = ComputeQueryKey(queryOptions); + + bool subscriptionChanged = _changeSubscription.EnsureSubscription( + _providerLease.Provider!, + queryOptions, + newQueryKey, + ProcessProviderChangeBytes); + + if (subscriptionChanged) + { + LastSelectionHash = null; + } + } + + private void ProcessProviderChangeBytes(byte[] bytesMemory) + { + bool changed = _cache.ProcessProviderChange(bytesMemory, _rule.Options?.SelectPath, _rule.Options?.MountPath); + + if (changed) + { + _changeSubscription.PublishChangeSafely(); + } + } + + private bool HandleSelectFailure(string selectPath, Exception ex) + { + if (Required) + { + throw new InvalidOperationException($"Selection path '{selectPath}' failed for provider {_rule.ProviderType.Name}", ex); + } + + _logger.OptionalSelectPathFailed(ex, selectPath); + LastOutcome = RuleExecutionOutcome.Failed; + LastFailureException = ex; + return false; + } + + private ReadOnlyMemory HandleFailure(Exception ex) + { + LastOutcome = RuleExecutionOutcome.Failed; + LastFailureException = ex; + + CocoarMetrics.ProviderErrors.Add(1, + new KeyValuePair("provider_type", _rule.ProviderType.Name), + new KeyValuePair("required", Required.ToString())); + + if (Required) + { + _logger.RequiredRuleFailed(ex, _rule.ProviderType.Name, _rule.ConcreteType.Name); + throw new InvalidOperationException($"Required rule failed for {_rule.ProviderType.Name} → {_rule.ConcreteType.Name}", ex); + } + + _logger.OptionalRuleFailed(ex, _rule.ProviderType.Name, _rule.ConcreteType.Name); + return EmptyObjectResult(); + } + + /// + /// Returns empty JSON object - used when optional rules fail but should still contribute empty data. + /// Health monitoring tracks the failure via LastFailureException. + /// + private static ReadOnlyMemory EmptyObjectResult() + { + return "{}"u8.ToArray(); + } + + private string ComputeQueryKey(IProviderQuery query) + { + try + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + + var bufferWriter = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(bufferWriter); + + JsonSerializer.Serialize(writer, query, query.GetType()); + writer.Flush(); + + var written = bufferWriter.WrittenSpan; + hash.AppendData(written); + + return Convert.ToHexString(hash.GetHashAndReset()); + } + catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException) + { + _logger.QueryKeyHashFallback(ex, query.GetType().Name); + return JsonSerializer.Serialize(query, query.GetType()); + } + } + + private string ComputeTransformKey(ConfigRuleOptions? options) + { + try + { + var select = string.IsNullOrWhiteSpace(options?.SelectPath) ? string.Empty : options!.SelectPath!; + var mount = string.IsNullOrWhiteSpace(options?.MountPath) ? string.Empty : options!.MountPath!; + var input = select + "|" + mount; + var bytes = System.Text.Encoding.UTF8.GetBytes(input); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + _logger.TransformKeyFallback(ex); + return string.Empty; + } + } + + /// + /// Clears the cached bytes (zeros them) without disposing the SecureBytes object. + /// Used to zero plaintext before replacing with encrypted bytes. + /// + public void ClearCachedBytes() + { + _cache.ClearCachedBytes(); + } + + /// + /// Updates the cached bytes with encrypted/preprocessed bytes. + /// This prevents plaintext secrets from lingering in memory. + /// + public void UpdateCachedBytes(byte[] encryptedBytes) + { + _cache.UpdateCachedBytes(encryptedBytes); + } + + public void Dispose() + { + _changeSubscription.Dispose(); + _cache.Dispose(); + _providerLease.Dispose(); + } +} diff --git a/src/Cocoar.Configuration/Rules/RuleProviderLease.cs b/src/Cocoar.Configuration/Rules/RuleProviderLease.cs index 1a60db6..cbc6f3d 100644 --- a/src/Cocoar.Configuration/Rules/RuleProviderLease.cs +++ b/src/Cocoar.Configuration/Rules/RuleProviderLease.cs @@ -1,84 +1,84 @@ -using Cocoar.Configuration.Infrastructure; -using Cocoar.Configuration.Providers.Abstractions; -using Cocoar.Configuration.Utilities; - -namespace Cocoar.Configuration.Rules; - -/// -/// Manages provider lifecycle for a configuration rule. -/// Handles acquisition, key-based caching, and disposal of provider handles. -/// -internal sealed class RuleProviderLease : IDisposable -{ - private readonly Type _providerType; - private readonly ProviderRegistry _registry; - - private ProviderRegistry.ProviderHandle? _providerHandle; - private ConfigurationProvider? _provider; - private string? _providerKey; - - public RuleProviderLease(Type providerType, ProviderRegistry registry) - { - _providerType = providerType; - _registry = registry; - } - - /// - /// Gets the current provider instance, or null if not acquired. - /// - public ConfigurationProvider? Provider => _provider; - - /// - /// Gets the current provider key. - /// - public string? ProviderKey => _providerKey; - - /// - /// Indicates whether a provider is currently held. - /// - public bool HasProvider => _provider != null; - - /// - /// Ensures a provider is available for the given options. - /// Returns true if the provider was rebuilt (options changed). - /// - /// The provider configuration options. - /// Callback invoked BEFORE provider rebuild (for unsubscribing/cache invalidation). - /// True if the provider was newly acquired or rebuilt. - public bool EnsureProvider(IProviderConfiguration providerOptions, Action? onBeforeRebuild = null) - { - var newProviderKey = providerOptions.GenerateProviderKey(); - if (_provider != null && _providerKey == newProviderKey) - { - return false; - } - - onBeforeRebuild?.Invoke(); - RebuildProvider(providerOptions, newProviderKey); - return true; - } - - private void RebuildProvider(IProviderConfiguration providerOptions, string? providerKey) - { - if (_providerHandle is not null) - { - Safety.DisposeQuietly(_providerHandle); - _providerHandle = null; - } - - _providerHandle = _registry.Acquire(_providerType, providerOptions); - _provider = _providerHandle.Provider; - _providerKey = providerKey; - } - - public void Dispose() - { - if (_providerHandle is not null) - { - Safety.DisposeQuietly(_providerHandle); - _providerHandle = null; - } - - _provider = null; - } -} +using Cocoar.Configuration.Infrastructure; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.Configuration.Utilities; + +namespace Cocoar.Configuration.Rules; + +/// +/// Manages provider lifecycle for a configuration rule. +/// Handles acquisition, key-based caching, and disposal of provider handles. +/// +internal sealed class RuleProviderLease : IDisposable +{ + private readonly Type _providerType; + private readonly ProviderRegistry _registry; + + private ProviderRegistry.ProviderHandle? _providerHandle; + private ConfigurationProvider? _provider; + private string? _providerKey; + + public RuleProviderLease(Type providerType, ProviderRegistry registry) + { + _providerType = providerType; + _registry = registry; + } + + /// + /// Gets the current provider instance, or null if not acquired. + /// + public ConfigurationProvider? Provider => _provider; + + /// + /// Gets the current provider key. + /// + public string? ProviderKey => _providerKey; + + /// + /// Indicates whether a provider is currently held. + /// + public bool HasProvider => _provider != null; + + /// + /// Ensures a provider is available for the given options. + /// Returns true if the provider was rebuilt (options changed). + /// + /// The provider configuration options. + /// Callback invoked BEFORE provider rebuild (for unsubscribing/cache invalidation). + /// True if the provider was newly acquired or rebuilt. + public bool EnsureProvider(IProviderConfiguration providerOptions, Action? onBeforeRebuild = null) + { + var newProviderKey = providerOptions.GenerateProviderKey(); + if (_provider != null && _providerKey == newProviderKey) + { + return false; + } + + onBeforeRebuild?.Invoke(); + RebuildProvider(providerOptions, newProviderKey); + return true; + } + + private void RebuildProvider(IProviderConfiguration providerOptions, string? providerKey) + { + if (_providerHandle is not null) + { + Safety.DisposeQuietly(_providerHandle); + _providerHandle = null; + } + + _providerHandle = _registry.Acquire(_providerType, providerOptions); + _provider = _providerHandle.Provider; + _providerKey = providerKey; + } + + public void Dispose() + { + if (_providerHandle is not null) + { + Safety.DisposeQuietly(_providerHandle); + _providerHandle = null; + } + + _provider = null; + } +} diff --git a/src/Cocoar.Configuration/Rules/TransformCache.cs b/src/Cocoar.Configuration/Rules/TransformCache.cs index a91587a..6c873bf 100644 --- a/src/Cocoar.Configuration/Rules/TransformCache.cs +++ b/src/Cocoar.Configuration/Rules/TransformCache.cs @@ -1,204 +1,204 @@ -using System.Security.Cryptography; -using Cocoar.Configuration.Helper; -using Cocoar.Configuration.Utilities; - -namespace Cocoar.Configuration.Rules; - -/// -/// Manages byte caching and transformation deduplication for configuration rules. -/// Handles secure byte storage, hash-based change detection, and transform key tracking. -/// -internal sealed class TransformCache : IDisposable -{ -#if NET9_0_OR_GREATER - private readonly Lock _lock = new(); -#else - private readonly object _lock = new(); -#endif - private SecureBytes? _cachedBytes; - private string? _lastTransformKey; - private string? _lastSelectionHash; - private bool _dirty; - private bool _dirtyFromTransformChange; - - /// - /// Gets whether the cache is dirty and needs refresh. - /// - public bool IsDirty => _dirty; - - /// - /// Gets the last computed selection hash for change detection. - /// - public string? LastSelectionHash - { - get => _lastSelectionHash; - set => _lastSelectionHash = value; - } - - /// - /// Checks if the cache has valid bytes and is not dirty. - /// - public bool HasValidCache { get { lock (_lock) return !_dirty && _cachedBytes is not null; } } - - /// - /// Checks if cache is dirty but has bytes that can be reused (no transform change). - /// - public bool CanReuseWithoutFetch { get { lock (_lock) return _dirty && _cachedBytes is not null && !_dirtyFromTransformChange; } } - - /// - /// Gets the cached bytes as read-only memory. Returns empty if no cache. - /// - public ReadOnlyMemory GetCachedBytes() - { - lock (_lock) return _cachedBytes?.AsReadOnlyMemory() ?? ReadOnlyMemory.Empty; - } - - /// - /// Updates the transform key and marks cache dirty if changed. - /// - public void UpdateTransformKey(string newTransformKey) - { - lock (_lock) - { - if (_lastTransformKey == newTransformKey) - { - return; - } - - _dirty = true; - _dirtyFromTransformChange = true; - _lastTransformKey = newTransformKey; - } - } - - /// - /// Stores transformed bytes in the cache and clears dirty flags. - /// - public void StoreTransformedBytes(byte[] transformedBytes) - { - lock (_lock) - { - if (_cachedBytes is null) - { - _cachedBytes = SecureBytes.From(transformedBytes); - } - else - { - _cachedBytes.Replace(transformedBytes); - } - - _dirty = false; - _dirtyFromTransformChange = false; - } - } - - /// - /// Processes provider change bytes: transforms, hashes, and caches if changed. - /// Returns true if the data actually changed (hash differs). - /// - public bool ProcessProviderChange(byte[] rawBytes, string? selectPath, string? mountPath) - { - try - { - var transformed = JsonTransform.SelectAndMount(rawBytes, selectPath, mountPath); - var hash = ComputeSelectionHash(transformed); - - lock (_lock) - { - if (_lastSelectionHash is not null && - string.Equals(hash, _lastSelectionHash, StringComparison.Ordinal)) - { - return false; // No change - } - - _lastSelectionHash = hash; - - if (_cachedBytes is null) - { - _cachedBytes = SecureBytes.From(transformed); - } - else - { - _cachedBytes.Replace(transformed); - } - _dirty = true; - _dirtyFromTransformChange = false; - - return true; // Data changed - } - } - catch - { - return false; // Ignore transform errors in change handler - } - } - - /// - /// Marks the cache as clean (used after successful reuse without fetch). - /// - public void MarkClean() - { - lock (_lock) _dirty = false; - } - - /// - /// Invalidates the entire cache (used when provider changes). - /// - public void Invalidate() - { - lock (_lock) - { - _lastSelectionHash = null; - _dirty = true; - _cachedBytes?.Dispose(); - _cachedBytes = null; - } - } - - /// - /// Clears the cached bytes (zeros them) without disposing the SecureBytes object. - /// Used to zero plaintext before replacing with encrypted bytes. - /// - public void ClearCachedBytes() - { - lock (_lock) _cachedBytes?.Clear(); - } - - /// - /// Updates the cached bytes with encrypted/preprocessed bytes. - /// This prevents plaintext secrets from lingering in memory. - /// - public void UpdateCachedBytes(byte[] encryptedBytes) - { - lock (_lock) - { - if (_cachedBytes == null) - { - _cachedBytes = SecureBytes.From(encryptedBytes); - } - else - { - _cachedBytes.Replace(encryptedBytes); - } - } - } - - private static string ComputeSelectionHash(byte[] transformedBytes) - { - try - { - var hash = SHA256.HashData(transformedBytes); - return Convert.ToHexString(hash); - } - catch - { - return string.Empty; - } - } - - public void Dispose() - { - _cachedBytes?.Dispose(); - _cachedBytes = null; - } -} +using System.Security.Cryptography; +using Cocoar.Configuration.Helper; +using Cocoar.Configuration.Utilities; + +namespace Cocoar.Configuration.Rules; + +/// +/// Manages byte caching and transformation deduplication for configuration rules. +/// Handles secure byte storage, hash-based change detection, and transform key tracking. +/// +internal sealed class TransformCache : IDisposable +{ +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + private SecureBytes? _cachedBytes; + private string? _lastTransformKey; + private string? _lastSelectionHash; + private bool _dirty; + private bool _dirtyFromTransformChange; + + /// + /// Gets whether the cache is dirty and needs refresh. + /// + public bool IsDirty => _dirty; + + /// + /// Gets the last computed selection hash for change detection. + /// + public string? LastSelectionHash + { + get => _lastSelectionHash; + set => _lastSelectionHash = value; + } + + /// + /// Checks if the cache has valid bytes and is not dirty. + /// + public bool HasValidCache { get { lock (_lock) return !_dirty && _cachedBytes is not null; } } + + /// + /// Checks if cache is dirty but has bytes that can be reused (no transform change). + /// + public bool CanReuseWithoutFetch { get { lock (_lock) return _dirty && _cachedBytes is not null && !_dirtyFromTransformChange; } } + + /// + /// Gets the cached bytes as read-only memory. Returns empty if no cache. + /// + public ReadOnlyMemory GetCachedBytes() + { + lock (_lock) return _cachedBytes?.AsReadOnlyMemory() ?? ReadOnlyMemory.Empty; + } + + /// + /// Updates the transform key and marks cache dirty if changed. + /// + public void UpdateTransformKey(string newTransformKey) + { + lock (_lock) + { + if (_lastTransformKey == newTransformKey) + { + return; + } + + _dirty = true; + _dirtyFromTransformChange = true; + _lastTransformKey = newTransformKey; + } + } + + /// + /// Stores transformed bytes in the cache and clears dirty flags. + /// + public void StoreTransformedBytes(byte[] transformedBytes) + { + lock (_lock) + { + if (_cachedBytes is null) + { + _cachedBytes = SecureBytes.From(transformedBytes); + } + else + { + _cachedBytes.Replace(transformedBytes); + } + + _dirty = false; + _dirtyFromTransformChange = false; + } + } + + /// + /// Processes provider change bytes: transforms, hashes, and caches if changed. + /// Returns true if the data actually changed (hash differs). + /// + public bool ProcessProviderChange(byte[] rawBytes, string? selectPath, string? mountPath) + { + try + { + var transformed = JsonTransform.SelectAndMount(rawBytes, selectPath, mountPath); + var hash = ComputeSelectionHash(transformed); + + lock (_lock) + { + if (_lastSelectionHash is not null && + string.Equals(hash, _lastSelectionHash, StringComparison.Ordinal)) + { + return false; // No change + } + + _lastSelectionHash = hash; + + if (_cachedBytes is null) + { + _cachedBytes = SecureBytes.From(transformed); + } + else + { + _cachedBytes.Replace(transformed); + } + _dirty = true; + _dirtyFromTransformChange = false; + + return true; // Data changed + } + } + catch + { + return false; // Ignore transform errors in change handler + } + } + + /// + /// Marks the cache as clean (used after successful reuse without fetch). + /// + public void MarkClean() + { + lock (_lock) _dirty = false; + } + + /// + /// Invalidates the entire cache (used when provider changes). + /// + public void Invalidate() + { + lock (_lock) + { + _lastSelectionHash = null; + _dirty = true; + _cachedBytes?.Dispose(); + _cachedBytes = null; + } + } + + /// + /// Clears the cached bytes (zeros them) without disposing the SecureBytes object. + /// Used to zero plaintext before replacing with encrypted bytes. + /// + public void ClearCachedBytes() + { + lock (_lock) _cachedBytes?.Clear(); + } + + /// + /// Updates the cached bytes with encrypted/preprocessed bytes. + /// This prevents plaintext secrets from lingering in memory. + /// + public void UpdateCachedBytes(byte[] encryptedBytes) + { + lock (_lock) + { + if (_cachedBytes == null) + { + _cachedBytes = SecureBytes.From(encryptedBytes); + } + else + { + _cachedBytes.Replace(encryptedBytes); + } + } + } + + private static string ComputeSelectionHash(byte[] transformedBytes) + { + try + { + var hash = SHA256.HashData(transformedBytes); + return Convert.ToHexString(hash); + } + catch + { + return string.Empty; + } + } + + public void Dispose() + { + _cachedBytes?.Dispose(); + _cachedBytes = null; + } +} diff --git a/src/Cocoar.Configuration/Secrets/Converters/Base64UrlByteArrayConverter.cs b/src/Cocoar.Configuration/Secrets/Converters/Base64UrlByteArrayConverter.cs index 3e623e5..ec53075 100644 --- a/src/Cocoar.Configuration/Secrets/Converters/Base64UrlByteArrayConverter.cs +++ b/src/Cocoar.Configuration/Secrets/Converters/Base64UrlByteArrayConverter.cs @@ -1,37 +1,37 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Cocoar.Configuration.Secrets.Protectors.Hybrid; - -namespace Cocoar.Configuration.Secrets.Converters; - -/// -/// JSON converter for byte arrays that uses base64url encoding instead of standard base64. -/// This ensures URL-safe encoding without padding characters. -/// -internal sealed class Base64UrlByteArrayConverter : JsonConverter -{ - public override byte[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.Null) - return null; - - var base64Url = reader.GetString(); - if (string.IsNullOrEmpty(base64Url)) - return Array.Empty(); - - return HybridEnvelopeSerializer.FromBase64Url(base64Url); - } - - public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) - { - if (value == null || value.Length == 0) - { - writer.WriteStringValue(string.Empty); - return; - } - - var base64 = Convert.ToBase64String(value); - var base64Url = base64.Replace('+', '-').Replace('/', '_').TrimEnd('='); - writer.WriteStringValue(base64Url); - } -} +using System.Text.Json; +using System.Text.Json.Serialization; +using Cocoar.Configuration.Secrets.Protectors.Hybrid; + +namespace Cocoar.Configuration.Secrets.Converters; + +/// +/// JSON converter for byte arrays that uses base64url encoding instead of standard base64. +/// This ensures URL-safe encoding without padding characters. +/// +internal sealed class Base64UrlByteArrayConverter : JsonConverter +{ + public override byte[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + + var base64Url = reader.GetString(); + if (string.IsNullOrEmpty(base64Url)) + return Array.Empty(); + + return HybridEnvelopeSerializer.FromBase64Url(base64Url); + } + + public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) + { + if (value == null || value.Length == 0) + { + writer.WriteStringValue(string.Empty); + return; + } + + var base64 = Convert.ToBase64String(value); + var base64Url = base64.Replace('+', '-').Replace('/', '_').TrimEnd('='); + writer.WriteStringValue(base64Url); + } +} diff --git a/src/Cocoar.Configuration/Secrets/Converters/PlaintextSecretJsonConverter.cs b/src/Cocoar.Configuration/Secrets/Converters/PlaintextSecretJsonConverter.cs index 1fa95e2..e8a3a91 100644 --- a/src/Cocoar.Configuration/Secrets/Converters/PlaintextSecretJsonConverter.cs +++ b/src/Cocoar.Configuration/Secrets/Converters/PlaintextSecretJsonConverter.cs @@ -1,45 +1,45 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace Cocoar.Configuration.Secrets.Converters; - -/// -/// Converter that serializes Secret<T> as its primitive value. -/// Used only in test scenarios to preserve secret values through FromStatic serialization. -/// -internal sealed class PlaintextSecretJsonConverter : JsonConverter> -{ - public override Secret? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - // Not used for reading - normal converter handles deserialization - throw new NotSupportedException("Use standard SecretJsonConverter for reading"); - } - - public override void Write(Utf8JsonWriter writer, Secret value, JsonSerializerOptions options) - { - // Open the secret and write its primitive value - using var lease = value.Open(); - JsonSerializer.Serialize(writer, lease.Value, options); - } -} - -/// -/// Converter that serializes ISecret<T> as its primitive value. -/// Used only in test scenarios to preserve secret values through FromStatic serialization. -/// -internal sealed class PlaintextISecretJsonConverter : JsonConverter> -{ - public override ISecret? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - // Not used for reading - normal converter handles deserialization - throw new NotSupportedException("Use standard SecretJsonConverter for reading"); - } - - public override void Write(Utf8JsonWriter writer, ISecret value, JsonSerializerOptions options) - { - // Open the secret and write its primitive value - using var lease = value.Open(); - JsonSerializer.Serialize(writer, lease.Value, options); - } -} +using System.Text.Json; +using System.Text.Json.Serialization; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Converters; + +/// +/// Converter that serializes Secret<T> as its primitive value. +/// Used only in test scenarios to preserve secret values through FromStatic serialization. +/// +internal sealed class PlaintextSecretJsonConverter : JsonConverter> +{ + public override Secret? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Not used for reading - normal converter handles deserialization + throw new NotSupportedException("Use standard SecretJsonConverter for reading"); + } + + public override void Write(Utf8JsonWriter writer, Secret value, JsonSerializerOptions options) + { + // Open the secret and write its primitive value + using var lease = value.Open(); + JsonSerializer.Serialize(writer, lease.Value, options); + } +} + +/// +/// Converter that serializes ISecret<T> as its primitive value. +/// Used only in test scenarios to preserve secret values through FromStatic serialization. +/// +internal sealed class PlaintextISecretJsonConverter : JsonConverter> +{ + public override ISecret? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Not used for reading - normal converter handles deserialization + throw new NotSupportedException("Use standard SecretJsonConverter for reading"); + } + + public override void Write(Utf8JsonWriter writer, ISecret value, JsonSerializerOptions options) + { + // Open the secret and write its primitive value + using var lease = value.Open(); + JsonSerializer.Serialize(writer, lease.Value, options); + } +} diff --git a/src/Cocoar.Configuration/Secrets/Converters/PlaintextSecretJsonConverterFactory.cs b/src/Cocoar.Configuration/Secrets/Converters/PlaintextSecretJsonConverterFactory.cs index e8fa770..0d07a9c 100644 --- a/src/Cocoar.Configuration/Secrets/Converters/PlaintextSecretJsonConverterFactory.cs +++ b/src/Cocoar.Configuration/Secrets/Converters/PlaintextSecretJsonConverterFactory.cs @@ -1,35 +1,35 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace Cocoar.Configuration.Secrets.Converters; - -/// -/// Factory that creates plaintext secret converters for test scenarios. -/// These converters serialize Secret<T> and ISecret<T> as their primitive values -/// instead of "***". -/// -internal sealed class PlaintextSecretJsonConverterFactory : JsonConverterFactory -{ - public override bool CanConvert(Type typeToConvert) - { - if (!typeToConvert.IsGenericType) return false; - var def = typeToConvert.GetGenericTypeDefinition(); - return def == typeof(Secret<>) || def == typeof(ISecret<>); - } - - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) - { - var genericTypeDef = typeToConvert.GetGenericTypeDefinition(); - var valueType = typeToConvert.GetGenericArguments()[0]; - - if (genericTypeDef == typeof(ISecret<>)) - { - var converterType = typeof(PlaintextISecretJsonConverter<>).MakeGenericType(valueType); - return (JsonConverter?)Activator.CreateInstance(converterType); - } - - var secretConverterType = typeof(PlaintextSecretJsonConverter<>).MakeGenericType(valueType); - return (JsonConverter?)Activator.CreateInstance(secretConverterType); - } -} +using System.Text.Json; +using System.Text.Json.Serialization; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Converters; + +/// +/// Factory that creates plaintext secret converters for test scenarios. +/// These converters serialize Secret<T> and ISecret<T> as their primitive values +/// instead of "***". +/// +internal sealed class PlaintextSecretJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + if (!typeToConvert.IsGenericType) return false; + var def = typeToConvert.GetGenericTypeDefinition(); + return def == typeof(Secret<>) || def == typeof(ISecret<>); + } + + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var genericTypeDef = typeToConvert.GetGenericTypeDefinition(); + var valueType = typeToConvert.GetGenericArguments()[0]; + + if (genericTypeDef == typeof(ISecret<>)) + { + var converterType = typeof(PlaintextISecretJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)Activator.CreateInstance(converterType); + } + + var secretConverterType = typeof(PlaintextSecretJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)Activator.CreateInstance(secretConverterType); + } +} diff --git a/src/Cocoar.Configuration/Secrets/Converters/SecretJsonConverter.cs b/src/Cocoar.Configuration/Secrets/Converters/SecretJsonConverter.cs index 2a308d3..cc5a2fc 100644 --- a/src/Cocoar.Configuration/Secrets/Converters/SecretJsonConverter.cs +++ b/src/Cocoar.Configuration/Secrets/Converters/SecretJsonConverter.cs @@ -1,146 +1,146 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Secrets.Core; -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace Cocoar.Configuration.Secrets.Converters; - -/// -/// Shared helper for determining if a type can hold null values. -/// Used by both SecretJsonConverter and ISecretJsonConverter. -/// -internal static class SecretNullabilityHelper -{ - /// - /// Returns true if type T can legally hold a null value. - /// This includes reference types (string?, object?) and nullable value types (int?, bool?). - /// - public static bool TypeAcceptsNull() - { - // Reference types where default is null - if (!typeof(T).IsValueType) - return true; - // Nullable value types (int?, bool?, etc.) - return Nullable.GetUnderlyingType(typeof(T)) != null; - } -} - -/// -/// JSON converter for ISecret<T> interface types. -/// Deserializes to Secret<T> instances, enabling interface-typed properties in configuration classes. -/// -internal sealed class ISecretJsonConverter : JsonConverter> -{ - private readonly SecretJsonConverter _innerConverter; - - public ISecretJsonConverter(ConfigManagerCapabilityScope scope) - { - _innerConverter = new SecretJsonConverter(scope); - } - - /// - /// Always handle null JSON values - delegate to inner converter for proper error handling. - /// - public override bool HandleNull => true; - - public override ISecret? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - return _innerConverter.Read(ref reader, typeof(Secret), options); - } - - public override void Write(Utf8JsonWriter writer, ISecret value, JsonSerializerOptions options) - { - writer.WriteStringValue("***"); - } -} - -internal sealed class SecretJsonConverter : JsonConverter> -{ - private readonly ConfigManagerCapabilityScope _scope; - - public SecretJsonConverter(ConfigManagerCapabilityScope scope) - { - _scope = scope ?? throw new ArgumentNullException(nameof(scope)); - } - - /// - /// Always handle null JSON values so we can: - /// - Create a Secret containing null for nullable types (T?) - /// - Throw a clear error for non-nullable types (T) - /// Without this, System.Text.Json silently sets the property to null. - /// - public override bool HandleNull => true; - - private bool GetAllowPlaintextSetting() - { - var composition = _scope.Owner.GetComposition(); - var policies = composition?.GetAll().ToList(); - var policy = policies is { Count: > 0 } ? policies[^1] : SecretsPolicy.Default; - return policy.AllowPlaintextSecrets; - } - - public override Secret? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var resolver = new SecretsDecryptorResolver(_scope); - - if (reader.TokenType == JsonTokenType.StartObject) - { - using var doc = JsonDocument.ParseValue(ref reader); - var element = doc.RootElement; - - if (SecretEnvelopeWrapper.IsEnvelope(element)) - { - if (!SecretEnvelopeWrapper.TryParse(element, out var env) || env is null) - { - throw new JsonException($"Invalid secret envelope for Secret<{typeof(T).Name}>"); - } - - return new Secret(env, resolver); - } - - // Deserialize directly from JsonElement — never create an intermediate string. - // .NET strings are immutable and cannot be zeroed; plaintext secrets must stay as bytes. - var plainValue = element.Deserialize(options); - if (plainValue is null && !SecretNullabilityHelper.TypeAcceptsNull()) - { - throw new JsonException($"Failed to deserialize plain value for Secret<{typeof(T).Name}>"); - } - return new Secret(plainValue!, resolver, allowPlaintext: GetAllowPlaintextSetting()); - } - - // Handle explicit null token - if (reader.TokenType == JsonTokenType.Null) - { - if (!SecretNullabilityHelper.TypeAcceptsNull()) - { - throw new JsonException( - $"Cannot deserialize null to Secret<{typeof(T).Name}>. " + - $"Use Secret<{typeof(T).Name}?> if the value can be null."); - } - // For nullable types, create a Secret containing null - return new Secret(default!, resolver, allowPlaintext: GetAllowPlaintextSetting()); - } - - if (reader.TokenType == JsonTokenType.String || - reader.TokenType == JsonTokenType.Number || - reader.TokenType == JsonTokenType.True || - reader.TokenType == JsonTokenType.False) - { - using var tempDoc = JsonDocument.ParseValue(ref reader); - var plainValue = tempDoc.RootElement.Deserialize(options); - if (plainValue is null && !SecretNullabilityHelper.TypeAcceptsNull()) - { - throw new JsonException($"Failed to deserialize plain value for Secret<{typeof(T).Name}>"); - } - return new Secret(plainValue!, resolver, allowPlaintext: GetAllowPlaintextSetting()); - } - - throw new JsonException($"Unexpected token type '{reader.TokenType}' for Secret<{typeof(T).Name}>"); - } - - public override void Write(Utf8JsonWriter writer, Secret value, JsonSerializerOptions options) - { - writer.WriteStringValue("***"); - } -} +using System.Text.Json; +using System.Text.Json.Serialization; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Converters; + +/// +/// Shared helper for determining if a type can hold null values. +/// Used by both SecretJsonConverter and ISecretJsonConverter. +/// +internal static class SecretNullabilityHelper +{ + /// + /// Returns true if type T can legally hold a null value. + /// This includes reference types (string?, object?) and nullable value types (int?, bool?). + /// + public static bool TypeAcceptsNull() + { + // Reference types where default is null + if (!typeof(T).IsValueType) + return true; + // Nullable value types (int?, bool?, etc.) + return Nullable.GetUnderlyingType(typeof(T)) != null; + } +} + +/// +/// JSON converter for ISecret<T> interface types. +/// Deserializes to Secret<T> instances, enabling interface-typed properties in configuration classes. +/// +internal sealed class ISecretJsonConverter : JsonConverter> +{ + private readonly SecretJsonConverter _innerConverter; + + public ISecretJsonConverter(ConfigManagerCapabilityScope scope) + { + _innerConverter = new SecretJsonConverter(scope); + } + + /// + /// Always handle null JSON values - delegate to inner converter for proper error handling. + /// + public override bool HandleNull => true; + + public override ISecret? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return _innerConverter.Read(ref reader, typeof(Secret), options); + } + + public override void Write(Utf8JsonWriter writer, ISecret value, JsonSerializerOptions options) + { + writer.WriteStringValue("***"); + } +} + +internal sealed class SecretJsonConverter : JsonConverter> +{ + private readonly ConfigManagerCapabilityScope _scope; + + public SecretJsonConverter(ConfigManagerCapabilityScope scope) + { + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + } + + /// + /// Always handle null JSON values so we can: + /// - Create a Secret containing null for nullable types (T?) + /// - Throw a clear error for non-nullable types (T) + /// Without this, System.Text.Json silently sets the property to null. + /// + public override bool HandleNull => true; + + private bool GetAllowPlaintextSetting() + { + var composition = _scope.Owner.GetComposition(); + var policies = composition?.GetAll().ToList(); + var policy = policies is { Count: > 0 } ? policies[^1] : SecretsPolicy.Default; + return policy.AllowPlaintextSecrets; + } + + public override Secret? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var resolver = new SecretsDecryptorResolver(_scope); + + if (reader.TokenType == JsonTokenType.StartObject) + { + using var doc = JsonDocument.ParseValue(ref reader); + var element = doc.RootElement; + + if (SecretEnvelopeWrapper.IsEnvelope(element)) + { + if (!SecretEnvelopeWrapper.TryParse(element, out var env) || env is null) + { + throw new JsonException($"Invalid secret envelope for Secret<{typeof(T).Name}>"); + } + + return new Secret(env, resolver); + } + + // Deserialize directly from JsonElement — never create an intermediate string. + // .NET strings are immutable and cannot be zeroed; plaintext secrets must stay as bytes. + var plainValue = element.Deserialize(options); + if (plainValue is null && !SecretNullabilityHelper.TypeAcceptsNull()) + { + throw new JsonException($"Failed to deserialize plain value for Secret<{typeof(T).Name}>"); + } + return new Secret(plainValue!, resolver, allowPlaintext: GetAllowPlaintextSetting()); + } + + // Handle explicit null token + if (reader.TokenType == JsonTokenType.Null) + { + if (!SecretNullabilityHelper.TypeAcceptsNull()) + { + throw new JsonException( + $"Cannot deserialize null to Secret<{typeof(T).Name}>. " + + $"Use Secret<{typeof(T).Name}?> if the value can be null."); + } + // For nullable types, create a Secret containing null + return new Secret(default!, resolver, allowPlaintext: GetAllowPlaintextSetting()); + } + + if (reader.TokenType == JsonTokenType.String || + reader.TokenType == JsonTokenType.Number || + reader.TokenType == JsonTokenType.True || + reader.TokenType == JsonTokenType.False) + { + using var tempDoc = JsonDocument.ParseValue(ref reader); + var plainValue = tempDoc.RootElement.Deserialize(options); + if (plainValue is null && !SecretNullabilityHelper.TypeAcceptsNull()) + { + throw new JsonException($"Failed to deserialize plain value for Secret<{typeof(T).Name}>"); + } + return new Secret(plainValue!, resolver, allowPlaintext: GetAllowPlaintextSetting()); + } + + throw new JsonException($"Unexpected token type '{reader.TokenType}' for Secret<{typeof(T).Name}>"); + } + + public override void Write(Utf8JsonWriter writer, Secret value, JsonSerializerOptions options) + { + writer.WriteStringValue("***"); + } +} diff --git a/src/Cocoar.Configuration/Secrets/Converters/SecretJsonConverterFactory.cs b/src/Cocoar.Configuration/Secrets/Converters/SecretJsonConverterFactory.cs index 02a6aba..b16fb60 100644 --- a/src/Cocoar.Configuration/Secrets/Converters/SecretJsonConverterFactory.cs +++ b/src/Cocoar.Configuration/Secrets/Converters/SecretJsonConverterFactory.cs @@ -1,40 +1,40 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Cocoar.Configuration.Core; - -namespace Cocoar.Configuration.Secrets.Converters; - -internal sealed class SecretJsonConverterFactory : JsonConverterFactory -{ - private readonly ConfigManagerCapabilityScope _scope; - - public SecretJsonConverterFactory(ConfigManagerCapabilityScope scope) - { - _scope = scope ?? throw new ArgumentNullException(nameof(scope)); - } - - public override bool CanConvert(Type typeToConvert) - { - if (!typeToConvert.IsGenericType) - return false; - - var genericTypeDef = typeToConvert.GetGenericTypeDefinition(); - return genericTypeDef == typeof(SecretTypes.Secret<>) || - genericTypeDef == typeof(SecretTypes.ISecret<>); - } - - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) - { - var genericTypeDef = typeToConvert.GetGenericTypeDefinition(); - var valueType = typeToConvert.GetGenericArguments()[0]; - - if (genericTypeDef == typeof(SecretTypes.ISecret<>)) - { - var converterType = typeof(ISecretJsonConverter<>).MakeGenericType(valueType); - return (JsonConverter?)Activator.CreateInstance(converterType, _scope); - } - - var secretConverterType = typeof(SecretJsonConverter<>).MakeGenericType(valueType); - return (JsonConverter?)Activator.CreateInstance(secretConverterType, _scope); - } -} +using System.Text.Json; +using System.Text.Json.Serialization; +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.Secrets.Converters; + +internal sealed class SecretJsonConverterFactory : JsonConverterFactory +{ + private readonly ConfigManagerCapabilityScope _scope; + + public SecretJsonConverterFactory(ConfigManagerCapabilityScope scope) + { + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + } + + public override bool CanConvert(Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + var genericTypeDef = typeToConvert.GetGenericTypeDefinition(); + return genericTypeDef == typeof(SecretTypes.Secret<>) || + genericTypeDef == typeof(SecretTypes.ISecret<>); + } + + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var genericTypeDef = typeToConvert.GetGenericTypeDefinition(); + var valueType = typeToConvert.GetGenericArguments()[0]; + + if (genericTypeDef == typeof(SecretTypes.ISecret<>)) + { + var converterType = typeof(ISecretJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)Activator.CreateInstance(converterType, _scope); + } + + var secretConverterType = typeof(SecretJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)Activator.CreateInstance(secretConverterType, _scope); + } +} diff --git a/src/Cocoar.Configuration/Secrets/Converters/SecretsSerializerSetupCapability.cs b/src/Cocoar.Configuration/Secrets/Converters/SecretsSerializerSetupCapability.cs index 09a36fc..a0b5cbc 100644 --- a/src/Cocoar.Configuration/Secrets/Converters/SecretsSerializerSetupCapability.cs +++ b/src/Cocoar.Configuration/Secrets/Converters/SecretsSerializerSetupCapability.cs @@ -1,23 +1,23 @@ -using System.Text.Json; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Extensibility; - -namespace Cocoar.Configuration.Secrets.Converters; - -/// -/// Capability that registers JSON converters for Secret types. -/// -internal sealed class SecretsSerializerSetup : ISerializerSetupCapability -{ - private readonly ConfigManagerCapabilityScope _scope; - - public SecretsSerializerSetup(ConfigManagerCapabilityScope scope) - { - _scope = scope ?? throw new ArgumentNullException(nameof(scope)); - } - - public void Configure(JsonSerializerOptions options) - { - options.Converters.Add(new SecretJsonConverterFactory(_scope)); - } -} +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Extensibility; + +namespace Cocoar.Configuration.Secrets.Converters; + +/// +/// Capability that registers JSON converters for Secret types. +/// +internal sealed class SecretsSerializerSetup : ISerializerSetupCapability +{ + private readonly ConfigManagerCapabilityScope _scope; + + public SecretsSerializerSetup(ConfigManagerCapabilityScope scope) + { + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + } + + public void Configure(JsonSerializerOptions options) + { + options.Converters.Add(new SecretJsonConverterFactory(_scope)); + } +} diff --git a/src/Cocoar.Configuration/Secrets/Core/ISecretProtectorCapability.cs b/src/Cocoar.Configuration/Secrets/Core/ISecretProtectorCapability.cs index a7780f6..4ab0b6b 100644 --- a/src/Cocoar.Configuration/Secrets/Core/ISecretProtectorCapability.cs +++ b/src/Cocoar.Configuration/Secrets/Core/ISecretProtectorCapability.cs @@ -1,5 +1,5 @@ -namespace Cocoar.Configuration.Secrets.Core; - -public interface IProtectorConfiguration -{ -} +namespace Cocoar.Configuration.Secrets.Core; + +public interface IProtectorConfiguration +{ +} diff --git a/src/Cocoar.Configuration/Secrets/Core/ISecretsSetupContributor.cs b/src/Cocoar.Configuration/Secrets/Core/ISecretsSetupContributor.cs index 4b31ebb..941852d 100644 --- a/src/Cocoar.Configuration/Secrets/Core/ISecretsSetupContributor.cs +++ b/src/Cocoar.Configuration/Secrets/Core/ISecretsSetupContributor.cs @@ -1,9 +1,9 @@ -using Cocoar.Capabilities; -using Cocoar.Configuration.Core; - -namespace Cocoar.Configuration.Secrets.Core; - -public interface ISecretsSetupContributor -{ - void Apply(ConfigManagerCapabilityScope scope, IComposition composition); -} +using Cocoar.Capabilities; +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.Secrets.Core; + +public interface ISecretsSetupContributor +{ + void Apply(ConfigManagerCapabilityScope scope, IComposition composition); +} diff --git a/src/Cocoar.Configuration/Secrets/Core/ProtectorInterfaces.cs b/src/Cocoar.Configuration/Secrets/Core/ProtectorInterfaces.cs index 7e3bf0c..7460a60 100644 --- a/src/Cocoar.Configuration/Secrets/Core/ProtectorInterfaces.cs +++ b/src/Cocoar.Configuration/Secrets/Core/ProtectorInterfaces.cs @@ -1,29 +1,29 @@ -namespace Cocoar.Configuration.Secrets.Core; - -public interface IEncryptedEnvelope -{ - byte[] Ciphertext { get; } -} - -internal interface IRuntimeSecretDecryptor -{ - bool CanDecrypt(string kid); - byte[] UnprotectInternal(IEncryptedEnvelope envelope, string kid); - IEncryptedEnvelope DeserializeEnvelope(string json); -} - -internal interface IRuntimeSecretEncryptor : IRuntimeSecretDecryptor -{ - IEncryptedEnvelope ProtectInternal(ReadOnlySpan plaintext, string kid); -} - -public interface ISecretDecryptor where TEnvelope : IEncryptedEnvelope -{ - bool CanDecrypt(string kid); - byte[] Unprotect(TEnvelope envelope, string kid); -} - -internal interface ISecretEncryptor : ISecretDecryptor where TEnvelope : IEncryptedEnvelope -{ - TEnvelope Protect(ReadOnlySpan plaintext, string kid); -} +namespace Cocoar.Configuration.Secrets.Core; + +public interface IEncryptedEnvelope +{ + byte[] Ciphertext { get; } +} + +internal interface IRuntimeSecretDecryptor +{ + bool CanDecrypt(string kid); + byte[] UnprotectInternal(IEncryptedEnvelope envelope, string kid); + IEncryptedEnvelope DeserializeEnvelope(string json); +} + +internal interface IRuntimeSecretEncryptor : IRuntimeSecretDecryptor +{ + IEncryptedEnvelope ProtectInternal(ReadOnlySpan plaintext, string kid); +} + +public interface ISecretDecryptor where TEnvelope : IEncryptedEnvelope +{ + bool CanDecrypt(string kid); + byte[] Unprotect(TEnvelope envelope, string kid); +} + +internal interface ISecretEncryptor : ISecretDecryptor where TEnvelope : IEncryptedEnvelope +{ + TEnvelope Protect(ReadOnlySpan plaintext, string kid); +} diff --git a/src/Cocoar.Configuration/Secrets/Core/SecretEnvelope.cs b/src/Cocoar.Configuration/Secrets/Core/SecretEnvelope.cs index 048d765..e734219 100644 --- a/src/Cocoar.Configuration/Secrets/Core/SecretEnvelope.cs +++ b/src/Cocoar.Configuration/Secrets/Core/SecretEnvelope.cs @@ -1,100 +1,100 @@ -using System.Text.Json; - -namespace Cocoar.Configuration.Secrets.Core; - -internal sealed class SecretEnvelopeWrapper -{ - // Core required fields present on every Cocoar secret envelope - public string Type { get; } - public int Version { get; } - public string? Alg { get; } - public string Kid { get; } - public DateTimeOffset? CreatedAt { get; } - public JsonElement Data { get; } - - public SecretEnvelopeWrapper(string type, int version, string? alg, string kid, JsonElement data, DateTimeOffset? createdAt) - { - Type = type; - Version = version; - Alg = alg; - Kid = kid; - Data = data; - CreatedAt = createdAt; - } - - public static bool IsEnvelope(JsonElement element) - { - if (element.ValueKind != JsonValueKind.Object) - { - return false; - } - - // Required discriminator fields - if (!element.TryGetProperty("type", out var typeProp) || typeProp.ValueKind != JsonValueKind.String) - { - return false; - } - - if (!element.TryGetProperty("version", out var versionProp) || versionProp.ValueKind != JsonValueKind.Number) - { - return false; - } - - var type = typeProp.GetString(); - var version = versionProp.GetInt32(); - - return string.Equals(type, "cocoar.secret", StringComparison.OrdinalIgnoreCase) && version == 1; - } - - public static bool TryParse(JsonElement element, out SecretEnvelopeWrapper? envelope) - { - envelope = null; - if (!IsEnvelope(element)) return false; - - string GetStr(string name) - => element.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String - ? p.GetString()! - : throw new FormatException($"Missing or invalid '{name}' in secret envelope"); - - string? GetOptionalStr(string name) - => element.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String - ? p.GetString() - : null; - - var type = GetStr("type"); - - if (!element.TryGetProperty("version", out var versionProp) || versionProp.ValueKind != JsonValueKind.Number) - { - throw new FormatException("Missing or invalid 'version' in secret envelope"); - } - - var version = versionProp.GetInt32(); - var alg = GetOptionalStr("alg"); - var kid = GetStr("kid"); - - DateTimeOffset? createdAt = null; - if (element.TryGetProperty("createdAt", out var ts) && ts.ValueKind == JsonValueKind.String) - { - if (DateTimeOffset.TryParse(ts.GetString(), out var dto)) - { - createdAt = dto; - } - } - - // Everything except the well-known metadata properties is treated as data - var dataObject = new Dictionary(); - foreach (var prop in element.EnumerateObject()) - { - if (prop.Name == "type" || prop.Name == "version" || prop.Name == "alg" || prop.Name == "kid" || prop.Name == "createdAt") - continue; - - dataObject[prop.Name] = prop.Value; - } - - var dataJson = JsonSerializer.Serialize(dataObject); - var dataElement = JsonDocument.Parse(dataJson).RootElement; - - envelope = new SecretEnvelopeWrapper(type, version, alg, kid, dataElement, createdAt); - return true; - } -} +using System.Text.Json; + +namespace Cocoar.Configuration.Secrets.Core; + +internal sealed class SecretEnvelopeWrapper +{ + // Core required fields present on every Cocoar secret envelope + public string Type { get; } + public int Version { get; } + public string? Alg { get; } + public string Kid { get; } + public DateTimeOffset? CreatedAt { get; } + public JsonElement Data { get; } + + public SecretEnvelopeWrapper(string type, int version, string? alg, string kid, JsonElement data, DateTimeOffset? createdAt) + { + Type = type; + Version = version; + Alg = alg; + Kid = kid; + Data = data; + CreatedAt = createdAt; + } + + public static bool IsEnvelope(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + { + return false; + } + + // Required discriminator fields + if (!element.TryGetProperty("type", out var typeProp) || typeProp.ValueKind != JsonValueKind.String) + { + return false; + } + + if (!element.TryGetProperty("version", out var versionProp) || versionProp.ValueKind != JsonValueKind.Number) + { + return false; + } + + var type = typeProp.GetString(); + var version = versionProp.GetInt32(); + + return string.Equals(type, "cocoar.secret", StringComparison.OrdinalIgnoreCase) && version == 1; + } + + public static bool TryParse(JsonElement element, out SecretEnvelopeWrapper? envelope) + { + envelope = null; + if (!IsEnvelope(element)) return false; + + string GetStr(string name) + => element.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String + ? p.GetString()! + : throw new FormatException($"Missing or invalid '{name}' in secret envelope"); + + string? GetOptionalStr(string name) + => element.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String + ? p.GetString() + : null; + + var type = GetStr("type"); + + if (!element.TryGetProperty("version", out var versionProp) || versionProp.ValueKind != JsonValueKind.Number) + { + throw new FormatException("Missing or invalid 'version' in secret envelope"); + } + + var version = versionProp.GetInt32(); + var alg = GetOptionalStr("alg"); + var kid = GetStr("kid"); + + DateTimeOffset? createdAt = null; + if (element.TryGetProperty("createdAt", out var ts) && ts.ValueKind == JsonValueKind.String) + { + if (DateTimeOffset.TryParse(ts.GetString(), out var dto)) + { + createdAt = dto; + } + } + + // Everything except the well-known metadata properties is treated as data + var dataObject = new Dictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.Name == "type" || prop.Name == "version" || prop.Name == "alg" || prop.Name == "kid" || prop.Name == "createdAt") + continue; + + dataObject[prop.Name] = prop.Value; + } + + var dataJson = JsonSerializer.Serialize(dataObject); + var dataElement = JsonDocument.Parse(dataJson).RootElement; + + envelope = new SecretEnvelopeWrapper(type, version, alg, kid, dataElement, createdAt); + return true; + } +} diff --git a/src/Cocoar.Configuration/Secrets/Core/SecretProtectorAdapters.cs b/src/Cocoar.Configuration/Secrets/Core/SecretProtectorAdapters.cs index 18871ef..1d0f9da 100644 --- a/src/Cocoar.Configuration/Secrets/Core/SecretProtectorAdapters.cs +++ b/src/Cocoar.Configuration/Secrets/Core/SecretProtectorAdapters.cs @@ -1,65 +1,65 @@ -using System.Text.Json; -using Cocoar.Configuration.Secrets.Converters; - -namespace Cocoar.Configuration.Secrets.Core; - -/// -/// Adapter that wraps a public ISecretDecryptor<TEnvelope> to work with the runtime interface. -/// Bridges the generic public API to the non-generic runtime interface. -/// -internal sealed class PublicToRuntimeDecryptorAdapter : IRuntimeSecretDecryptor - where TEnvelope : IEncryptedEnvelope -{ - private readonly ISecretDecryptor _decryptor; - - // Shared JsonSerializerOptions with Base64UrlByteArrayConverter - private static readonly JsonSerializerOptions SerializerOptions = new() - { - Converters = { new Base64UrlByteArrayConverter() } - }; - - public PublicToRuntimeDecryptorAdapter(ISecretDecryptor decryptor) - { - _decryptor = decryptor ?? throw new ArgumentNullException(nameof(decryptor)); - } - - public bool CanDecrypt(string kid) => _decryptor.CanDecrypt(kid); - - public byte[] UnprotectInternal(IEncryptedEnvelope envelope, string kid) - => _decryptor.Unprotect((TEnvelope)envelope, kid); - - public IEncryptedEnvelope DeserializeEnvelope(string json) - => JsonSerializer.Deserialize(json, SerializerOptions)!; -} - -/// -/// Adapter that wraps a public ISecretEncryptor<TEnvelope> to work with the runtime interface. -/// Bridges the generic public API to the non-generic runtime interface. -/// -internal sealed class PublicToRuntimeEncryptorAdapter : IRuntimeSecretEncryptor - where TEnvelope : IEncryptedEnvelope -{ - private readonly ISecretEncryptor _encryptor; - - // Shared JsonSerializerOptions with Base64UrlByteArrayConverter - private static readonly JsonSerializerOptions SerializerOptions = new() - { - Converters = { new Base64UrlByteArrayConverter() } - }; - - public PublicToRuntimeEncryptorAdapter(ISecretEncryptor encryptor) - { - _encryptor = encryptor ?? throw new ArgumentNullException(nameof(encryptor)); - } - - public bool CanDecrypt(string kid) => _encryptor.CanDecrypt(kid); - - public IEncryptedEnvelope ProtectInternal(ReadOnlySpan plaintext, string kid) - => _encryptor.Protect(plaintext, kid); - - public byte[] UnprotectInternal(IEncryptedEnvelope envelope, string kid) - => _encryptor.Unprotect((TEnvelope)envelope, kid); - - public IEncryptedEnvelope DeserializeEnvelope(string json) - => JsonSerializer.Deserialize(json, SerializerOptions)!; -} +using System.Text.Json; +using Cocoar.Configuration.Secrets.Converters; + +namespace Cocoar.Configuration.Secrets.Core; + +/// +/// Adapter that wraps a public ISecretDecryptor<TEnvelope> to work with the runtime interface. +/// Bridges the generic public API to the non-generic runtime interface. +/// +internal sealed class PublicToRuntimeDecryptorAdapter : IRuntimeSecretDecryptor + where TEnvelope : IEncryptedEnvelope +{ + private readonly ISecretDecryptor _decryptor; + + // Shared JsonSerializerOptions with Base64UrlByteArrayConverter + private static readonly JsonSerializerOptions SerializerOptions = new() + { + Converters = { new Base64UrlByteArrayConverter() } + }; + + public PublicToRuntimeDecryptorAdapter(ISecretDecryptor decryptor) + { + _decryptor = decryptor ?? throw new ArgumentNullException(nameof(decryptor)); + } + + public bool CanDecrypt(string kid) => _decryptor.CanDecrypt(kid); + + public byte[] UnprotectInternal(IEncryptedEnvelope envelope, string kid) + => _decryptor.Unprotect((TEnvelope)envelope, kid); + + public IEncryptedEnvelope DeserializeEnvelope(string json) + => JsonSerializer.Deserialize(json, SerializerOptions)!; +} + +/// +/// Adapter that wraps a public ISecretEncryptor<TEnvelope> to work with the runtime interface. +/// Bridges the generic public API to the non-generic runtime interface. +/// +internal sealed class PublicToRuntimeEncryptorAdapter : IRuntimeSecretEncryptor + where TEnvelope : IEncryptedEnvelope +{ + private readonly ISecretEncryptor _encryptor; + + // Shared JsonSerializerOptions with Base64UrlByteArrayConverter + private static readonly JsonSerializerOptions SerializerOptions = new() + { + Converters = { new Base64UrlByteArrayConverter() } + }; + + public PublicToRuntimeEncryptorAdapter(ISecretEncryptor encryptor) + { + _encryptor = encryptor ?? throw new ArgumentNullException(nameof(encryptor)); + } + + public bool CanDecrypt(string kid) => _encryptor.CanDecrypt(kid); + + public IEncryptedEnvelope ProtectInternal(ReadOnlySpan plaintext, string kid) + => _encryptor.Protect(plaintext, kid); + + public byte[] UnprotectInternal(IEncryptedEnvelope envelope, string kid) + => _encryptor.Unprotect((TEnvelope)envelope, kid); + + public IEncryptedEnvelope DeserializeEnvelope(string json) + => JsonSerializer.Deserialize(json, SerializerOptions)!; +} diff --git a/src/Cocoar.Configuration/Secrets/Core/SecretsDecryptorResolver.cs b/src/Cocoar.Configuration/Secrets/Core/SecretsDecryptorResolver.cs index eed5986..5404e32 100644 --- a/src/Cocoar.Configuration/Secrets/Core/SecretsDecryptorResolver.cs +++ b/src/Cocoar.Configuration/Secrets/Core/SecretsDecryptorResolver.cs @@ -1,136 +1,136 @@ -using System.Security.Cryptography; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Secrets.Exceptions; - -namespace Cocoar.Configuration.Secrets.Core; - -internal sealed class SecretsDecryptorResolver -{ - private readonly ConfigManagerCapabilityScope _scope; - - public SecretsDecryptorResolver(ConfigManagerCapabilityScope scope) - { - _scope = scope ?? throw new ArgumentNullException(nameof(scope)); - } - - public IConfigurationAccessor ConfigAccessor => _scope.Owner.Get(); - - public IRuntimeSecretDecryptor ResolveForKid(string kid) - { - var composition = _scope.Owner.GetComposition(); - var allDecryptors = composition?.GetAll() ?? Enumerable.Empty(); - - var capableDecryptors = allDecryptors - .Where(d => d.CanDecrypt(kid)) - .ToList(); - - if (capableDecryptors.Count == 0) - { - // Get available kids for better error message - var availableKids = GetAvailableKids(allDecryptors); - - // If no decryptors at all, provide setup guidance - if (!allDecryptors.Any()) - { - throw new InvalidOperationException( - $"Cannot decrypt Secret with kid '{kid}': no certificates configured.\n\n" + - "To fix, configure a certificate in your secrets setup:\n\n" + - " .UseSecretsSetup(secrets => secrets\n" + - " .UseCertificateFromFile(\"path/to/cert.pfx\")\n" + - " .WithKeyId(\"your-key-id\"))\n\n" + - "Or use a certificate folder:\n\n" + - " .UseSecretsSetup(secrets => secrets\n" + - " .UseCertificatesFromFolder(\"path/to/certs\"))"); - } - - throw SecretDecryptionException.KidNotFound(kid, "RSA-OAEP-AES256-GCM", availableKids); - } - - return capableDecryptors.Count == 1 - ? capableDecryptors[0] - : new MultiKeyDecryptor(kid, capableDecryptors); - } - - /// - /// Gets all kid values that can be decrypted by registered protectors. - /// Used for diagnostic error messages. - /// - public string[] GetAvailableKids() - { - var composition = _scope.Owner.GetComposition(); - var allDecryptors = composition?.GetAll() ?? Enumerable.Empty(); - return GetAvailableKids(allDecryptors); - } - - private static string[] GetAvailableKids(IEnumerable decryptors) - { - // Note: We can't easily enumerate all kids without trying them - // This is a limitation of the current CanDecrypt(kid) API - // For now, we'll return a placeholder indicating we have decryptors registered - var count = decryptors.Count(); - return count > 0 - ? new[] { $"{count} protector(s) registered - check logs for loaded certificates" } - : Array.Empty(); - } -} - -internal sealed class MultiKeyDecryptor : IRuntimeSecretEncryptor -{ - private readonly string _kid; - private readonly List _inner; - - public MultiKeyDecryptor(string kid, IEnumerable inner) - { - _kid = kid; - _inner = inner?.ToList() ?? throw new ArgumentNullException(nameof(inner)); - if (_inner.Count == 0) - throw new ArgumentException("Multi-key decryptor requires at least one inner decryptor", nameof(inner)); - } - - public bool CanDecrypt(string kid) => string.Equals(_kid, kid, StringComparison.OrdinalIgnoreCase); - - public IEncryptedEnvelope ProtectInternal(ReadOnlySpan plaintext, string kid) - { - if (_inner[_inner.Count - 1] is IRuntimeSecretEncryptor encryptor) - { - return encryptor.ProtectInternal(plaintext, kid); - } - throw new NotSupportedException( - $"The most recent decryptor for kid '{kid}' does not support encryption."); - } - - public byte[] UnprotectInternal(IEncryptedEnvelope envelope, string kid) - { - Exception? lastException = null; - for (int i = _inner.Count - 1; i >= 0; i--) - { - var p = _inner[i]; - try - { - return p.UnprotectInternal(envelope, kid); - } - catch (CryptographicException ex) - { - lastException = ex; - } - catch (NotSupportedException ex) - { - lastException = ex; - } - } - - // All decryptors that claimed they could handle this kid failed - // Try to get algorithm from envelope if it's a HybridEnvelope - var algorithm = envelope is Protectors.Hybrid.HybridEnvelope hybridEnv - ? hybridEnv.WrappingAlgorithm - : "RSA-OAEP-AES256-GCM"; - - throw SecretDecryptionException.DecryptionFailed( - kid, - algorithm, - lastException ?? new CryptographicException("All registered decryptors failed")); - } - - public IEncryptedEnvelope DeserializeEnvelope(string json) => - _inner[_inner.Count - 1].DeserializeEnvelope(json); -} +using System.Security.Cryptography; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Secrets.Exceptions; + +namespace Cocoar.Configuration.Secrets.Core; + +internal sealed class SecretsDecryptorResolver +{ + private readonly ConfigManagerCapabilityScope _scope; + + public SecretsDecryptorResolver(ConfigManagerCapabilityScope scope) + { + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + } + + public IConfigurationAccessor ConfigAccessor => _scope.Owner.Get(); + + public IRuntimeSecretDecryptor ResolveForKid(string kid) + { + var composition = _scope.Owner.GetComposition(); + var allDecryptors = composition?.GetAll() ?? Enumerable.Empty(); + + var capableDecryptors = allDecryptors + .Where(d => d.CanDecrypt(kid)) + .ToList(); + + if (capableDecryptors.Count == 0) + { + // Get available kids for better error message + var availableKids = GetAvailableKids(allDecryptors); + + // If no decryptors at all, provide setup guidance + if (!allDecryptors.Any()) + { + throw new InvalidOperationException( + $"Cannot decrypt Secret with kid '{kid}': no certificates configured.\n\n" + + "To fix, configure a certificate in your secrets setup:\n\n" + + " .UseSecretsSetup(secrets => secrets\n" + + " .UseCertificateFromFile(\"path/to/cert.pfx\")\n" + + " .WithKeyId(\"your-key-id\"))\n\n" + + "Or use a certificate folder:\n\n" + + " .UseSecretsSetup(secrets => secrets\n" + + " .UseCertificatesFromFolder(\"path/to/certs\"))"); + } + + throw SecretDecryptionException.KidNotFound(kid, "RSA-OAEP-AES256-GCM", availableKids); + } + + return capableDecryptors.Count == 1 + ? capableDecryptors[0] + : new MultiKeyDecryptor(kid, capableDecryptors); + } + + /// + /// Gets all kid values that can be decrypted by registered protectors. + /// Used for diagnostic error messages. + /// + public string[] GetAvailableKids() + { + var composition = _scope.Owner.GetComposition(); + var allDecryptors = composition?.GetAll() ?? Enumerable.Empty(); + return GetAvailableKids(allDecryptors); + } + + private static string[] GetAvailableKids(IEnumerable decryptors) + { + // Note: We can't easily enumerate all kids without trying them + // This is a limitation of the current CanDecrypt(kid) API + // For now, we'll return a placeholder indicating we have decryptors registered + var count = decryptors.Count(); + return count > 0 + ? new[] { $"{count} protector(s) registered - check logs for loaded certificates" } + : Array.Empty(); + } +} + +internal sealed class MultiKeyDecryptor : IRuntimeSecretEncryptor +{ + private readonly string _kid; + private readonly List _inner; + + public MultiKeyDecryptor(string kid, IEnumerable inner) + { + _kid = kid; + _inner = inner?.ToList() ?? throw new ArgumentNullException(nameof(inner)); + if (_inner.Count == 0) + throw new ArgumentException("Multi-key decryptor requires at least one inner decryptor", nameof(inner)); + } + + public bool CanDecrypt(string kid) => string.Equals(_kid, kid, StringComparison.OrdinalIgnoreCase); + + public IEncryptedEnvelope ProtectInternal(ReadOnlySpan plaintext, string kid) + { + if (_inner[_inner.Count - 1] is IRuntimeSecretEncryptor encryptor) + { + return encryptor.ProtectInternal(plaintext, kid); + } + throw new NotSupportedException( + $"The most recent decryptor for kid '{kid}' does not support encryption."); + } + + public byte[] UnprotectInternal(IEncryptedEnvelope envelope, string kid) + { + Exception? lastException = null; + for (int i = _inner.Count - 1; i >= 0; i--) + { + var p = _inner[i]; + try + { + return p.UnprotectInternal(envelope, kid); + } + catch (CryptographicException ex) + { + lastException = ex; + } + catch (NotSupportedException ex) + { + lastException = ex; + } + } + + // All decryptors that claimed they could handle this kid failed + // Try to get algorithm from envelope if it's a HybridEnvelope + var algorithm = envelope is Protectors.Hybrid.HybridEnvelope hybridEnv + ? hybridEnv.WrappingAlgorithm + : "RSA-OAEP-AES256-GCM"; + + throw SecretDecryptionException.DecryptionFailed( + kid, + algorithm, + lastException ?? new CryptographicException("All registered decryptors failed")); + } + + public IEncryptedEnvelope DeserializeEnvelope(string json) => + _inner[_inner.Count - 1].DeserializeEnvelope(json); +} diff --git a/src/Cocoar.Configuration/Secrets/Core/SecretsPolicy.cs b/src/Cocoar.Configuration/Secrets/Core/SecretsPolicy.cs index 233fbce..e84876a 100644 --- a/src/Cocoar.Configuration/Secrets/Core/SecretsPolicy.cs +++ b/src/Cocoar.Configuration/Secrets/Core/SecretsPolicy.cs @@ -1,22 +1,22 @@ -namespace Cocoar.Configuration.Secrets.Core; - -/// -/// Configuration policy for secrets deserialization behavior. -/// -public sealed record SecretsPolicy -{ - /// - /// Gets the default secrets policy with all security protections enabled. - /// - public static SecretsPolicy Default { get; } = new(); - - /// - /// When true, allows Secret<T> to be deserialized from plaintext JSON values - /// without throwing on .Open(). - /// - /// SECURITY WARNING: Only enable this in development/test environments. - /// Production configurations should always use encrypted envelopes. - /// - /// - public bool AllowPlaintextSecrets { get; init; } -} +namespace Cocoar.Configuration.Secrets.Core; + +/// +/// Configuration policy for secrets deserialization behavior. +/// +public sealed record SecretsPolicy +{ + /// + /// Gets the default secrets policy with all security protections enabled. + /// + public static SecretsPolicy Default { get; } = new(); + + /// + /// When true, allows Secret<T> to be deserialized from plaintext JSON values + /// without throwing on .Open(). + /// + /// SECURITY WARNING: Only enable this in development/test environments. + /// Production configurations should always use encrypted envelopes. + /// + /// + public bool AllowPlaintextSecrets { get; init; } +} diff --git a/src/Cocoar.Configuration/Secrets/Core/SecretsSetupDeferredConfiguration.cs b/src/Cocoar.Configuration/Secrets/Core/SecretsSetupDeferredConfiguration.cs index f9c8d42..6b758c7 100644 --- a/src/Cocoar.Configuration/Secrets/Core/SecretsSetupDeferredConfiguration.cs +++ b/src/Cocoar.Configuration/Secrets/Core/SecretsSetupDeferredConfiguration.cs @@ -1,25 +1,25 @@ -using Cocoar.Capabilities; -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Core; - -namespace Cocoar.Configuration.Secrets.Core; - -public sealed class SecretsSetupDeferredConfiguration : IDeferredConfiguration -{ - private readonly ConfigManagerCapabilityScope _capabilityScope; - - internal SecretsSetupDeferredConfiguration(ConfigManagerCapabilityScope capabilityScope) - { - _capabilityScope = capabilityScope ?? throw new ArgumentNullException(nameof(capabilityScope)); - } - - public void Apply() - { - var composition = _capabilityScope.Owner.GetRequiredComposition(); - - // Apply all registered setup contributors (AutoProtect, HybridProtector, etc.) - composition.UsingEach(contributor => - contributor.Apply(_capabilityScope, composition)); - } - -} +using Cocoar.Capabilities; +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.Secrets.Core; + +public sealed class SecretsSetupDeferredConfiguration : IDeferredConfiguration +{ + private readonly ConfigManagerCapabilityScope _capabilityScope; + + internal SecretsSetupDeferredConfiguration(ConfigManagerCapabilityScope capabilityScope) + { + _capabilityScope = capabilityScope ?? throw new ArgumentNullException(nameof(capabilityScope)); + } + + public void Apply() + { + var composition = _capabilityScope.Owner.GetRequiredComposition(); + + // Apply all registered setup contributors (AutoProtect, HybridProtector, etc.) + composition.UsingEach(contributor => + contributor.Apply(_capabilityScope, composition)); + } + +} diff --git a/src/Cocoar.Configuration/Secrets/Exceptions/SecretDecryptionException.cs b/src/Cocoar.Configuration/Secrets/Exceptions/SecretDecryptionException.cs index 5f9fe8a..15ae6e9 100644 --- a/src/Cocoar.Configuration/Secrets/Exceptions/SecretDecryptionException.cs +++ b/src/Cocoar.Configuration/Secrets/Exceptions/SecretDecryptionException.cs @@ -1,116 +1,116 @@ -using System; - -namespace Cocoar.Configuration.Secrets.Exceptions; - -/// -/// Exception thrown when secret decryption fails with detailed diagnostic context. -/// -public class SecretDecryptionException : InvalidOperationException -{ - /// - /// The Key ID (kid) that was attempted for decryption. - /// - public string? AttemptedKid { get; } - - /// - /// The encryption algorithm from the envelope. - /// - public string? Algorithm { get; } - - /// - /// List of Key IDs that are currently available in the protector registry. - /// - public string[]? AvailableKids { get; } - - /// - /// Creates a new SecretDecryptionException with detailed context. - /// - /// The error message. - /// The kid that was attempted. - /// The algorithm from the envelope. - /// List of available kids. - /// The underlying exception. - public SecretDecryptionException( - string message, - string? attemptedKid = null, - string? algorithm = null, - string[]? availableKids = null, - Exception? innerException = null) - : base(message, innerException) - { - AttemptedKid = attemptedKid; - Algorithm = algorithm; - AvailableKids = availableKids; - } - - /// - /// Creates a detailed error message for kid mismatch scenarios. - /// - public static SecretDecryptionException KidNotFound( - string attemptedKid, - string algorithm, - string[] availableKids) - { - var message = $"Failed to decrypt secret with kid '{attemptedKid}'.\n\n" + - $"Possible causes:\n" + - $" 1. Certificate with kid '{attemptedKid}' not registered\n" + - $" 2. Certificate was removed or rotated without updating secrets\n" + - $" 3. Wrong environment (dev cert used in production)\n\n" + - $"Envelope algorithm: {algorithm}\n" + - $"Available kids: {(availableKids.Length > 0 ? string.Join(", ", availableKids) : "(none)")}\n\n" + - $"To fix:\n" + - $" • Register the missing certificate via .UseCertificateFromFile() or .UseCertificatesFromFolder()\n" + - $" • Re-encrypt secrets with an available kid using: cocoar-secrets rotate"; - - return new SecretDecryptionException(message, attemptedKid, algorithm, availableKids); - } - - /// - /// Creates a detailed error message for decryption failures. - /// - public static SecretDecryptionException DecryptionFailed( - string attemptedKid, - string algorithm, - Exception innerException) - { - var message = $"Failed to decrypt secret with kid '{attemptedKid}'.\n\n" + - $"Possible causes:\n" + - $" 1. Certificate password incorrect\n" + - $" 2. Certificate lacks private key\n" + - $" 3. Envelope data corrupted or tampered\n" + - $" 4. Wrong certificate (thumbprint mismatch)\n\n" + - $"Envelope algorithm: {algorithm}\n" + - $"Inner error: {innerException.Message}\n\n" + - $"To fix:\n" + - $" • Verify certificate password is correct\n" + - $" • Check certificate has private key: openssl pkcs12 -info -in cert.pfx\n" + - $" • Verify envelope was encrypted with this certificate's public key"; - - return new SecretDecryptionException(message, attemptedKid, algorithm, null, innerException); - } - - /// - /// Creates a detailed error message for invalid envelope format. - /// - public static SecretDecryptionException InvalidEnvelope(string reason) - { - var message = $"Invalid secret envelope format.\n\n" + - $"Reason: {reason}\n\n" + - $"Expected envelope structure:\n" + - $" {{\n" + - $" \"__cocoar_secret__\": \"v1\",\n" + - $" \"kid\": \"certificate-key-id\",\n" + - $" \"alg\": \"RSA-OAEP-AES256-GCM\",\n" + - $" \"type\": \"utf8\",\n" + - $" \"iv\": \"base64-encoded-iv\",\n" + - $" \"ct\": \"base64-encoded-ciphertext\",\n" + - $" \"tag\": \"base64-encoded-tag\",\n" + - $" \"wk\": \"base64-encoded-wrapped-key\"\n" + - $" }}\n\n" + - $"To fix:\n" + - $" • Verify envelope was created with: cocoar-secrets encrypt\n" + - $" • Check for manual JSON editing errors"; - - return new SecretDecryptionException(message); - } -} +using System; + +namespace Cocoar.Configuration.Secrets.Exceptions; + +/// +/// Exception thrown when secret decryption fails with detailed diagnostic context. +/// +public class SecretDecryptionException : InvalidOperationException +{ + /// + /// The Key ID (kid) that was attempted for decryption. + /// + public string? AttemptedKid { get; } + + /// + /// The encryption algorithm from the envelope. + /// + public string? Algorithm { get; } + + /// + /// List of Key IDs that are currently available in the protector registry. + /// + public string[]? AvailableKids { get; } + + /// + /// Creates a new SecretDecryptionException with detailed context. + /// + /// The error message. + /// The kid that was attempted. + /// The algorithm from the envelope. + /// List of available kids. + /// The underlying exception. + public SecretDecryptionException( + string message, + string? attemptedKid = null, + string? algorithm = null, + string[]? availableKids = null, + Exception? innerException = null) + : base(message, innerException) + { + AttemptedKid = attemptedKid; + Algorithm = algorithm; + AvailableKids = availableKids; + } + + /// + /// Creates a detailed error message for kid mismatch scenarios. + /// + public static SecretDecryptionException KidNotFound( + string attemptedKid, + string algorithm, + string[] availableKids) + { + var message = $"Failed to decrypt secret with kid '{attemptedKid}'.\n\n" + + $"Possible causes:\n" + + $" 1. Certificate with kid '{attemptedKid}' not registered\n" + + $" 2. Certificate was removed or rotated without updating secrets\n" + + $" 3. Wrong environment (dev cert used in production)\n\n" + + $"Envelope algorithm: {algorithm}\n" + + $"Available kids: {(availableKids.Length > 0 ? string.Join(", ", availableKids) : "(none)")}\n\n" + + $"To fix:\n" + + $" • Register the missing certificate via .UseCertificateFromFile() or .UseCertificatesFromFolder()\n" + + $" • Re-encrypt secrets with an available kid using: cocoar-secrets rotate"; + + return new SecretDecryptionException(message, attemptedKid, algorithm, availableKids); + } + + /// + /// Creates a detailed error message for decryption failures. + /// + public static SecretDecryptionException DecryptionFailed( + string attemptedKid, + string algorithm, + Exception innerException) + { + var message = $"Failed to decrypt secret with kid '{attemptedKid}'.\n\n" + + $"Possible causes:\n" + + $" 1. Certificate password incorrect\n" + + $" 2. Certificate lacks private key\n" + + $" 3. Envelope data corrupted or tampered\n" + + $" 4. Wrong certificate (thumbprint mismatch)\n\n" + + $"Envelope algorithm: {algorithm}\n" + + $"Inner error: {innerException.Message}\n\n" + + $"To fix:\n" + + $" • Verify certificate password is correct\n" + + $" • Check certificate has private key: openssl pkcs12 -info -in cert.pfx\n" + + $" • Verify envelope was encrypted with this certificate's public key"; + + return new SecretDecryptionException(message, attemptedKid, algorithm, null, innerException); + } + + /// + /// Creates a detailed error message for invalid envelope format. + /// + public static SecretDecryptionException InvalidEnvelope(string reason) + { + var message = $"Invalid secret envelope format.\n\n" + + $"Reason: {reason}\n\n" + + $"Expected envelope structure:\n" + + $" {{\n" + + $" \"__cocoar_secret__\": \"v1\",\n" + + $" \"kid\": \"certificate-key-id\",\n" + + $" \"alg\": \"RSA-OAEP-AES256-GCM\",\n" + + $" \"type\": \"utf8\",\n" + + $" \"iv\": \"base64-encoded-iv\",\n" + + $" \"ct\": \"base64-encoded-ciphertext\",\n" + + $" \"tag\": \"base64-encoded-tag\",\n" + + $" \"wk\": \"base64-encoded-wrapped-key\"\n" + + $" }}\n\n" + + $"To fix:\n" + + $" • Verify envelope was created with: cocoar-secrets encrypt\n" + + $" • Check for manual JSON editing errors"; + + return new SecretDecryptionException(message); + } +} diff --git a/src/Cocoar.Configuration/Secrets/Helpers/CertificateHelper.cs b/src/Cocoar.Configuration/Secrets/Helpers/CertificateHelper.cs index 5881f77..a5db583 100644 --- a/src/Cocoar.Configuration/Secrets/Helpers/CertificateHelper.cs +++ b/src/Cocoar.Configuration/Secrets/Helpers/CertificateHelper.cs @@ -1,209 +1,209 @@ -using System.Security.Cryptography.X509Certificates; - -namespace Cocoar.Configuration.Secrets.Helpers; - -/// -/// Helper methods for loading and querying X.509 certificates. -/// Certificate generation is available via . -/// -public static class CertificateHelper -{ - public static X509Certificate2 LoadFromFile( - string path, - string? password = null, - X509KeyStorageFlags? keyStorageFlags = null) - { - if (string.IsNullOrWhiteSpace(path)) - throw new ArgumentException("Path is required", nameof(path)); - if (!File.Exists(path)) - throw new FileNotFoundException($"Certificate file not found: {path}", path); - - var extension = Path.GetExtension(path).ToLowerInvariant(); - var flags = keyStorageFlags ?? GetPlatformDefaultKeyStorageFlags(); - - X509Certificate2 cert = extension switch - { - ".pfx" or ".p12" => LoadPkcs12Certificate(path, password, flags), - ".pem" or ".crt" or ".cer" => LoadPemCertificate(path, flags), - _ => throw new NotSupportedException( - $"Certificate format '{extension}' is not supported. " + - $"Supported formats: .pfx, .p12 (PKCS#12), .pem, .crt, .cer (PEM with matching .key file)") - }; - - // Validate certificate expiration - ValidateCertificateExpiration(cert, path); - - // Validate certificate security properties - ValidateCertificateSecurity(cert, path); - - if (!cert.HasPrivateKey) - { - throw new InvalidOperationException( - $"The certificate at '{path}' does not contain a private key. " + - $"For PEM certificates, ensure a matching private key file exists (e.g., {Path.ChangeExtension(path, ".key")})."); - } - - return cert; - } - - private static X509Certificate2 LoadPkcs12Certificate(string path, string? password, X509KeyStorageFlags flags) - { -#if NET9_0_OR_GREATER - return X509CertificateLoader.LoadPkcs12FromFile(path, password, flags); -#else - return new X509Certificate2(path, password, flags); -#endif - } - - private static X509Certificate2 LoadPemCertificate(string certPath, X509KeyStorageFlags flags) - { - // PEM certificates require a separate private key file - var directory = Path.GetDirectoryName(certPath) ?? "."; - var fileNameWithoutExt = Path.GetFileNameWithoutExtension(certPath); - - // Look for matching .key file with same base name - var keyPath = Path.Combine(directory, $"{fileNameWithoutExt}.key"); - - if (!File.Exists(keyPath)) - { - throw new FileNotFoundException( - $"PEM certificate requires a matching private key file. Expected: {keyPath}", - keyPath); - } - - // Load certificate and private key from PEM files - var certPem = File.ReadAllText(certPath); - var keyPem = File.ReadAllText(keyPath); - - return X509Certificate2.CreateFromPem(certPem, keyPem); - } - - private static X509KeyStorageFlags GetPlatformDefaultKeyStorageFlags() - { - if (OperatingSystem.IsMacOS()) - { - // macOS requires PersistKeySet due to keychain behavior - return X509KeyStorageFlags.PersistKeySet; - } - - if (OperatingSystem.IsLinux()) - { - // Linux prefers MachineKeySet for system-wide certs - return X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.EphemeralKeySet; - } - - // Windows default - ephemeral for security - return X509KeyStorageFlags.EphemeralKeySet; - } - - public static X509Certificate2 FindByThumbprint( - string thumbprint, - StoreName storeName = StoreName.My, - StoreLocation storeLocation = StoreLocation.CurrentUser) - { - if (string.IsNullOrWhiteSpace(thumbprint)) - throw new ArgumentException("Thumbprint is required", nameof(thumbprint)); - - using var store = new X509Store(storeName, storeLocation); - store.Open(OpenFlags.ReadOnly); - - var matches = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false); - var cert = matches.Cast().FirstOrDefault(c => c is not null && c.HasPrivateKey); - - if (cert is null) - { - throw new InvalidOperationException( - $"Certificate with thumbprint '{thumbprint}' not found or missing private key in {storeLocation}/{storeName}."); - } - - return cert; - } - - public static X509Certificate2 FindBySubject( - string subjectDistinguishedName, - StoreName storeName = StoreName.My, - StoreLocation storeLocation = StoreLocation.CurrentUser) - { - if (string.IsNullOrWhiteSpace(subjectDistinguishedName)) - throw new ArgumentException("Subject distinguished name is required", nameof(subjectDistinguishedName)); - - using var store = new X509Store(storeName, storeLocation); - store.Open(OpenFlags.ReadOnly); - - var matches = store.Certificates.Find( - X509FindType.FindBySubjectDistinguishedName, - subjectDistinguishedName, - validOnly: false); - - var cert = matches.Cast().FirstOrDefault(c => c is not null && c.HasPrivateKey); - - if (cert is null) - { - throw new InvalidOperationException( - $"Certificate with subject '{subjectDistinguishedName}' not found or missing private key in {storeLocation}/{storeName}."); - } - - return cert; - } - - private static void ValidateCertificateExpiration(X509Certificate2 cert, string path) - { - var now = DateTime.UtcNow; - - // Warn if already expired - if (cert.NotAfter < now) - { - var daysExpired = (now - cert.NotAfter).Days; - Console.WriteLine( - $"WARNING: Certificate '{cert.Subject}' EXPIRED {daysExpired} days ago " + - $"(Expired: {cert.NotAfter:yyyy-MM-dd}). Rotation urgently needed. " + - $"Path: {path}, Thumbprint: {cert.Thumbprint}"); - } - // Warn if expiring within 30 days - else if ((cert.NotAfter - now).TotalDays < 30) - { - var daysRemaining = (int)(cert.NotAfter - now).TotalDays; - Console.WriteLine( - $"WARNING: Certificate '{cert.Subject}' expires in {daysRemaining} days " + - $"(Expires: {cert.NotAfter:yyyy-MM-dd}). Plan rotation soon. " + - $"Path: {path}, Thumbprint: {cert.Thumbprint}"); - } - } - - private static void ValidateCertificateSecurity(X509Certificate2 cert, string path) - { - // Check RSA key size - var rsa = cert.GetRSAPublicKey(); - if (rsa != null) - { - var keySize = rsa.KeySize; - if (keySize < 2048) - { - Console.WriteLine( - $"WARNING: Certificate '{cert.Subject}' uses weak RSA key size {keySize} bits. " + - $"Recommended: 2048+ bits for security. " + - $"Path: {path}, Thumbprint: {cert.Thumbprint}"); - } - } - - // Check signature algorithm for weak hashes - var sigAlgOid = cert.SignatureAlgorithm.Value; - if (sigAlgOid != null) - { - // OIDs for weak algorithms: MD5, SHA-1 - // MD5: 1.2.840.113549.1.1.4 (md5WithRSAEncryption) - // SHA-1: 1.2.840.113549.1.1.5 (sha1WithRSAEncryption), 1.3.14.3.2.29 (sha1WithRSA) - if (sigAlgOid.Contains("1.1.4") || // md5WithRSAEncryption - sigAlgOid.Contains("1.1.5") || // sha1WithRSAEncryption - sigAlgOid.Contains("3.2.29")) // sha1WithRSA - { - Console.WriteLine( - $"WARNING: Certificate '{cert.Subject}' uses weak signature algorithm " + - $"'{cert.SignatureAlgorithm.FriendlyName}'. " + - $"Recommended: SHA-256 or higher. " + - $"Path: {path}, Thumbprint: {cert.Thumbprint}"); - } - } - } -} - +using System.Security.Cryptography.X509Certificates; + +namespace Cocoar.Configuration.Secrets.Helpers; + +/// +/// Helper methods for loading and querying X.509 certificates. +/// Certificate generation is available via . +/// +public static class CertificateHelper +{ + public static X509Certificate2 LoadFromFile( + string path, + string? password = null, + X509KeyStorageFlags? keyStorageFlags = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Path is required", nameof(path)); + if (!File.Exists(path)) + throw new FileNotFoundException($"Certificate file not found: {path}", path); + + var extension = Path.GetExtension(path).ToLowerInvariant(); + var flags = keyStorageFlags ?? GetPlatformDefaultKeyStorageFlags(); + + X509Certificate2 cert = extension switch + { + ".pfx" or ".p12" => LoadPkcs12Certificate(path, password, flags), + ".pem" or ".crt" or ".cer" => LoadPemCertificate(path, flags), + _ => throw new NotSupportedException( + $"Certificate format '{extension}' is not supported. " + + $"Supported formats: .pfx, .p12 (PKCS#12), .pem, .crt, .cer (PEM with matching .key file)") + }; + + // Validate certificate expiration + ValidateCertificateExpiration(cert, path); + + // Validate certificate security properties + ValidateCertificateSecurity(cert, path); + + if (!cert.HasPrivateKey) + { + throw new InvalidOperationException( + $"The certificate at '{path}' does not contain a private key. " + + $"For PEM certificates, ensure a matching private key file exists (e.g., {Path.ChangeExtension(path, ".key")})."); + } + + return cert; + } + + private static X509Certificate2 LoadPkcs12Certificate(string path, string? password, X509KeyStorageFlags flags) + { +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12FromFile(path, password, flags); +#else + return new X509Certificate2(path, password, flags); +#endif + } + + private static X509Certificate2 LoadPemCertificate(string certPath, X509KeyStorageFlags flags) + { + // PEM certificates require a separate private key file + var directory = Path.GetDirectoryName(certPath) ?? "."; + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(certPath); + + // Look for matching .key file with same base name + var keyPath = Path.Combine(directory, $"{fileNameWithoutExt}.key"); + + if (!File.Exists(keyPath)) + { + throw new FileNotFoundException( + $"PEM certificate requires a matching private key file. Expected: {keyPath}", + keyPath); + } + + // Load certificate and private key from PEM files + var certPem = File.ReadAllText(certPath); + var keyPem = File.ReadAllText(keyPath); + + return X509Certificate2.CreateFromPem(certPem, keyPem); + } + + private static X509KeyStorageFlags GetPlatformDefaultKeyStorageFlags() + { + if (OperatingSystem.IsMacOS()) + { + // macOS requires PersistKeySet due to keychain behavior + return X509KeyStorageFlags.PersistKeySet; + } + + if (OperatingSystem.IsLinux()) + { + // Linux prefers MachineKeySet for system-wide certs + return X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.EphemeralKeySet; + } + + // Windows default - ephemeral for security + return X509KeyStorageFlags.EphemeralKeySet; + } + + public static X509Certificate2 FindByThumbprint( + string thumbprint, + StoreName storeName = StoreName.My, + StoreLocation storeLocation = StoreLocation.CurrentUser) + { + if (string.IsNullOrWhiteSpace(thumbprint)) + throw new ArgumentException("Thumbprint is required", nameof(thumbprint)); + + using var store = new X509Store(storeName, storeLocation); + store.Open(OpenFlags.ReadOnly); + + var matches = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false); + var cert = matches.Cast().FirstOrDefault(c => c is not null && c.HasPrivateKey); + + if (cert is null) + { + throw new InvalidOperationException( + $"Certificate with thumbprint '{thumbprint}' not found or missing private key in {storeLocation}/{storeName}."); + } + + return cert; + } + + public static X509Certificate2 FindBySubject( + string subjectDistinguishedName, + StoreName storeName = StoreName.My, + StoreLocation storeLocation = StoreLocation.CurrentUser) + { + if (string.IsNullOrWhiteSpace(subjectDistinguishedName)) + throw new ArgumentException("Subject distinguished name is required", nameof(subjectDistinguishedName)); + + using var store = new X509Store(storeName, storeLocation); + store.Open(OpenFlags.ReadOnly); + + var matches = store.Certificates.Find( + X509FindType.FindBySubjectDistinguishedName, + subjectDistinguishedName, + validOnly: false); + + var cert = matches.Cast().FirstOrDefault(c => c is not null && c.HasPrivateKey); + + if (cert is null) + { + throw new InvalidOperationException( + $"Certificate with subject '{subjectDistinguishedName}' not found or missing private key in {storeLocation}/{storeName}."); + } + + return cert; + } + + private static void ValidateCertificateExpiration(X509Certificate2 cert, string path) + { + var now = DateTime.UtcNow; + + // Warn if already expired + if (cert.NotAfter < now) + { + var daysExpired = (now - cert.NotAfter).Days; + Console.WriteLine( + $"WARNING: Certificate '{cert.Subject}' EXPIRED {daysExpired} days ago " + + $"(Expired: {cert.NotAfter:yyyy-MM-dd}). Rotation urgently needed. " + + $"Path: {path}, Thumbprint: {cert.Thumbprint}"); + } + // Warn if expiring within 30 days + else if ((cert.NotAfter - now).TotalDays < 30) + { + var daysRemaining = (int)(cert.NotAfter - now).TotalDays; + Console.WriteLine( + $"WARNING: Certificate '{cert.Subject}' expires in {daysRemaining} days " + + $"(Expires: {cert.NotAfter:yyyy-MM-dd}). Plan rotation soon. " + + $"Path: {path}, Thumbprint: {cert.Thumbprint}"); + } + } + + private static void ValidateCertificateSecurity(X509Certificate2 cert, string path) + { + // Check RSA key size + var rsa = cert.GetRSAPublicKey(); + if (rsa != null) + { + var keySize = rsa.KeySize; + if (keySize < 2048) + { + Console.WriteLine( + $"WARNING: Certificate '{cert.Subject}' uses weak RSA key size {keySize} bits. " + + $"Recommended: 2048+ bits for security. " + + $"Path: {path}, Thumbprint: {cert.Thumbprint}"); + } + } + + // Check signature algorithm for weak hashes + var sigAlgOid = cert.SignatureAlgorithm.Value; + if (sigAlgOid != null) + { + // OIDs for weak algorithms: MD5, SHA-1 + // MD5: 1.2.840.113549.1.1.4 (md5WithRSAEncryption) + // SHA-1: 1.2.840.113549.1.1.5 (sha1WithRSAEncryption), 1.3.14.3.2.29 (sha1WithRSA) + if (sigAlgOid.Contains("1.1.4") || // md5WithRSAEncryption + sigAlgOid.Contains("1.1.5") || // sha1WithRSAEncryption + sigAlgOid.Contains("3.2.29")) // sha1WithRSA + { + Console.WriteLine( + $"WARNING: Certificate '{cert.Subject}' uses weak signature algorithm " + + $"'{cert.SignatureAlgorithm.FriendlyName}'. " + + $"Recommended: SHA-256 or higher. " + + $"Path: {path}, Thumbprint: {cert.Thumbprint}"); + } + } + } +} + diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateContext.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateContext.cs index 81bdd05..e99633b 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateContext.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateContext.cs @@ -1,28 +1,28 @@ -using Cocoar.Configuration.Core; - -namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; - -/// -/// Provides context information about a certificate being loaded. -/// Used by password providers to determine the appropriate password(s) to try. -/// -public sealed class CertificateContext -{ - /// - /// The configuration accessor for reading passwords and other configuration values. - /// Provides access to the entire configuration system at the time the certificate is loaded. - /// - public required IConfigurationAccessor Config { get; init; } - - /// - /// The full file path of the certificate. - /// - public required string FilePath { get; init; } - - /// - /// The key identifier (kid) associated with this certificate. - /// For kid-specific certificates, this is the folder name. - /// For global fallback certificates, this is null. - /// - public string? Kid { get; init; } -} +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +/// +/// Provides context information about a certificate being loaded. +/// Used by password providers to determine the appropriate password(s) to try. +/// +public sealed class CertificateContext +{ + /// + /// The configuration accessor for reading passwords and other configuration values. + /// Provides access to the entire configuration system at the time the certificate is loaded. + /// + public required IConfigurationAccessor Config { get; init; } + + /// + /// The full file path of the certificate. + /// + public required string FilePath { get; init; } + + /// + /// The key identifier (kid) associated with this certificate. + /// For kid-specific certificates, this is the folder name. + /// For global fallback certificates, this is null. + /// + public string? Kid { get; init; } +} diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs index 181f02e..41098d7 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs @@ -1,529 +1,529 @@ -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Cocoar.Configuration.Core; -using Cocoar.FileSystem; - -namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; - -internal sealed class CertificateInventory : IDisposable -{ - private readonly string _folderPath; - private readonly string _searchPattern; - private readonly string? _kid; - private readonly IConfigurationAccessor? _configAccessor; - private readonly Func? _passwordProvider; - private readonly TimeSpan _cacheDuration; - private readonly IComparer? _fileInfoComparer; - private readonly int _includeSubdirectories; - - private readonly Dictionary _envelopeHashToCertPath = new(); - private readonly Dictionary _certCache = new(); - private readonly List _sortedCertPaths = new(); - - private readonly ReaderWriterLockSlim _lock = new(); - private readonly ResilientFileSystemMonitor _monitor; - - public CertificateInventory(string folderPath, string searchPattern, string? kid, IConfigurationAccessor? configAccessor, Func? passwordProvider, int cacheDurationSeconds = 30, IComparer? fileInfoComparer = null, int includeSubdirectories = 0) - { - ArgumentException.ThrowIfNullOrWhiteSpace(folderPath); - ArgumentException.ThrowIfNullOrWhiteSpace(searchPattern); - - // Resolve relative paths relative to the application directory, not the working directory - _folderPath = Path.IsPathRooted(folderPath) - ? Path.GetFullPath(folderPath) - : Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, folderPath)); - _searchPattern = searchPattern; - _kid = kid; - _configAccessor = configAccessor; - _passwordProvider = passwordProvider; - _cacheDuration = TimeSpan.FromSeconds(cacheDurationSeconds); - _fileInfoComparer = fileInfoComparer; - _includeSubdirectories = includeSubdirectories; - - RefreshInventory(); - - // Build file watcher with v2.1.0 features - var monitorBuilder = ResilientFileSystemMonitor - .Watch(_folderPath); - - // Apply file filters - v2.1.0 supports multiple patterns via .WithFilter() - if (_searchPattern.Contains(';')) - { - // Multiple patterns (semicolon-separated) - add each separately - var patterns = _searchPattern.Split(';', StringSplitOptions.RemoveEmptyEntries); - foreach (var pattern in patterns) - { - monitorBuilder = monitorBuilder.WithFilter(pattern.Trim()); - } - } - else if (_searchPattern == "*" || _searchPattern == "*.*") - { - // Auto-discover all supported formats - monitorBuilder = monitorBuilder - .WithFilter("*.pfx", "*.p12", "*.pem", "*.crt", "*.cer", "*.key"); - } - else - { - // Single pattern - monitorBuilder = monitorBuilder.WithFilter(_searchPattern); - } - - monitorBuilder = monitorBuilder.WithDebounce(100); - - // Apply subdirectory monitoring based on depth parameter - // -1 = unlimited depth (recursive) - // 0 = no subdirectories (flat, default) - // N = max depth of N levels - if (includeSubdirectories != 0) - { - monitorBuilder = monitorBuilder.IncludeSubdirectories(includeSubdirectories); - } - - _monitor = monitorBuilder - .OnCreated(OnFileSystemChanged) - .OnDeleted(OnFileSystemDeleted) - .OnChanged(OnFileSystemChanged) - .OnRenamed(OnFileSystemRenamed) - .Build(); - } - - public bool TryDecrypt(HybridEnvelope envelope, out byte[] plaintext) - { - var envelopeHash = ComputeEnvelopeHash(envelope); - - _lock.EnterReadLock(); - try - { - if (_envelopeHashToCertPath.TryGetValue(envelopeHash, out var knownCertPath)) - { - if (TryDecryptWithCert(knownCertPath, envelope, out plaintext)) - return true; - } - } - finally - { - _lock.ExitReadLock(); - } - - _lock.EnterWriteLock(); - try - { - _envelopeHashToCertPath.Remove(envelopeHash); - - foreach (var certPath in _sortedCertPaths) - { - if (TryDecryptWithCert(certPath, envelope, out plaintext)) - { - _envelopeHashToCertPath[envelopeHash] = certPath; - return true; - } - } - - plaintext = Array.Empty(); - return false; - } - finally - { - _lock.ExitWriteLock(); - } - } - - public bool HasCertificatesFor(string kid) - { - var kidPath = Path.GetFullPath(Path.Combine(_folderPath, kid)); - - _lock.EnterReadLock(); - try - { - // Check if any certificates exist in the kid-specific folder - // Add directory separator to ensure we match the folder, not a prefix - var kidPathWithSep = kidPath + Path.DirectorySeparatorChar; - return _sortedCertPaths.Any(path => path.StartsWith(kidPathWithSep, StringComparison.OrdinalIgnoreCase) || - string.Equals(Path.GetDirectoryName(path), kidPath, StringComparison.OrdinalIgnoreCase)); - } - finally - { - _lock.ExitReadLock(); - } - } - - public bool TryDecryptWithKid(HybridEnvelope envelope, string kid, out byte[] plaintext) - { - var kidPath = Path.GetFullPath(Path.Combine(_folderPath, kid)); - var kidPathWithSep = kidPath + Path.DirectorySeparatorChar; - var envelopeHash = ComputeEnvelopeHash(envelope); - - _lock.EnterReadLock(); - try - { - // Check cache first - if (_envelopeHashToCertPath.TryGetValue(envelopeHash, out var knownCertPath)) - { - var certDir = Path.GetDirectoryName(knownCertPath); - if (certDir != null && (knownCertPath.StartsWith(kidPathWithSep, StringComparison.OrdinalIgnoreCase) || - string.Equals(certDir, kidPath, StringComparison.OrdinalIgnoreCase))) - { - if (TryDecryptWithCert(knownCertPath, envelope, out plaintext)) - return true; - } - } - } - finally - { - _lock.ExitReadLock(); - } - - _lock.EnterWriteLock(); - try - { - _envelopeHashToCertPath.Remove(envelopeHash); - - // Only try certificates from the kid-specific folder - foreach (var certPath in _sortedCertPaths) - { - var certDir = Path.GetDirectoryName(certPath); - if (certDir == null || !(certPath.StartsWith(kidPathWithSep, StringComparison.OrdinalIgnoreCase) || - string.Equals(certDir, kidPath, StringComparison.OrdinalIgnoreCase))) - continue; - - if (TryDecryptWithCert(certPath, envelope, out plaintext)) - { - _envelopeHashToCertPath[envelopeHash] = certPath; - return true; - } - } - - plaintext = Array.Empty(); - return false; - } - finally - { - _lock.ExitWriteLock(); - } - } - - /// - /// Exports the SubjectPublicKeyInfo (DER) of the certificate the decryption engine prefers - /// (the first in the current ordering) — for publishing as the encryption public key. Returns - /// only public-key bytes; the is never exposed. Returns - /// when no usable RSA certificate is present. Runs under the write lock - /// because mutates the cache. - /// - internal byte[]? TryExportPreferredPublicKey() - => TryExportPreferredPublicKey(kidPath: null, kidPathWithSep: null); - - /// - /// Like but restricted to the certificates physically - /// under the {folder}/{kid} subfolder — the per-tenant (kid = tenant) publishing path. The - /// preferred (first-ordered, i.e. newest per the configured comparer) cert in that subfolder is - /// exported; older certs in the same subfolder remain available for decryption only. Returns - /// when that kid has no usable RSA certificate. - /// - internal byte[]? TryExportPreferredPublicKey(string kid) - { - ArgumentException.ThrowIfNullOrWhiteSpace(kid); - var kidPath = Path.GetFullPath(Path.Combine(_folderPath, kid)); - return TryExportPreferredPublicKey(kidPath, kidPath + Path.DirectorySeparatorChar); - } - - private byte[]? TryExportPreferredPublicKey(string? kidPath, string? kidPathWithSep) - { - _lock.EnterWriteLock(); - try - { - foreach (var certPath in _sortedCertPaths) - { - if (kidPath is not null && !IsCertUnderKid(certPath, kidPath, kidPathWithSep!)) - continue; - - X509Certificate2 cert; - try - { - cert = GetOrLoadCertificate(certPath); - } - catch (Exception ex) when ( - ex is CryptographicException or IOException or UnauthorizedAccessException - or InvalidOperationException or NotSupportedException) - { - // A cert that can't be loaded right now — e.g. a transient file race during - // rotation (delete+recreate / locked / partially written) or a non-usable file — - // is skipped so publishing degrades gracefully (empty result) instead of a 500. - continue; - } - - using var rsa = cert.GetRSAPublicKey(); - if (rsa is null) - continue; - - return rsa.ExportSubjectPublicKeyInfo(); - } - - return null; - } - finally - { - _lock.ExitWriteLock(); - } - } - - private static bool IsCertUnderKid(string certPath, string kidPath, string kidPathWithSep) - { - var certDir = Path.GetDirectoryName(certPath); - return certDir != null - && (certPath.StartsWith(kidPathWithSep, StringComparison.OrdinalIgnoreCase) - || string.Equals(certDir, kidPath, StringComparison.OrdinalIgnoreCase)); - } - - private bool TryDecryptWithCert(string certPath, HybridEnvelope envelope, out byte[] plaintext) - { - try - { - var cert = GetOrLoadCertificate(certPath); - var rsa = cert.GetRSAPrivateKey() ?? throw new CryptographicException($"Certificate has no RSA private key: {certPath}"); - - if (!string.Equals(envelope.WrappingAlgorithm, "RSA-OAEP-256", StringComparison.OrdinalIgnoreCase)) - { - plaintext = Array.Empty(); - return false; - } - - Span dek = stackalloc byte[32]; - try - { - var unwrappedKey = rsa.Decrypt(envelope.WrappedKey, RSAEncryptionPadding.OaepSHA256); - unwrappedKey.AsSpan().CopyTo(dek); - - plaintext = new byte[envelope.Ciphertext.Length]; - using (var aes = new AesGcm(dek, envelope.Tag.Length)) - { - aes.Decrypt(envelope.Iv, envelope.Ciphertext, envelope.Tag, plaintext, associatedData: null); - } - - Array.Clear(unwrappedKey, 0, unwrappedKey.Length); - return true; - } - finally - { - CryptographicOperations.ZeroMemory(dek); - } - } - catch (CryptographicException) - { - plaintext = Array.Empty(); - return false; - } - } - - private X509Certificate2 GetOrLoadCertificate(string certPath) - { - var now = DateTime.UtcNow; - - if (_certCache.TryGetValue(certPath, out var cached)) - { - if (now < cached.ExpiresAt) - { - cached.ExpiresAt = now.Add(_cacheDuration); - return cached.Certificate; - } - - cached.Certificate.Dispose(); - _certCache.Remove(certPath); - } - - X509Certificate2? cert = null; - - if (_passwordProvider != null) - { - var context = new CertificateContext - { - Config = _configAccessor!, - FilePath = certPath, - Kid = _kid - }; - var passwords = _passwordProvider(context); - foreach (var password in passwords) - { - try - { - cert = Helpers.CertificateHelper.LoadFromFile(certPath, password); - break; - } - catch (CryptographicException) - { - // Try next password - } - } - } - - cert ??= Helpers.CertificateHelper.LoadFromFile(certPath, null); - - _certCache[certPath] = new CachedCert - { - Certificate = cert, - ExpiresAt = now.Add(_cacheDuration) - }; - - return cert; - } - - private static string ComputeEnvelopeHash(HybridEnvelope envelope) - { - using var sha = SHA256.Create(); - - var hashInput = new byte[envelope.Ciphertext.Length + envelope.Iv.Length + envelope.WrappedKey.Length + envelope.Tag.Length]; - envelope.Ciphertext.CopyTo(hashInput, 0); - envelope.Iv.CopyTo(hashInput, envelope.Ciphertext.Length); - envelope.WrappedKey.CopyTo(hashInput, envelope.Ciphertext.Length + envelope.Iv.Length); - envelope.Tag.CopyTo(hashInput, envelope.Ciphertext.Length + envelope.Iv.Length + envelope.WrappedKey.Length); - - var hash = SHA256.HashData(hashInput); - return Convert.ToBase64String(hash); - } - - private void RefreshInventory() - { - _lock.EnterWriteLock(); - try - { - _sortedCertPaths.Clear(); - - if (!Directory.Exists(_folderPath)) - return; - - // Discover certificate files using FileSearcher with MaxDepth support - var certPaths = DiscoverCertificates(); - - if (_fileInfoComparer != null) - { - var fileInfos = certPaths.Select(p => new FileInfo(p)).ToList(); - fileInfos.Sort(_fileInfoComparer); - _sortedCertPaths.AddRange(fileInfos.Select(fi => fi.FullName)); - } - else - { - _sortedCertPaths.AddRange(certPaths.OrderByDescending(p => p)); - } - } - finally - { - _lock.ExitWriteLock(); - } - } - - private HashSet DiscoverCertificates() - { - var discoveredPaths = new HashSet(StringComparer.OrdinalIgnoreCase); - - // Determine patterns to search - var patterns = _searchPattern.Contains(';') - ? _searchPattern.Split(';', StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() - : _searchPattern == "*" || _searchPattern == "*.*" - ? new[] { "*.pfx", "*.p12", "*.pem", "*.crt", "*.cer" } - : new[] { _searchPattern }; - - // Use FileSearcher with MaxDepth support from Cocoar.FileSystem - foreach (var pattern in patterns) - { - var searcher = FileSearcher.Search(_folderPath, pattern); - - // Apply MaxDepth if subdirectories are requested - // -1 = unlimited, 0 = flat (no subdirs), N = max N levels deep - if (_includeSubdirectories != 0) - { - searcher = _includeSubdirectories == -1 - ? searcher.WithMaxDepth(int.MaxValue) // Unlimited - : searcher.WithMaxDepth(_includeSubdirectories); - } - - foreach (var file in searcher) - { - var ext = Path.GetExtension(file).ToLowerInvariant(); - - // Skip standalone .key files (they're paired with .crt/.pem/.cer files) - if (ext == ".key") - continue; - - // For PEM/CRT/CER, only include if matching .key file exists - if (ext is ".pem" or ".crt" or ".cer") - { - var keyFile = Path.ChangeExtension(file, ".key"); - if (!File.Exists(keyFile)) - continue; // Skip - missing required private key - } - - discoveredPaths.Add(file); - } - } - - return discoveredPaths; - } - - private void OnFileSystemChanged(object? sender, FileSystemEventArgs e) - { - // Refresh on file create, change, or rename - RefreshInventory(); - } - - private void OnFileSystemRenamed(object? sender, RenamedEventArgs e) - { - // Folder or file rename - refresh inventory to discover new paths - RefreshInventory(); - } - - private void OnFileSystemDeleted(object? sender, FileSystemEventArgs e) - { - RefreshInventory(); - - _lock.EnterWriteLock(); - try - { - var fullPath = Path.GetFullPath(e.FullPath); - if (_certCache.TryGetValue(fullPath, out var cached)) - { - cached.Certificate.Dispose(); - _certCache.Remove(fullPath); - } - - var keysToRemove = _envelopeHashToCertPath - .Where(kvp => kvp.Value == fullPath) - .Select(kvp => kvp.Key) - .ToList(); - - foreach (var key in keysToRemove) - _envelopeHashToCertPath.Remove(key); - } - finally - { - _lock.ExitWriteLock(); - } - } - - public void Dispose() - { - _monitor?.Dispose(); - - _lock.EnterWriteLock(); - try - { - foreach (var cached in _certCache.Values) - { - cached.Certificate.Dispose(); - } - _certCache.Clear(); - _envelopeHashToCertPath.Clear(); - _sortedCertPaths.Clear(); - } - finally - { - _lock.ExitWriteLock(); - } - - _lock.Dispose(); - } - - private sealed class CachedCert - { - public required X509Certificate2 Certificate { get; init; } - public DateTime ExpiresAt { get; set; } - } -} +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Cocoar.Configuration.Core; +using Cocoar.FileSystem; + +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +internal sealed class CertificateInventory : IDisposable +{ + private readonly string _folderPath; + private readonly string _searchPattern; + private readonly string? _kid; + private readonly IConfigurationAccessor? _configAccessor; + private readonly Func? _passwordProvider; + private readonly TimeSpan _cacheDuration; + private readonly IComparer? _fileInfoComparer; + private readonly int _includeSubdirectories; + + private readonly Dictionary _envelopeHashToCertPath = new(); + private readonly Dictionary _certCache = new(); + private readonly List _sortedCertPaths = new(); + + private readonly ReaderWriterLockSlim _lock = new(); + private readonly ResilientFileSystemMonitor _monitor; + + public CertificateInventory(string folderPath, string searchPattern, string? kid, IConfigurationAccessor? configAccessor, Func? passwordProvider, int cacheDurationSeconds = 30, IComparer? fileInfoComparer = null, int includeSubdirectories = 0) + { + ArgumentException.ThrowIfNullOrWhiteSpace(folderPath); + ArgumentException.ThrowIfNullOrWhiteSpace(searchPattern); + + // Resolve relative paths relative to the application directory, not the working directory + _folderPath = Path.IsPathRooted(folderPath) + ? Path.GetFullPath(folderPath) + : Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, folderPath)); + _searchPattern = searchPattern; + _kid = kid; + _configAccessor = configAccessor; + _passwordProvider = passwordProvider; + _cacheDuration = TimeSpan.FromSeconds(cacheDurationSeconds); + _fileInfoComparer = fileInfoComparer; + _includeSubdirectories = includeSubdirectories; + + RefreshInventory(); + + // Build file watcher with v2.1.0 features + var monitorBuilder = ResilientFileSystemMonitor + .Watch(_folderPath); + + // Apply file filters - v2.1.0 supports multiple patterns via .WithFilter() + if (_searchPattern.Contains(';')) + { + // Multiple patterns (semicolon-separated) - add each separately + var patterns = _searchPattern.Split(';', StringSplitOptions.RemoveEmptyEntries); + foreach (var pattern in patterns) + { + monitorBuilder = monitorBuilder.WithFilter(pattern.Trim()); + } + } + else if (_searchPattern == "*" || _searchPattern == "*.*") + { + // Auto-discover all supported formats + monitorBuilder = monitorBuilder + .WithFilter("*.pfx", "*.p12", "*.pem", "*.crt", "*.cer", "*.key"); + } + else + { + // Single pattern + monitorBuilder = monitorBuilder.WithFilter(_searchPattern); + } + + monitorBuilder = monitorBuilder.WithDebounce(100); + + // Apply subdirectory monitoring based on depth parameter + // -1 = unlimited depth (recursive) + // 0 = no subdirectories (flat, default) + // N = max depth of N levels + if (includeSubdirectories != 0) + { + monitorBuilder = monitorBuilder.IncludeSubdirectories(includeSubdirectories); + } + + _monitor = monitorBuilder + .OnCreated(OnFileSystemChanged) + .OnDeleted(OnFileSystemDeleted) + .OnChanged(OnFileSystemChanged) + .OnRenamed(OnFileSystemRenamed) + .Build(); + } + + public bool TryDecrypt(HybridEnvelope envelope, out byte[] plaintext) + { + var envelopeHash = ComputeEnvelopeHash(envelope); + + _lock.EnterReadLock(); + try + { + if (_envelopeHashToCertPath.TryGetValue(envelopeHash, out var knownCertPath)) + { + if (TryDecryptWithCert(knownCertPath, envelope, out plaintext)) + return true; + } + } + finally + { + _lock.ExitReadLock(); + } + + _lock.EnterWriteLock(); + try + { + _envelopeHashToCertPath.Remove(envelopeHash); + + foreach (var certPath in _sortedCertPaths) + { + if (TryDecryptWithCert(certPath, envelope, out plaintext)) + { + _envelopeHashToCertPath[envelopeHash] = certPath; + return true; + } + } + + plaintext = Array.Empty(); + return false; + } + finally + { + _lock.ExitWriteLock(); + } + } + + public bool HasCertificatesFor(string kid) + { + var kidPath = Path.GetFullPath(Path.Combine(_folderPath, kid)); + + _lock.EnterReadLock(); + try + { + // Check if any certificates exist in the kid-specific folder + // Add directory separator to ensure we match the folder, not a prefix + var kidPathWithSep = kidPath + Path.DirectorySeparatorChar; + return _sortedCertPaths.Any(path => path.StartsWith(kidPathWithSep, StringComparison.OrdinalIgnoreCase) || + string.Equals(Path.GetDirectoryName(path), kidPath, StringComparison.OrdinalIgnoreCase)); + } + finally + { + _lock.ExitReadLock(); + } + } + + public bool TryDecryptWithKid(HybridEnvelope envelope, string kid, out byte[] plaintext) + { + var kidPath = Path.GetFullPath(Path.Combine(_folderPath, kid)); + var kidPathWithSep = kidPath + Path.DirectorySeparatorChar; + var envelopeHash = ComputeEnvelopeHash(envelope); + + _lock.EnterReadLock(); + try + { + // Check cache first + if (_envelopeHashToCertPath.TryGetValue(envelopeHash, out var knownCertPath)) + { + var certDir = Path.GetDirectoryName(knownCertPath); + if (certDir != null && (knownCertPath.StartsWith(kidPathWithSep, StringComparison.OrdinalIgnoreCase) || + string.Equals(certDir, kidPath, StringComparison.OrdinalIgnoreCase))) + { + if (TryDecryptWithCert(knownCertPath, envelope, out plaintext)) + return true; + } + } + } + finally + { + _lock.ExitReadLock(); + } + + _lock.EnterWriteLock(); + try + { + _envelopeHashToCertPath.Remove(envelopeHash); + + // Only try certificates from the kid-specific folder + foreach (var certPath in _sortedCertPaths) + { + var certDir = Path.GetDirectoryName(certPath); + if (certDir == null || !(certPath.StartsWith(kidPathWithSep, StringComparison.OrdinalIgnoreCase) || + string.Equals(certDir, kidPath, StringComparison.OrdinalIgnoreCase))) + continue; + + if (TryDecryptWithCert(certPath, envelope, out plaintext)) + { + _envelopeHashToCertPath[envelopeHash] = certPath; + return true; + } + } + + plaintext = Array.Empty(); + return false; + } + finally + { + _lock.ExitWriteLock(); + } + } + + /// + /// Exports the SubjectPublicKeyInfo (DER) of the certificate the decryption engine prefers + /// (the first in the current ordering) — for publishing as the encryption public key. Returns + /// only public-key bytes; the is never exposed. Returns + /// when no usable RSA certificate is present. Runs under the write lock + /// because mutates the cache. + /// + internal byte[]? TryExportPreferredPublicKey() + => TryExportPreferredPublicKey(kidPath: null, kidPathWithSep: null); + + /// + /// Like but restricted to the certificates physically + /// under the {folder}/{kid} subfolder — the per-tenant (kid = tenant) publishing path. The + /// preferred (first-ordered, i.e. newest per the configured comparer) cert in that subfolder is + /// exported; older certs in the same subfolder remain available for decryption only. Returns + /// when that kid has no usable RSA certificate. + /// + internal byte[]? TryExportPreferredPublicKey(string kid) + { + ArgumentException.ThrowIfNullOrWhiteSpace(kid); + var kidPath = Path.GetFullPath(Path.Combine(_folderPath, kid)); + return TryExportPreferredPublicKey(kidPath, kidPath + Path.DirectorySeparatorChar); + } + + private byte[]? TryExportPreferredPublicKey(string? kidPath, string? kidPathWithSep) + { + _lock.EnterWriteLock(); + try + { + foreach (var certPath in _sortedCertPaths) + { + if (kidPath is not null && !IsCertUnderKid(certPath, kidPath, kidPathWithSep!)) + continue; + + X509Certificate2 cert; + try + { + cert = GetOrLoadCertificate(certPath); + } + catch (Exception ex) when ( + ex is CryptographicException or IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException) + { + // A cert that can't be loaded right now — e.g. a transient file race during + // rotation (delete+recreate / locked / partially written) or a non-usable file — + // is skipped so publishing degrades gracefully (empty result) instead of a 500. + continue; + } + + using var rsa = cert.GetRSAPublicKey(); + if (rsa is null) + continue; + + return rsa.ExportSubjectPublicKeyInfo(); + } + + return null; + } + finally + { + _lock.ExitWriteLock(); + } + } + + private static bool IsCertUnderKid(string certPath, string kidPath, string kidPathWithSep) + { + var certDir = Path.GetDirectoryName(certPath); + return certDir != null + && (certPath.StartsWith(kidPathWithSep, StringComparison.OrdinalIgnoreCase) + || string.Equals(certDir, kidPath, StringComparison.OrdinalIgnoreCase)); + } + + private bool TryDecryptWithCert(string certPath, HybridEnvelope envelope, out byte[] plaintext) + { + try + { + var cert = GetOrLoadCertificate(certPath); + var rsa = cert.GetRSAPrivateKey() ?? throw new CryptographicException($"Certificate has no RSA private key: {certPath}"); + + if (!string.Equals(envelope.WrappingAlgorithm, "RSA-OAEP-256", StringComparison.OrdinalIgnoreCase)) + { + plaintext = Array.Empty(); + return false; + } + + Span dek = stackalloc byte[32]; + try + { + var unwrappedKey = rsa.Decrypt(envelope.WrappedKey, RSAEncryptionPadding.OaepSHA256); + unwrappedKey.AsSpan().CopyTo(dek); + + plaintext = new byte[envelope.Ciphertext.Length]; + using (var aes = new AesGcm(dek, envelope.Tag.Length)) + { + aes.Decrypt(envelope.Iv, envelope.Ciphertext, envelope.Tag, plaintext, associatedData: null); + } + + Array.Clear(unwrappedKey, 0, unwrappedKey.Length); + return true; + } + finally + { + CryptographicOperations.ZeroMemory(dek); + } + } + catch (CryptographicException) + { + plaintext = Array.Empty(); + return false; + } + } + + private X509Certificate2 GetOrLoadCertificate(string certPath) + { + var now = DateTime.UtcNow; + + if (_certCache.TryGetValue(certPath, out var cached)) + { + if (now < cached.ExpiresAt) + { + cached.ExpiresAt = now.Add(_cacheDuration); + return cached.Certificate; + } + + cached.Certificate.Dispose(); + _certCache.Remove(certPath); + } + + X509Certificate2? cert = null; + + if (_passwordProvider != null) + { + var context = new CertificateContext + { + Config = _configAccessor!, + FilePath = certPath, + Kid = _kid + }; + var passwords = _passwordProvider(context); + foreach (var password in passwords) + { + try + { + cert = Helpers.CertificateHelper.LoadFromFile(certPath, password); + break; + } + catch (CryptographicException) + { + // Try next password + } + } + } + + cert ??= Helpers.CertificateHelper.LoadFromFile(certPath, null); + + _certCache[certPath] = new CachedCert + { + Certificate = cert, + ExpiresAt = now.Add(_cacheDuration) + }; + + return cert; + } + + private static string ComputeEnvelopeHash(HybridEnvelope envelope) + { + using var sha = SHA256.Create(); + + var hashInput = new byte[envelope.Ciphertext.Length + envelope.Iv.Length + envelope.WrappedKey.Length + envelope.Tag.Length]; + envelope.Ciphertext.CopyTo(hashInput, 0); + envelope.Iv.CopyTo(hashInput, envelope.Ciphertext.Length); + envelope.WrappedKey.CopyTo(hashInput, envelope.Ciphertext.Length + envelope.Iv.Length); + envelope.Tag.CopyTo(hashInput, envelope.Ciphertext.Length + envelope.Iv.Length + envelope.WrappedKey.Length); + + var hash = SHA256.HashData(hashInput); + return Convert.ToBase64String(hash); + } + + private void RefreshInventory() + { + _lock.EnterWriteLock(); + try + { + _sortedCertPaths.Clear(); + + if (!Directory.Exists(_folderPath)) + return; + + // Discover certificate files using FileSearcher with MaxDepth support + var certPaths = DiscoverCertificates(); + + if (_fileInfoComparer != null) + { + var fileInfos = certPaths.Select(p => new FileInfo(p)).ToList(); + fileInfos.Sort(_fileInfoComparer); + _sortedCertPaths.AddRange(fileInfos.Select(fi => fi.FullName)); + } + else + { + _sortedCertPaths.AddRange(certPaths.OrderByDescending(p => p)); + } + } + finally + { + _lock.ExitWriteLock(); + } + } + + private HashSet DiscoverCertificates() + { + var discoveredPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Determine patterns to search + var patterns = _searchPattern.Contains(';') + ? _searchPattern.Split(';', StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() + : _searchPattern == "*" || _searchPattern == "*.*" + ? new[] { "*.pfx", "*.p12", "*.pem", "*.crt", "*.cer" } + : new[] { _searchPattern }; + + // Use FileSearcher with MaxDepth support from Cocoar.FileSystem + foreach (var pattern in patterns) + { + var searcher = FileSearcher.Search(_folderPath, pattern); + + // Apply MaxDepth if subdirectories are requested + // -1 = unlimited, 0 = flat (no subdirs), N = max N levels deep + if (_includeSubdirectories != 0) + { + searcher = _includeSubdirectories == -1 + ? searcher.WithMaxDepth(int.MaxValue) // Unlimited + : searcher.WithMaxDepth(_includeSubdirectories); + } + + foreach (var file in searcher) + { + var ext = Path.GetExtension(file).ToLowerInvariant(); + + // Skip standalone .key files (they're paired with .crt/.pem/.cer files) + if (ext == ".key") + continue; + + // For PEM/CRT/CER, only include if matching .key file exists + if (ext is ".pem" or ".crt" or ".cer") + { + var keyFile = Path.ChangeExtension(file, ".key"); + if (!File.Exists(keyFile)) + continue; // Skip - missing required private key + } + + discoveredPaths.Add(file); + } + } + + return discoveredPaths; + } + + private void OnFileSystemChanged(object? sender, FileSystemEventArgs e) + { + // Refresh on file create, change, or rename + RefreshInventory(); + } + + private void OnFileSystemRenamed(object? sender, RenamedEventArgs e) + { + // Folder or file rename - refresh inventory to discover new paths + RefreshInventory(); + } + + private void OnFileSystemDeleted(object? sender, FileSystemEventArgs e) + { + RefreshInventory(); + + _lock.EnterWriteLock(); + try + { + var fullPath = Path.GetFullPath(e.FullPath); + if (_certCache.TryGetValue(fullPath, out var cached)) + { + cached.Certificate.Dispose(); + _certCache.Remove(fullPath); + } + + var keysToRemove = _envelopeHashToCertPath + .Where(kvp => kvp.Value == fullPath) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var key in keysToRemove) + _envelopeHashToCertPath.Remove(key); + } + finally + { + _lock.ExitWriteLock(); + } + } + + public void Dispose() + { + _monitor?.Dispose(); + + _lock.EnterWriteLock(); + try + { + foreach (var cached in _certCache.Values) + { + cached.Certificate.Dispose(); + } + _certCache.Clear(); + _envelopeHashToCertPath.Clear(); + _sortedCertPaths.Clear(); + } + finally + { + _lock.ExitWriteLock(); + } + + _lock.Dispose(); + } + + private sealed class CachedCert + { + public required X509Certificate2 Certificate { get; init; } + public DateTime ExpiresAt { get; set; } + } +} diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/EncryptionResultSerializer.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/EncryptionResultSerializer.cs index ce81e7f..11904c2 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/EncryptionResultSerializer.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/EncryptionResultSerializer.cs @@ -1,23 +1,23 @@ -namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; - -/// -/// Internal helper for base64url encoding/decoding of HybridEnvelope data. -/// -internal static class HybridEnvelopeSerializer -{ - /// - /// Decode a base64url-encoded string to bytes. - /// - internal static byte[] FromBase64Url(string s) - { - if (string.IsNullOrEmpty(s)) - return Array.Empty(); - - var base64 = s.Replace('-', '+').Replace('_', '/'); - var pad = base64.Length % 4; - if (pad != 0) - base64 = base64.PadRight(base64.Length + (4 - pad), '='); - - return Convert.FromBase64String(base64); - } -} +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +/// +/// Internal helper for base64url encoding/decoding of HybridEnvelope data. +/// +internal static class HybridEnvelopeSerializer +{ + /// + /// Decode a base64url-encoded string to bytes. + /// + internal static byte[] FromBase64Url(string s) + { + if (string.IsNullOrEmpty(s)) + return Array.Empty(); + + var base64 = s.Replace('-', '+').Replace('_', '/'); + var pad = base64.Length % 4; + if (pad != 0) + base64 = base64.PadRight(base64.Length + (4 - pad), '='); + + return Convert.FromBase64String(base64); + } +} diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridEnvelope.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridEnvelope.cs index cf14dda..4e21138 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridEnvelope.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridEnvelope.cs @@ -1,23 +1,23 @@ -using System.Text.Json.Serialization; -using Cocoar.Configuration.Secrets.Core; -using Cocoar.Configuration.X509Encryption; - -namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; - -public sealed record HybridEnvelope : IEncryptedEnvelope -{ - [JsonPropertyName("wk")] - public required byte[] WrappedKey { get; init; } - - [JsonPropertyName("walg")] - public required string WrappingAlgorithm { get; init; } - - [JsonPropertyName("iv")] - public required byte[] Iv { get; init; } - - [JsonPropertyName("ct")] - public required byte[] Ciphertext { get; init; } - - [JsonPropertyName("tag")] - public required byte[] Tag { get; init; } -} +using System.Text.Json.Serialization; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.X509Encryption; + +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +public sealed record HybridEnvelope : IEncryptedEnvelope +{ + [JsonPropertyName("wk")] + public required byte[] WrappedKey { get; init; } + + [JsonPropertyName("walg")] + public required string WrappingAlgorithm { get; init; } + + [JsonPropertyName("iv")] + public required byte[] Iv { get; init; } + + [JsonPropertyName("ct")] + public required byte[] Ciphertext { get; init; } + + [JsonPropertyName("tag")] + public required byte[] Tag { get; init; } +} diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorCapabilities.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorCapabilities.cs index b40e3e4..5747ba6 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorCapabilities.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorCapabilities.cs @@ -1,67 +1,67 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Secrets.Core; -using Cocoar.Configuration.X509Encryption; - -namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; - -/// -/// Unified configuration for certificate-based hybrid encryption. -/// Supports both single-kid (flat) and multi-kid (folder-based) scenarios. -/// -public sealed record CertificateProtectorConfig : IProtectorConfiguration -{ - /// - /// Base path for certificates. Can be a folder or specific file path. - /// - public required string BasePath { get; init; } - - /// - /// Search pattern for certificate files (e.g., "*.pfx", "cert.pfx"). - /// - public string SearchPattern { get; init; } = "*.pfx"; - - /// - /// If set, forces all certificates to use this Kid, ignoring folder structure. - /// Use for single-kid scenarios or flat folder structures. - /// If null, uses folder names as Kids (multi-kid mode). - /// - public string? ForceSingleKid { get; init; } - - /// - /// Additional Kid aliases for the primary kid (only used with ForceSingleKid). - /// - public string[]? AdditionalKids { get; init; } - - /// - /// Simple password for all certificates (alternative to PasswordProvider). - /// - public string? Password { get; init; } - - /// - /// Function to provide passwords for certificates based on context. - /// Takes precedence over simple Password property. - /// - public Func? PasswordProvider { get; init; } - - /// - /// Duration to cache loaded certificates in seconds. - /// Certificates are automatically monitored for changes and reloaded. - /// - public int CacheDurationSeconds { get; init; } = 30; - - /// - /// Custom comparer for sorting certificates (e.g., prefer newer certs). - /// - public IComparer? CertificateComparer { get; init; } - - /// - /// Maximum depth for kid folders relative to BasePath. - /// Only applies when ForceSingleKid is null (multi-kid mode). - /// - Depth 0: Files only in BasePath (no kid folders, requires ForceSingleKid) - /// - Depth 1: Kid folders are immediate children (default, recommended) - /// - Depth 2+: Allows nested kid structures (advanced scenarios) - /// Certificates can be at any depth within a kid folder. - /// - public int MaxKidDepth { get; init; } = 1; -} - +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.X509Encryption; + +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +/// +/// Unified configuration for certificate-based hybrid encryption. +/// Supports both single-kid (flat) and multi-kid (folder-based) scenarios. +/// +public sealed record CertificateProtectorConfig : IProtectorConfiguration +{ + /// + /// Base path for certificates. Can be a folder or specific file path. + /// + public required string BasePath { get; init; } + + /// + /// Search pattern for certificate files (e.g., "*.pfx", "cert.pfx"). + /// + public string SearchPattern { get; init; } = "*.pfx"; + + /// + /// If set, forces all certificates to use this Kid, ignoring folder structure. + /// Use for single-kid scenarios or flat folder structures. + /// If null, uses folder names as Kids (multi-kid mode). + /// + public string? ForceSingleKid { get; init; } + + /// + /// Additional Kid aliases for the primary kid (only used with ForceSingleKid). + /// + public string[]? AdditionalKids { get; init; } + + /// + /// Simple password for all certificates (alternative to PasswordProvider). + /// + public string? Password { get; init; } + + /// + /// Function to provide passwords for certificates based on context. + /// Takes precedence over simple Password property. + /// + public Func? PasswordProvider { get; init; } + + /// + /// Duration to cache loaded certificates in seconds. + /// Certificates are automatically monitored for changes and reloaded. + /// + public int CacheDurationSeconds { get; init; } = 30; + + /// + /// Custom comparer for sorting certificates (e.g., prefer newer certs). + /// + public IComparer? CertificateComparer { get; init; } + + /// + /// Maximum depth for kid folders relative to BasePath. + /// Only applies when ForceSingleKid is null (multi-kid mode). + /// - Depth 0: Files only in BasePath (no kid folders, requires ForceSingleKid) + /// - Depth 1: Kid folders are immediate children (default, recommended) + /// - Depth 2+: Allows nested kid structures (advanced scenarios) + /// Certificates can be at any depth within a kid folder. + /// + public int MaxKidDepth { get; init; } = 1; +} + diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs index 66c7684..701ce45 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs @@ -1,230 +1,230 @@ -using System.Globalization; -using System.Security.Cryptography.X509Certificates; -using System.Text; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Secrets.Core; -using Cocoar.Configuration.Secrets.Exceptions; -using Cocoar.Configuration.X509Encryption; -using Cocoar.Configuration.Secrets.Helpers; -using Cocoar.FileSystem; - -namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; - -/// -/// Encapsulates configuration and setup logic for Hybrid/X.509 protectors. -/// Keeps SecretsSetupDeferredConfiguration thin by delegating feature-specific work here. -/// -internal sealed class HybridProtectorConfigurator(ConfigManagerCapabilityScope capabilityScope) -{ - private readonly ConfigManagerCapabilityScope _capabilityScope = capabilityScope ?? throw new ArgumentNullException(nameof(capabilityScope)); - - private void RegisterProtectorAndKeyInfo(IRuntimeSecretDecryptor protector, ISecretEncryptionKeyInfoProvider keyInfo) - { - var composition = _capabilityScope.Owner.GetComposition(); - if (composition == null) return; - - var recomposer = _capabilityScope.Recompose(composition); - recomposer.AddAs(protector); - recomposer.AddAs(keyInfo); - recomposer.Build(); - } - - /// - /// Unified method to apply certificate protector configuration. - /// - public void ApplyCertificateProtector(CertificateProtectorConfig config) - { - // Validate structure before proceeding - ValidateCertificateStructure(config); - - var configAccessor = _capabilityScope.Owner.Get(); - - // Single-Kid Mode: All certs use one kid - if (config.ForceSingleKid != null) - { - ApplySingleKidMode(config, configAccessor); - return; - } - - // Multi-Kid Mode: Use folder structure - ApplyMultiKidMode(config, configAccessor); - } - - private void ApplySingleKidMode(CertificateProtectorConfig config, IConfigurationAccessor configAccessor) - { - // In single-kid mode, all certificates are in a flat structure (no kid subfolders) - // The protector will only respond to the configured kid(s) - var passwordProvider = config.PasswordProvider ?? (config.Password != null ? _ => [config.Password] : null); - - var inventory = new CertificateInventory( - config.BasePath, - config.SearchPattern, - kid: null, // No kid filtering in inventory - configAccessor, - passwordProvider, - config.CacheDurationSeconds, - config.CertificateComparer, - includeSubdirectories: 0); // Flat structure, no subdirectories - - // Single-kid mode: respond only to the configured kid (plus any explicit additional kids). - var protector = new SingleKidProtectorWrapper(inventory, config.ForceSingleKid!, config.AdditionalKids); - - // Publish the current encryption public key for this single, unambiguous kid. - var keyInfo = new InventoryKeyInfoProvider(inventory, config.ForceSingleKid!); - RegisterProtectorAndKeyInfo(protector, keyInfo); - } - - private void ApplyMultiKidMode(CertificateProtectorConfig config, IConfigurationAccessor configAccessor) - { - var passwordProvider = config.PasswordProvider ?? (config.Password != null ? _ => [config.Password] : null); - - // Single global inventory watching the entire certificate tree recursively - var globalInventory = new CertificateInventory( - config.BasePath, - config.SearchPattern, - kid: null, - configAccessor, - passwordProvider, - config.CacheDurationSeconds, - config.CertificateComparer, - includeSubdirectories: -1); // Unlimited recursive - watches all kid folders - - // Register ONE protector that handles all kids dynamically, plus a folder-aware key-info - // provider that publishes the current public key per kid (= per tenant) on demand. - var protector = new X509HybridFolderSecretProtector(globalInventory); - var keyInfo = new FolderKeyInfoProvider(globalInventory); - RegisterProtectorAndKeyInfo(protector, keyInfo); - } - - private static void ValidateCertificateStructure(CertificateProtectorConfig config) - { - // Skip validation for single-kid mode - if (config.ForceSingleKid != null) - return; - - // Find all certificates recursively - if (!Directory.Exists(config.BasePath)) - return; - - var allCerts = Directory.GetFiles(config.BasePath, config.SearchPattern, SearchOption.AllDirectories); - var violations = new List(); - - foreach (var certPath in allCerts) - { - var relativePath = Path.GetRelativePath(config.BasePath, certPath); - var depth = CalculateDepth(relativePath); - - // Depth 0 = fallback (always allowed) - // Depth 1+ = must be <= MaxKidDepth + 1 (kid folder + depth within) - if (depth > 0 && depth > config.MaxKidDepth + 1) - { - violations.Add($" • {relativePath} (depth: {depth}, max: {config.MaxKidDepth + 1})"); - } - } - - if (violations.Count > 0) - { - var errorMsg = new StringBuilder(); - errorMsg.AppendLine(CultureInfo.InvariantCulture, $"Certificate folder structure violates MaxKidDepth={config.MaxKidDepth}."); - errorMsg.AppendLine(); - errorMsg.AppendLine("The following certificates are too deeply nested:"); - errorMsg.AppendLine(); - foreach (var violation in violations) - { - errorMsg.AppendLine(violation); - } - errorMsg.AppendLine(); - errorMsg.AppendLine(CultureInfo.InvariantCulture, $"With MaxKidDepth={config.MaxKidDepth}, certificates must be:"); - errorMsg.AppendLine(" • Directly in BasePath (depth 0, fallback certificates)"); - errorMsg.AppendLine(" • In immediate child folders (depth 1, kid folders)"); - if (config.MaxKidDepth > 1) - { - errorMsg.AppendLine(CultureInfo.InvariantCulture, $" • Up to depth {config.MaxKidDepth} within kid folders (depth 1+{config.MaxKidDepth} total)"); - } - errorMsg.AppendLine(); - errorMsg.AppendLine("Expected structure:"); - errorMsg.AppendLine(" BasePath/"); - errorMsg.AppendLine(" ├── fallback.pfx ← Depth 0 (optional fallback)"); - errorMsg.AppendLine(" ├── kid1/"); - errorMsg.AppendLine(" │ └── cert.pfx ← Depth 1 (kid certificates)"); - errorMsg.AppendLine(" └── kid2/"); - errorMsg.AppendLine(" └── cert.pfx ← Depth 1 (kid certificates)"); - - throw new InvalidOperationException(errorMsg.ToString()); - } - } - - private static int CalculateDepth(string relativePath) - { - var directoryPart = Path.GetDirectoryName(relativePath) ?? string.Empty; - if (string.IsNullOrEmpty(directoryPart) || directoryPart == ".") - return 0; - - var separators = directoryPart.Split( - new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, - StringSplitOptions.RemoveEmptyEntries); - - return separators.Length; - } -} - -/// -/// Protector wrapper for single-kid mode (flat certificate structure). -/// Accepts any of the configured kids and uses the flat certificate inventory. -/// -internal sealed class SingleKidProtectorWrapper : IRuntimeSecretEncryptor -{ - private static readonly System.Text.Json.JsonSerializerOptions EnvelopeDeserializationOptions = new() - { - Converters = { new Cocoar.Configuration.Secrets.Converters.Base64UrlByteArrayConverter() } - }; - - private readonly CertificateInventory _inventory; - private readonly HashSet _acceptedKids; - - public SingleKidProtectorWrapper(CertificateInventory inventory, string primaryKid, string[]? additionalKids) - { - _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); - _acceptedKids = new HashSet(StringComparer.OrdinalIgnoreCase) { primaryKid }; - - if (additionalKids != null) - { - foreach (var kid in additionalKids) - _acceptedKids.Add(kid); - } - } - - public bool CanDecrypt(string kid) - { - // Accept any of the configured kids - return _acceptedKids.Contains(kid); - } - - public byte[] UnprotectInternal(IEncryptedEnvelope envelope, string kid) - { - if (!CanDecrypt(kid)) - throw new InvalidOperationException($"This protector does not handle kid '{kid}'"); - - var hybridEnv = (HybridEnvelope)envelope; - - // Use the flat inventory (no kid subfolder - all certs in root) - if (_inventory.TryDecrypt(hybridEnv, out var plaintext)) - return plaintext; - - // Detailed error with actionable guidance - throw SecretDecryptionException.DecryptionFailed( - kid, - "RSA-OAEP-AES256-GCM", - new System.Security.Cryptography.CryptographicException($"No certificate could decrypt this envelope for kid '{kid}'")); - } - - public IEncryptedEnvelope ProtectInternal(ReadOnlySpan plaintext, string kid) - { - throw new NotSupportedException("Single-kid mode does not support encryption. Use UseCertificateFromFile with an encryption cert."); - } - - public IEncryptedEnvelope DeserializeEnvelope(string json) - { - return System.Text.Json.JsonSerializer.Deserialize(json, EnvelopeDeserializationOptions)!; - } -} +using System.Globalization; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.Secrets.Exceptions; +using Cocoar.Configuration.X509Encryption; +using Cocoar.Configuration.Secrets.Helpers; +using Cocoar.FileSystem; + +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +/// +/// Encapsulates configuration and setup logic for Hybrid/X.509 protectors. +/// Keeps SecretsSetupDeferredConfiguration thin by delegating feature-specific work here. +/// +internal sealed class HybridProtectorConfigurator(ConfigManagerCapabilityScope capabilityScope) +{ + private readonly ConfigManagerCapabilityScope _capabilityScope = capabilityScope ?? throw new ArgumentNullException(nameof(capabilityScope)); + + private void RegisterProtectorAndKeyInfo(IRuntimeSecretDecryptor protector, ISecretEncryptionKeyInfoProvider keyInfo) + { + var composition = _capabilityScope.Owner.GetComposition(); + if (composition == null) return; + + var recomposer = _capabilityScope.Recompose(composition); + recomposer.AddAs(protector); + recomposer.AddAs(keyInfo); + recomposer.Build(); + } + + /// + /// Unified method to apply certificate protector configuration. + /// + public void ApplyCertificateProtector(CertificateProtectorConfig config) + { + // Validate structure before proceeding + ValidateCertificateStructure(config); + + var configAccessor = _capabilityScope.Owner.Get(); + + // Single-Kid Mode: All certs use one kid + if (config.ForceSingleKid != null) + { + ApplySingleKidMode(config, configAccessor); + return; + } + + // Multi-Kid Mode: Use folder structure + ApplyMultiKidMode(config, configAccessor); + } + + private void ApplySingleKidMode(CertificateProtectorConfig config, IConfigurationAccessor configAccessor) + { + // In single-kid mode, all certificates are in a flat structure (no kid subfolders) + // The protector will only respond to the configured kid(s) + var passwordProvider = config.PasswordProvider ?? (config.Password != null ? _ => [config.Password] : null); + + var inventory = new CertificateInventory( + config.BasePath, + config.SearchPattern, + kid: null, // No kid filtering in inventory + configAccessor, + passwordProvider, + config.CacheDurationSeconds, + config.CertificateComparer, + includeSubdirectories: 0); // Flat structure, no subdirectories + + // Single-kid mode: respond only to the configured kid (plus any explicit additional kids). + var protector = new SingleKidProtectorWrapper(inventory, config.ForceSingleKid!, config.AdditionalKids); + + // Publish the current encryption public key for this single, unambiguous kid. + var keyInfo = new InventoryKeyInfoProvider(inventory, config.ForceSingleKid!); + RegisterProtectorAndKeyInfo(protector, keyInfo); + } + + private void ApplyMultiKidMode(CertificateProtectorConfig config, IConfigurationAccessor configAccessor) + { + var passwordProvider = config.PasswordProvider ?? (config.Password != null ? _ => [config.Password] : null); + + // Single global inventory watching the entire certificate tree recursively + var globalInventory = new CertificateInventory( + config.BasePath, + config.SearchPattern, + kid: null, + configAccessor, + passwordProvider, + config.CacheDurationSeconds, + config.CertificateComparer, + includeSubdirectories: -1); // Unlimited recursive - watches all kid folders + + // Register ONE protector that handles all kids dynamically, plus a folder-aware key-info + // provider that publishes the current public key per kid (= per tenant) on demand. + var protector = new X509HybridFolderSecretProtector(globalInventory); + var keyInfo = new FolderKeyInfoProvider(globalInventory); + RegisterProtectorAndKeyInfo(protector, keyInfo); + } + + private static void ValidateCertificateStructure(CertificateProtectorConfig config) + { + // Skip validation for single-kid mode + if (config.ForceSingleKid != null) + return; + + // Find all certificates recursively + if (!Directory.Exists(config.BasePath)) + return; + + var allCerts = Directory.GetFiles(config.BasePath, config.SearchPattern, SearchOption.AllDirectories); + var violations = new List(); + + foreach (var certPath in allCerts) + { + var relativePath = Path.GetRelativePath(config.BasePath, certPath); + var depth = CalculateDepth(relativePath); + + // Depth 0 = fallback (always allowed) + // Depth 1+ = must be <= MaxKidDepth + 1 (kid folder + depth within) + if (depth > 0 && depth > config.MaxKidDepth + 1) + { + violations.Add($" • {relativePath} (depth: {depth}, max: {config.MaxKidDepth + 1})"); + } + } + + if (violations.Count > 0) + { + var errorMsg = new StringBuilder(); + errorMsg.AppendLine(CultureInfo.InvariantCulture, $"Certificate folder structure violates MaxKidDepth={config.MaxKidDepth}."); + errorMsg.AppendLine(); + errorMsg.AppendLine("The following certificates are too deeply nested:"); + errorMsg.AppendLine(); + foreach (var violation in violations) + { + errorMsg.AppendLine(violation); + } + errorMsg.AppendLine(); + errorMsg.AppendLine(CultureInfo.InvariantCulture, $"With MaxKidDepth={config.MaxKidDepth}, certificates must be:"); + errorMsg.AppendLine(" • Directly in BasePath (depth 0, fallback certificates)"); + errorMsg.AppendLine(" • In immediate child folders (depth 1, kid folders)"); + if (config.MaxKidDepth > 1) + { + errorMsg.AppendLine(CultureInfo.InvariantCulture, $" • Up to depth {config.MaxKidDepth} within kid folders (depth 1+{config.MaxKidDepth} total)"); + } + errorMsg.AppendLine(); + errorMsg.AppendLine("Expected structure:"); + errorMsg.AppendLine(" BasePath/"); + errorMsg.AppendLine(" ├── fallback.pfx ← Depth 0 (optional fallback)"); + errorMsg.AppendLine(" ├── kid1/"); + errorMsg.AppendLine(" │ └── cert.pfx ← Depth 1 (kid certificates)"); + errorMsg.AppendLine(" └── kid2/"); + errorMsg.AppendLine(" └── cert.pfx ← Depth 1 (kid certificates)"); + + throw new InvalidOperationException(errorMsg.ToString()); + } + } + + private static int CalculateDepth(string relativePath) + { + var directoryPart = Path.GetDirectoryName(relativePath) ?? string.Empty; + if (string.IsNullOrEmpty(directoryPart) || directoryPart == ".") + return 0; + + var separators = directoryPart.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + + return separators.Length; + } +} + +/// +/// Protector wrapper for single-kid mode (flat certificate structure). +/// Accepts any of the configured kids and uses the flat certificate inventory. +/// +internal sealed class SingleKidProtectorWrapper : IRuntimeSecretEncryptor +{ + private static readonly System.Text.Json.JsonSerializerOptions EnvelopeDeserializationOptions = new() + { + Converters = { new Cocoar.Configuration.Secrets.Converters.Base64UrlByteArrayConverter() } + }; + + private readonly CertificateInventory _inventory; + private readonly HashSet _acceptedKids; + + public SingleKidProtectorWrapper(CertificateInventory inventory, string primaryKid, string[]? additionalKids) + { + _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); + _acceptedKids = new HashSet(StringComparer.OrdinalIgnoreCase) { primaryKid }; + + if (additionalKids != null) + { + foreach (var kid in additionalKids) + _acceptedKids.Add(kid); + } + } + + public bool CanDecrypt(string kid) + { + // Accept any of the configured kids + return _acceptedKids.Contains(kid); + } + + public byte[] UnprotectInternal(IEncryptedEnvelope envelope, string kid) + { + if (!CanDecrypt(kid)) + throw new InvalidOperationException($"This protector does not handle kid '{kid}'"); + + var hybridEnv = (HybridEnvelope)envelope; + + // Use the flat inventory (no kid subfolder - all certs in root) + if (_inventory.TryDecrypt(hybridEnv, out var plaintext)) + return plaintext; + + // Detailed error with actionable guidance + throw SecretDecryptionException.DecryptionFailed( + kid, + "RSA-OAEP-AES256-GCM", + new System.Security.Cryptography.CryptographicException($"No certificate could decrypt this envelope for kid '{kid}'")); + } + + public IEncryptedEnvelope ProtectInternal(ReadOnlySpan plaintext, string kid) + { + throw new NotSupportedException("Single-kid mode does not support encryption. Use UseCertificateFromFile with an encryption cert."); + } + + public IEncryptedEnvelope DeserializeEnvelope(string json) + { + return System.Text.Json.JsonSerializer.Deserialize(json, EnvelopeDeserializationOptions)!; + } +} diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorSetupContributor.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorSetupContributor.cs index c02ec82..e066cb6 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorSetupContributor.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorSetupContributor.cs @@ -1,19 +1,19 @@ -using Cocoar.Capabilities; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Secrets.Core; -using Cocoar.Configuration.X509Encryption; - -namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; - -/// -/// Setup contributor for X509 hybrid protectors. -/// Handles registration of certificate-based encryption/decryption during secrets initialization. -/// -internal sealed class HybridProtectorSetupContributor : ISecretsSetupContributor -{ - public void Apply(ConfigManagerCapabilityScope scope, IComposition composition) - { - var registrar = new HybridProtectorConfigurator(scope); - composition.UsingLast(registrar.ApplyCertificateProtector); - } -} +using Cocoar.Capabilities; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.X509Encryption; + +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +/// +/// Setup contributor for X509 hybrid protectors. +/// Handles registration of certificate-based encryption/decryption during secrets initialization. +/// +internal sealed class HybridProtectorSetupContributor : ISecretsSetupContributor +{ + public void Apply(ConfigManagerCapabilityScope scope, IComposition composition) + { + var registrar = new HybridProtectorConfigurator(scope); + composition.UsingLast(registrar.ApplyCertificateProtector); + } +} diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/SecretsHybridExtensions.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/SecretsHybridExtensions.cs index 845bdf2..d083f29 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/SecretsHybridExtensions.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/SecretsHybridExtensions.cs @@ -1,137 +1,137 @@ -using Cocoar.Capabilities; -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Secrets.Core; -using Cocoar.Configuration.X509Encryption; -using Cocoar.Configuration.Secrets.Protectors.Hybrid; - - -namespace Cocoar.Configuration.Secrets; - -public static class SecretsHybridExtensions -{ - private static void EnsureContributorRegistered(Composer? composer) - { - if (composer != null && !composer.Has()) - { - composer.AddAs(new HybridProtectorSetupContributor()); - } - } - - /// - /// Registers a single certificate file for hybrid encryption/decryption. - /// Certificate must be password-less and protected by file system permissions. - /// - /// The secrets builder. - /// Path to the certificate file (PFX or PEM). - /// - /// Best practice: Use password-less certificates protected by file permissions (chmod 600 on Linux/macOS, ACLs on Windows). - /// If you have a password-protected certificate, use 'cocoar-secrets convert-cert' CLI command to convert it. - /// - public static CertificateSetupBuilder UseCertificateFromFile(this SecretsBuilder builder, string pfxPath) - { - EnsureContributorRegistered(SecretsBuilder.GetComposerFor(builder)); - return new(SecretsBuilder.GetCapabilityScopeFor(builder), builder, SecretsBuilder.GetComposerFor(builder), pfxPath); - } - - /// - /// Registers certificates from a folder for hybrid encryption/decryption. - /// Supports multiple certificate formats: - /// - PKCS#12: .pfx, .p12 (password-less archives) - /// - PEM: .pem, .crt, .cer (requires matching .key file with same base name) - /// - /// All certificates must be password-less and protected by file system permissions. - /// Supports kid-based subdirectories for multi-tenant scenarios: basePath/{kid}/cert.pfx - /// - /// The secrets builder. - /// Path to folder containing certificates. - /// - /// File pattern to search for certificates. Default "*" searches all supported formats. - /// Examples: - /// - "*" (default) - Auto-discover all formats (*.pfx, *.p12, *.pem, *.crt, *.cer) - /// - "*.pfx" - Only PFX files - /// - "*.pfx;*.p12" - Multiple patterns (semicolon-separated) - /// - "*.crt" - Only PEM certificates with matching .key files - /// Note: PEM certificates (.crt, .cer, .pem) require a matching .key file with the same base name. - /// - /// How long to cache loaded certificates (default: 30 seconds). - /// Optional comparer for certificate selection order. - /// - /// Best practice: Use password-less certificates protected by file permissions. - /// - Linux/macOS: chmod 600 cert.pfx && chown app-user cert.pfx - /// - Windows: icacls cert.pfx /inheritance:r /grant:r "AppUser:(R)" - /// - /// If you have password-protected certificates, use 'cocoar-secrets convert-cert' CLI command to convert them. - /// - public static SecretsBuilder UseCertificatesFromFolder( - this SecretsBuilder builder, - string basePath, - string searchPattern = "*", - int cacheDurationSeconds = 30, - IComparer? certificateComparer = null) - { - EnsureContributorRegistered(SecretsBuilder.GetComposerFor(builder)); - var composer = SecretsBuilder.GetComposerFor(builder); - - composer?.Add(new CertificateProtectorConfig - { - BasePath = basePath, - SearchPattern = searchPattern, - ForceSingleKid = null, // Multi-Kid mode - PasswordProvider = null, // Password-less only - CacheDurationSeconds = cacheDurationSeconds, - CertificateComparer = certificateComparer - }); - - return builder; - } -} - -public sealed class CertificateSetupBuilder: SetupDefinition -{ - private readonly SecretsBuilder _setup; - private readonly Composer? _composer; - private readonly string _pfxPath; - private string _keyId = "hybrid-encryption"; - private readonly List _additionalKids = new(); - - internal CertificateSetupBuilder(ConfigManagerCapabilityScope capabilityScope, SecretsBuilder setup, Composer? composer, string pfxPath) : base(capabilityScope) - { - _setup = setup; - _composer = composer; - _pfxPath = pfxPath; - } - - public CertificateSetupBuilder WithKeyId(string keyId) - { - _keyId = keyId; - return this; - } - - public CertificateSetupBuilder WithAdditionalKeyId(string additionalKeyId) - { - if (!_additionalKids.Contains(additionalKeyId)) - { - _additionalKids.Add(additionalKeyId); - } - return this; - } - - internal override SetupDefinition Build() - { - // Resolve path relative to application directory - var fullPath = Path.IsPathRooted(_pfxPath) - ? _pfxPath - : Path.Combine(AppContext.BaseDirectory, _pfxPath); - - _composer?.Add(new CertificateProtectorConfig - { - BasePath = Path.GetDirectoryName(fullPath) ?? AppContext.BaseDirectory, - SearchPattern = Path.GetFileName(fullPath) ?? "*.pfx", - ForceSingleKid = _keyId, - AdditionalKids = _additionalKids.Count > 0 ? _additionalKids.ToArray() : null, - Password = null // Password-less only - }); - return _setup; - } -} +using Cocoar.Capabilities; +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.X509Encryption; +using Cocoar.Configuration.Secrets.Protectors.Hybrid; + + +namespace Cocoar.Configuration.Secrets; + +public static class SecretsHybridExtensions +{ + private static void EnsureContributorRegistered(Composer? composer) + { + if (composer != null && !composer.Has()) + { + composer.AddAs(new HybridProtectorSetupContributor()); + } + } + + /// + /// Registers a single certificate file for hybrid encryption/decryption. + /// Certificate must be password-less and protected by file system permissions. + /// + /// The secrets builder. + /// Path to the certificate file (PFX or PEM). + /// + /// Best practice: Use password-less certificates protected by file permissions (chmod 600 on Linux/macOS, ACLs on Windows). + /// If you have a password-protected certificate, use 'cocoar-secrets convert-cert' CLI command to convert it. + /// + public static CertificateSetupBuilder UseCertificateFromFile(this SecretsBuilder builder, string pfxPath) + { + EnsureContributorRegistered(SecretsBuilder.GetComposerFor(builder)); + return new(SecretsBuilder.GetCapabilityScopeFor(builder), builder, SecretsBuilder.GetComposerFor(builder), pfxPath); + } + + /// + /// Registers certificates from a folder for hybrid encryption/decryption. + /// Supports multiple certificate formats: + /// - PKCS#12: .pfx, .p12 (password-less archives) + /// - PEM: .pem, .crt, .cer (requires matching .key file with same base name) + /// + /// All certificates must be password-less and protected by file system permissions. + /// Supports kid-based subdirectories for multi-tenant scenarios: basePath/{kid}/cert.pfx + /// + /// The secrets builder. + /// Path to folder containing certificates. + /// + /// File pattern to search for certificates. Default "*" searches all supported formats. + /// Examples: + /// - "*" (default) - Auto-discover all formats (*.pfx, *.p12, *.pem, *.crt, *.cer) + /// - "*.pfx" - Only PFX files + /// - "*.pfx;*.p12" - Multiple patterns (semicolon-separated) + /// - "*.crt" - Only PEM certificates with matching .key files + /// Note: PEM certificates (.crt, .cer, .pem) require a matching .key file with the same base name. + /// + /// How long to cache loaded certificates (default: 30 seconds). + /// Optional comparer for certificate selection order. + /// + /// Best practice: Use password-less certificates protected by file permissions. + /// - Linux/macOS: chmod 600 cert.pfx && chown app-user cert.pfx + /// - Windows: icacls cert.pfx /inheritance:r /grant:r "AppUser:(R)" + /// + /// If you have password-protected certificates, use 'cocoar-secrets convert-cert' CLI command to convert them. + /// + public static SecretsBuilder UseCertificatesFromFolder( + this SecretsBuilder builder, + string basePath, + string searchPattern = "*", + int cacheDurationSeconds = 30, + IComparer? certificateComparer = null) + { + EnsureContributorRegistered(SecretsBuilder.GetComposerFor(builder)); + var composer = SecretsBuilder.GetComposerFor(builder); + + composer?.Add(new CertificateProtectorConfig + { + BasePath = basePath, + SearchPattern = searchPattern, + ForceSingleKid = null, // Multi-Kid mode + PasswordProvider = null, // Password-less only + CacheDurationSeconds = cacheDurationSeconds, + CertificateComparer = certificateComparer + }); + + return builder; + } +} + +public sealed class CertificateSetupBuilder: SetupDefinition +{ + private readonly SecretsBuilder _setup; + private readonly Composer? _composer; + private readonly string _pfxPath; + private string _keyId = "hybrid-encryption"; + private readonly List _additionalKids = new(); + + internal CertificateSetupBuilder(ConfigManagerCapabilityScope capabilityScope, SecretsBuilder setup, Composer? composer, string pfxPath) : base(capabilityScope) + { + _setup = setup; + _composer = composer; + _pfxPath = pfxPath; + } + + public CertificateSetupBuilder WithKeyId(string keyId) + { + _keyId = keyId; + return this; + } + + public CertificateSetupBuilder WithAdditionalKeyId(string additionalKeyId) + { + if (!_additionalKids.Contains(additionalKeyId)) + { + _additionalKids.Add(additionalKeyId); + } + return this; + } + + internal override SetupDefinition Build() + { + // Resolve path relative to application directory + var fullPath = Path.IsPathRooted(_pfxPath) + ? _pfxPath + : Path.Combine(AppContext.BaseDirectory, _pfxPath); + + _composer?.Add(new CertificateProtectorConfig + { + BasePath = Path.GetDirectoryName(fullPath) ?? AppContext.BaseDirectory, + SearchPattern = Path.GetFileName(fullPath) ?? "*.pfx", + ForceSingleKid = _keyId, + AdditionalKids = _additionalKids.Count > 0 ? _additionalKids.ToArray() : null, + Password = null // Password-less only + }); + return _setup; + } +} diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/X509HybridFolderSecretProtector.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/X509HybridFolderSecretProtector.cs index ce8c414..115e499 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/X509HybridFolderSecretProtector.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/X509HybridFolderSecretProtector.cs @@ -1,116 +1,116 @@ -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text.Json; -using Cocoar.Configuration.Secrets.Converters; -using Cocoar.Configuration.Secrets.Core; -using Cocoar.Configuration.Secrets.Exceptions; -using Cocoar.Configuration.X509Encryption; - -namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; - -/// -/// Folder-based hybrid protector with dynamic kid discovery. -/// Uses CertificateInventory to manage multiple certificates with automatic discovery and rotation. -/// -internal sealed class X509HybridFolderSecretProtector : ISecretEncryptor, IRuntimeSecretEncryptor -{ - private readonly CertificateInventory _inventory; - private readonly X509Certificate2? _encryptionCert; - private readonly RSA? _encryptionRsa; - private readonly string? _encryptionKid; - - private static readonly JsonSerializerOptions SerializerOptions = new() - { - Converters = { new Base64UrlByteArrayConverter() } - }; - - /// - /// Create a folder-based protector (decrypt-only). - /// - public X509HybridFolderSecretProtector(CertificateInventory inventory) - { - _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); - } - - /// - /// Create a folder-based protector with a specific cert for encryption. - /// Decryption will try all certs in the kid-specific folder with intelligent caching. - /// - public X509HybridFolderSecretProtector(CertificateInventory inventory, X509Certificate2 encryptionCert, string encryptionKid) - : this(inventory) - { - if (!encryptionCert.HasPrivateKey) - throw new ArgumentException("Encryption certificate must contain a private key", nameof(encryptionCert)); - - _encryptionCert = encryptionCert; - _encryptionRsa = encryptionCert.GetRSAPrivateKey() - ?? throw new InvalidOperationException("RSA private key not available on encryption certificate"); - _encryptionKid = encryptionKid ?? throw new ArgumentNullException(nameof(encryptionKid)); - } - - public bool CanDecrypt(string kid) - { - return _inventory.HasCertificatesFor(kid); - } - - public HybridEnvelope Protect(ReadOnlySpan plaintext, string kid) - { - if (_encryptionCert == null || _encryptionRsa == null) - throw new NotSupportedException("This protector is decrypt-only. No encryption certificate was provided."); - - Span dek = stackalloc byte[32]; - RandomNumberGenerator.Fill(dek); - - try - { - byte[] iv = new byte[12]; - RandomNumberGenerator.Fill(iv); - byte[] ct = new byte[plaintext.Length]; - byte[] tag = new byte[16]; - - using (var aes = new AesGcm(dek, tag.Length)) - { - aes.Encrypt(iv, plaintext, ct, tag, associatedData: null); - } - - var wrappedKey = _encryptionRsa.Encrypt(dek.ToArray(), RSAEncryptionPadding.OaepSHA256); - - return new HybridEnvelope - { - WrappedKey = wrappedKey, - WrappingAlgorithm = "RSA-OAEP-256", - Iv = iv, - Ciphertext = ct, - Tag = tag - }; - } - finally - { - CryptographicOperations.ZeroMemory(dek); - } - } - - public byte[] Unprotect(HybridEnvelope envelope, string kid) - { - if (_inventory.TryDecryptWithKid(envelope, kid, out var plaintext)) - return plaintext; - - // Detailed error with actionable guidance - throw SecretDecryptionException.DecryptionFailed( - kid, - "RSA-OAEP-AES256-GCM", - new CryptographicException($"No certificate in kid folder '{kid}' could decrypt this envelope")); - } - - bool IRuntimeSecretDecryptor.CanDecrypt(string kid) - => CanDecrypt(kid); - - IEncryptedEnvelope IRuntimeSecretEncryptor.ProtectInternal(ReadOnlySpan plaintext, string kid) - => Protect(plaintext, kid); - - byte[] IRuntimeSecretDecryptor.UnprotectInternal(IEncryptedEnvelope envelope, string kid) - => Unprotect((HybridEnvelope)envelope, kid); - - IEncryptedEnvelope IRuntimeSecretDecryptor.DeserializeEnvelope(string json) - => JsonSerializer.Deserialize(json, SerializerOptions)!; -} +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using Cocoar.Configuration.Secrets.Converters; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.Secrets.Exceptions; +using Cocoar.Configuration.X509Encryption; + +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +/// +/// Folder-based hybrid protector with dynamic kid discovery. +/// Uses CertificateInventory to manage multiple certificates with automatic discovery and rotation. +/// +internal sealed class X509HybridFolderSecretProtector : ISecretEncryptor, IRuntimeSecretEncryptor +{ + private readonly CertificateInventory _inventory; + private readonly X509Certificate2? _encryptionCert; + private readonly RSA? _encryptionRsa; + private readonly string? _encryptionKid; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + Converters = { new Base64UrlByteArrayConverter() } + }; + + /// + /// Create a folder-based protector (decrypt-only). + /// + public X509HybridFolderSecretProtector(CertificateInventory inventory) + { + _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); + } + + /// + /// Create a folder-based protector with a specific cert for encryption. + /// Decryption will try all certs in the kid-specific folder with intelligent caching. + /// + public X509HybridFolderSecretProtector(CertificateInventory inventory, X509Certificate2 encryptionCert, string encryptionKid) + : this(inventory) + { + if (!encryptionCert.HasPrivateKey) + throw new ArgumentException("Encryption certificate must contain a private key", nameof(encryptionCert)); + + _encryptionCert = encryptionCert; + _encryptionRsa = encryptionCert.GetRSAPrivateKey() + ?? throw new InvalidOperationException("RSA private key not available on encryption certificate"); + _encryptionKid = encryptionKid ?? throw new ArgumentNullException(nameof(encryptionKid)); + } + + public bool CanDecrypt(string kid) + { + return _inventory.HasCertificatesFor(kid); + } + + public HybridEnvelope Protect(ReadOnlySpan plaintext, string kid) + { + if (_encryptionCert == null || _encryptionRsa == null) + throw new NotSupportedException("This protector is decrypt-only. No encryption certificate was provided."); + + Span dek = stackalloc byte[32]; + RandomNumberGenerator.Fill(dek); + + try + { + byte[] iv = new byte[12]; + RandomNumberGenerator.Fill(iv); + byte[] ct = new byte[plaintext.Length]; + byte[] tag = new byte[16]; + + using (var aes = new AesGcm(dek, tag.Length)) + { + aes.Encrypt(iv, plaintext, ct, tag, associatedData: null); + } + + var wrappedKey = _encryptionRsa.Encrypt(dek.ToArray(), RSAEncryptionPadding.OaepSHA256); + + return new HybridEnvelope + { + WrappedKey = wrappedKey, + WrappingAlgorithm = "RSA-OAEP-256", + Iv = iv, + Ciphertext = ct, + Tag = tag + }; + } + finally + { + CryptographicOperations.ZeroMemory(dek); + } + } + + public byte[] Unprotect(HybridEnvelope envelope, string kid) + { + if (_inventory.TryDecryptWithKid(envelope, kid, out var plaintext)) + return plaintext; + + // Detailed error with actionable guidance + throw SecretDecryptionException.DecryptionFailed( + kid, + "RSA-OAEP-AES256-GCM", + new CryptographicException($"No certificate in kid folder '{kid}' could decrypt this envelope")); + } + + bool IRuntimeSecretDecryptor.CanDecrypt(string kid) + => CanDecrypt(kid); + + IEncryptedEnvelope IRuntimeSecretEncryptor.ProtectInternal(ReadOnlySpan plaintext, string kid) + => Protect(plaintext, kid); + + byte[] IRuntimeSecretDecryptor.UnprotectInternal(IEncryptedEnvelope envelope, string kid) + => Unprotect((HybridEnvelope)envelope, kid); + + IEncryptedEnvelope IRuntimeSecretDecryptor.DeserializeEnvelope(string json) + => JsonSerializer.Deserialize(json, SerializerOptions)!; +} diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/X509SelfSignedOptions.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/X509SelfSignedOptions.cs index 04b2b05..1fe9133 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/X509SelfSignedOptions.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/X509SelfSignedOptions.cs @@ -1,17 +1,17 @@ -using System.Security.Cryptography.X509Certificates; - -namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; - -/// -/// Defaults for creating and locating a self-signed X.509 certificate -/// used by the Hybrid (RSA+AES-GCM) protector for AutoProtect write scenarios. -/// -public sealed class X509SelfSignedCertificateOptions -{ - public string SubjectName { get; set; } = "CN=Cocoar.Configuration.AutoProtect"; - public int KeySize { get; set; } = 2048; - public int ValidityYears { get; set; } = 5; - public StoreLocation StoreLocation { get; set; } = StoreLocation.CurrentUser; - public StoreName StoreName { get; set; } = StoreName.My; - -} +using System.Security.Cryptography.X509Certificates; + +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +/// +/// Defaults for creating and locating a self-signed X.509 certificate +/// used by the Hybrid (RSA+AES-GCM) protector for AutoProtect write scenarios. +/// +public sealed class X509SelfSignedCertificateOptions +{ + public string SubjectName { get; set; } = "CN=Cocoar.Configuration.AutoProtect"; + public int KeySize { get; set; } = 2048; + public int ValidityYears { get; set; } = 5; + public StoreLocation StoreLocation { get; set; } = StoreLocation.CurrentUser; + public StoreName StoreName { get; set; } = StoreName.My; + +} diff --git a/src/Cocoar.Configuration/Secrets/SecretTypes/ByteArraySecretDeserializer.cs b/src/Cocoar.Configuration/Secrets/SecretTypes/ByteArraySecretDeserializer.cs index 99b1ac2..44f3505 100644 --- a/src/Cocoar.Configuration/Secrets/SecretTypes/ByteArraySecretDeserializer.cs +++ b/src/Cocoar.Configuration/Secrets/SecretTypes/ByteArraySecretDeserializer.cs @@ -1,120 +1,120 @@ -using System.Buffers; -using System.Buffers.Text; -using System.Text.Json; - -namespace Cocoar.Configuration.Secrets.SecretTypes; - -/// -/// Handles deserialization of Secret<byte[]> with special logic for: -/// - Base64-encoded strings (decoded to bytes) -/// - Plain strings (converted to UTF-8 bytes) -/// - Non-string JSON (returned as raw UTF-8 bytes) -/// -internal static class ByteArraySecretDeserializer -{ - /// - /// Deserializes JSON bytes to byte[] with intelligent type detection. - /// Keeps decrypted bytes in memory for minimum duration. - /// - /// The JSON representation (already decrypted) - /// Whether the input bytes should be cleaned up - /// A SecretLease containing the byte array value - public static SecretLease Deserialize(byte[] jsonBytes, bool needsCleanup) - { - try - { - var reader = new Utf8JsonReader(jsonBytes, isFinalBlock: true, state: default); - - if (!reader.Read()) - { - // Empty or invalid JSON - return raw bytes - return CreateLease(jsonBytes, needsCleanup, additionalCleanup: null); - } - - if (reader.TokenType == JsonTokenType.String) - { - return HandleStringToken(ref reader, jsonBytes, needsCleanup); - } - - // Non-string JSON - return raw UTF-8 bytes - return CreateLease(jsonBytes, needsCleanup, additionalCleanup: null); - } - catch - { - // On any parsing error, return raw bytes - return CreateLease(jsonBytes, needsCleanup, additionalCleanup: null); - } - } - - private static SecretLease HandleStringToken(ref Utf8JsonReader reader, byte[] jsonBytes, bool needsCleanup) - { - // Extract string content bytes - byte[] stringContentBytes; - if (reader.HasValueSequence) - { - stringContentBytes = ExtractFromSequence(reader.ValueSequence); - } - else - { - stringContentBytes = reader.ValueSpan.ToArray(); - } - - // Try Base64 decode - if (TryDecodeBase64(stringContentBytes, out var decoded, out var decodedLength)) - { - // Successfully decoded as Base64 - if (decodedLength != decoded.Length) - { - Array.Resize(ref decoded, decodedLength); - } - - return CreateLease(decoded, needsCleanup, additionalCleanup: decoded); - } - - // Not valid Base64, return string content as UTF-8 bytes - return CreateLease(stringContentBytes, needsCleanup, additionalCleanup: stringContentBytes); - } - - private static byte[] ExtractFromSequence(ReadOnlySequence sequence) - { - var length = checked((int)sequence.Length); - var bytes = new byte[length]; - var offset = 0; - - foreach (var segment in sequence) - { - segment.Span.CopyTo(bytes.AsSpan(offset)); - offset += segment.Length; - } - - return bytes; - } - - private static bool TryDecodeBase64(byte[] input, out byte[] decoded, out int decodedLength) - { - var maxLen = Base64.GetMaxDecodedFromUtf8Length(input.Length); - decoded = new byte[maxLen]; - - var status = Base64.DecodeFromUtf8(input, decoded, out var consumed, out decodedLength); - - return status == OperationStatus.Done && consumed == input.Length; - } - - private static SecretLease CreateLease(byte[] value, bool needsCleanup, byte[]? additionalCleanup) - { - Action cleanup = () => - { - if (needsCleanup) - { - Array.Clear(value, 0, value.Length); - } - - if (additionalCleanup != null && !ReferenceEquals(additionalCleanup, value)) - { - Array.Clear(additionalCleanup, 0, additionalCleanup.Length); - } - }; - - return new SecretLease((T)(object)value, cleanup); - } -} +using System.Buffers; +using System.Buffers.Text; +using System.Text.Json; + +namespace Cocoar.Configuration.Secrets.SecretTypes; + +/// +/// Handles deserialization of Secret<byte[]> with special logic for: +/// - Base64-encoded strings (decoded to bytes) +/// - Plain strings (converted to UTF-8 bytes) +/// - Non-string JSON (returned as raw UTF-8 bytes) +/// +internal static class ByteArraySecretDeserializer +{ + /// + /// Deserializes JSON bytes to byte[] with intelligent type detection. + /// Keeps decrypted bytes in memory for minimum duration. + /// + /// The JSON representation (already decrypted) + /// Whether the input bytes should be cleaned up + /// A SecretLease containing the byte array value + public static SecretLease Deserialize(byte[] jsonBytes, bool needsCleanup) + { + try + { + var reader = new Utf8JsonReader(jsonBytes, isFinalBlock: true, state: default); + + if (!reader.Read()) + { + // Empty or invalid JSON - return raw bytes + return CreateLease(jsonBytes, needsCleanup, additionalCleanup: null); + } + + if (reader.TokenType == JsonTokenType.String) + { + return HandleStringToken(ref reader, jsonBytes, needsCleanup); + } + + // Non-string JSON - return raw UTF-8 bytes + return CreateLease(jsonBytes, needsCleanup, additionalCleanup: null); + } + catch + { + // On any parsing error, return raw bytes + return CreateLease(jsonBytes, needsCleanup, additionalCleanup: null); + } + } + + private static SecretLease HandleStringToken(ref Utf8JsonReader reader, byte[] jsonBytes, bool needsCleanup) + { + // Extract string content bytes + byte[] stringContentBytes; + if (reader.HasValueSequence) + { + stringContentBytes = ExtractFromSequence(reader.ValueSequence); + } + else + { + stringContentBytes = reader.ValueSpan.ToArray(); + } + + // Try Base64 decode + if (TryDecodeBase64(stringContentBytes, out var decoded, out var decodedLength)) + { + // Successfully decoded as Base64 + if (decodedLength != decoded.Length) + { + Array.Resize(ref decoded, decodedLength); + } + + return CreateLease(decoded, needsCleanup, additionalCleanup: decoded); + } + + // Not valid Base64, return string content as UTF-8 bytes + return CreateLease(stringContentBytes, needsCleanup, additionalCleanup: stringContentBytes); + } + + private static byte[] ExtractFromSequence(ReadOnlySequence sequence) + { + var length = checked((int)sequence.Length); + var bytes = new byte[length]; + var offset = 0; + + foreach (var segment in sequence) + { + segment.Span.CopyTo(bytes.AsSpan(offset)); + offset += segment.Length; + } + + return bytes; + } + + private static bool TryDecodeBase64(byte[] input, out byte[] decoded, out int decodedLength) + { + var maxLen = Base64.GetMaxDecodedFromUtf8Length(input.Length); + decoded = new byte[maxLen]; + + var status = Base64.DecodeFromUtf8(input, decoded, out var consumed, out decodedLength); + + return status == OperationStatus.Done && consumed == input.Length; + } + + private static SecretLease CreateLease(byte[] value, bool needsCleanup, byte[]? additionalCleanup) + { + Action cleanup = () => + { + if (needsCleanup) + { + Array.Clear(value, 0, value.Length); + } + + if (additionalCleanup != null && !ReferenceEquals(additionalCleanup, value)) + { + Array.Clear(additionalCleanup, 0, additionalCleanup.Length); + } + }; + + return new SecretLease((T)(object)value, cleanup); + } +} diff --git a/src/Cocoar.Configuration/Secrets/SecretTypes/Secret.cs b/src/Cocoar.Configuration/Secrets/SecretTypes/Secret.cs index 6852c93..b7aaa6b 100644 --- a/src/Cocoar.Configuration/Secrets/SecretTypes/Secret.cs +++ b/src/Cocoar.Configuration/Secrets/SecretTypes/Secret.cs @@ -1,207 +1,207 @@ -using System.Text.Json; -using Cocoar.Configuration.Secrets.Core; -using Cocoar.Configuration.X509Encryption; - -namespace Cocoar.Configuration.Secrets.SecretTypes; - -public sealed class Secret : ISecret -{ - private byte[]? _plainBytes; - private SecretEnvelopeWrapper? _envelope; - private SecretsDecryptorResolver? _resolver; - private bool _disposed; - private readonly bool _blockPlaintextAccess; -#if NET9_0_OR_GREATER - private readonly Lock _lock = new(); -#else - private readonly object _lock = new(); -#endif - - internal Secret(T plain, SecretsDecryptorResolver? resolver = null, bool allowPlaintext = false) - { - // Serialize directly to UTF-8 bytes — never create an intermediate string. - // Lenient options (enums as names) keep the payload round-trip-safe and consistent with the read side. - _plainBytes = JsonSerializer.SerializeToUtf8Bytes(plain, SecretValueSerialization.Options); - _resolver = resolver; - _blockPlaintextAccess = !allowPlaintext; // Block access if plaintext is NOT explicitly allowed - } - - internal Secret(SecretEnvelopeWrapper envelope, SecretsDecryptorResolver? resolver = null) - { - ArgumentNullException.ThrowIfNull(envelope); - _envelope = envelope; - _resolver = resolver; - _blockPlaintextAccess = false; - } - - public override string ToString() => "***"; - - public SecretLease Open() - { - lock (_lock) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - ValidatePlaintextAccess(); - - // Get decrypted bytes - decrypt at the LAST possible moment - byte[] bytes; - bool needsCleanup; - - if (_plainBytes is { } plain) - { - System.Diagnostics.Trace.TraceWarning( - $"Cocoar.Configuration: Secret<{typeof(T).Name}>.Open() is accessing a plaintext secret. " + - "This is acceptable for development and testing but should not occur in production. " + - "Use encrypted envelopes for production deployments."); - bytes = plain; - needsCleanup = false; - } - else if (_envelope is { } env) - { - // CRITICAL: Decrypt here, right before deserialization - bytes = DecryptEnvelope(env); - needsCleanup = true; - } - else - { - throw new ObjectDisposedException(nameof(Secret)); - } - - // Deserialize and create lease immediately after decryption - try - { - return DeserializeAndCreateLease(bytes, needsCleanup); - } - catch - { - if (needsCleanup) - { - Array.Clear(bytes, 0, bytes.Length); - } - throw; - } - } - } - - private void ValidatePlaintextAccess() - { - if (_blockPlaintextAccess) - { - throw new InvalidOperationException( - $"Secret<{typeof(T).Name}> was deserialized from plaintext JSON instead of an encrypted envelope. " + - "Pre-encrypted envelopes are required for security. Ensure your configuration source delivers " + - "secrets in encrypted envelope format with the '_cocoar_secret' marker."); - } - } - - private byte[] DecryptEnvelope(SecretEnvelopeWrapper env) - { - if (_resolver is null) - { - throw new InvalidOperationException( - $"Cannot decrypt Secret<{typeof(T).Name}>: secrets infrastructure not configured.\n\n" + - "To fix, add secrets setup when creating ConfigManager:\n\n" + - " ConfigManager.Create(c => c\n" + - " .UseConfiguration(rules => [...])\n" + - " .UseSecretsSetup(secrets => secrets\n" + - " .UseCertificateFromFile(\"cert.pfx\")));\n\n" + - "Or with DI:\n\n" + - " services.AddCocoarConfiguration(c => c\n" + - " .UseConfiguration(rules => [...])\n" + - " .UseSecretsSetup(secrets => secrets\n" + - " .UseCertificateFromFile(\"cert.pfx\")));"); - } - - var protector = _resolver.ResolveForKid(env.Kid); - var envelopeJson = env.Data.GetRawText(); - var envelope = protector.DeserializeEnvelope(envelopeJson); - - return protector.UnprotectInternal(envelope, env.Kid); - } - - private static SecretLease DeserializeAndCreateLease(byte[] bytes, bool needsCleanup) - { - // byte[] — uses Utf8JsonReader directly with zero-copy Base64 decode. - // All allocations tracked for cleanup. See ByteArraySecretDeserializer. - if (typeof(T) == typeof(byte[])) - { - return ByteArraySecretDeserializer.Deserialize(bytes, needsCleanup); - } - - // Deserialize directly from UTF-8 bytes — no intermediate string allocation. - // Security at rest: before Open(), secrets exist only as encrypted envelopes. - // At Open() time, the consumer needs the value (to pass to HttpClient, DB, etc.) - // so converting to T is unavoidable. The decrypted bytes are zeroed on lease dispose. - var value = JsonSerializer.Deserialize(new ReadOnlySpan(bytes), SecretValueSerialization.Options); - - if (value is null) - { - if (TypeAcceptsNull()) - { - return new SecretLease(value!, CreateCleanupAction(bytes, needsCleanup)); - } - - throw new JsonException($"Failed to deserialize secret value of type {typeof(T).Name} - result was null"); - } - - return new SecretLease(value, CreateCleanupAction(bytes, needsCleanup)); - } - - /// - /// Returns true if type T can legally hold a null value. - /// This includes reference types (string?, object?) and nullable value types (int?, bool?). - /// - private static bool TypeAcceptsNull() - { - // Reference types where default is null - if (!typeof(T).IsValueType) - return true; - // Nullable value types (int?, bool?, etc.) - return Nullable.GetUnderlyingType(typeof(T)) != null; - } - - private static Action CreateCleanupAction(byte[] bytes, bool needsCleanup) - { - return needsCleanup - ? () => Array.Clear(bytes, 0, bytes.Length) - : () => { }; - } - - public void Dispose() - { - lock (_lock) - { - if (_disposed) return; - - if (_plainBytes is { } bytes) - { - Array.Clear(bytes, 0, bytes.Length); - _plainBytes = null; - } - - _envelope = null; - _disposed = true; - } - } - - /// - /// Creates a Secret from plaintext value. For testing/development only. - /// Use pre-encrypted envelopes in production. - /// - internal static Secret FromPlain(T value) => new(value, resolver: null, allowPlaintext: true); - - internal static Secret FromEnvelope(JsonElement element) - { - if (!SecretEnvelopeWrapper.TryParse(element, out var env) || env is null) - throw new FormatException($"Invalid secret envelope for Secret<{typeof(T).Name}>"); - - return new Secret(env, resolver: null); - } -} - - -public static class Secret -{ - public static Secret FromPlain(T value) => Secret.FromPlain(value); -} +using System.Text.Json; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.X509Encryption; + +namespace Cocoar.Configuration.Secrets.SecretTypes; + +public sealed class Secret : ISecret +{ + private byte[]? _plainBytes; + private SecretEnvelopeWrapper? _envelope; + private SecretsDecryptorResolver? _resolver; + private bool _disposed; + private readonly bool _blockPlaintextAccess; +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + + internal Secret(T plain, SecretsDecryptorResolver? resolver = null, bool allowPlaintext = false) + { + // Serialize directly to UTF-8 bytes — never create an intermediate string. + // Lenient options (enums as names) keep the payload round-trip-safe and consistent with the read side. + _plainBytes = JsonSerializer.SerializeToUtf8Bytes(plain, SecretValueSerialization.Options); + _resolver = resolver; + _blockPlaintextAccess = !allowPlaintext; // Block access if plaintext is NOT explicitly allowed + } + + internal Secret(SecretEnvelopeWrapper envelope, SecretsDecryptorResolver? resolver = null) + { + ArgumentNullException.ThrowIfNull(envelope); + _envelope = envelope; + _resolver = resolver; + _blockPlaintextAccess = false; + } + + public override string ToString() => "***"; + + public SecretLease Open() + { + lock (_lock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + ValidatePlaintextAccess(); + + // Get decrypted bytes - decrypt at the LAST possible moment + byte[] bytes; + bool needsCleanup; + + if (_plainBytes is { } plain) + { + System.Diagnostics.Trace.TraceWarning( + $"Cocoar.Configuration: Secret<{typeof(T).Name}>.Open() is accessing a plaintext secret. " + + "This is acceptable for development and testing but should not occur in production. " + + "Use encrypted envelopes for production deployments."); + bytes = plain; + needsCleanup = false; + } + else if (_envelope is { } env) + { + // CRITICAL: Decrypt here, right before deserialization + bytes = DecryptEnvelope(env); + needsCleanup = true; + } + else + { + throw new ObjectDisposedException(nameof(Secret)); + } + + // Deserialize and create lease immediately after decryption + try + { + return DeserializeAndCreateLease(bytes, needsCleanup); + } + catch + { + if (needsCleanup) + { + Array.Clear(bytes, 0, bytes.Length); + } + throw; + } + } + } + + private void ValidatePlaintextAccess() + { + if (_blockPlaintextAccess) + { + throw new InvalidOperationException( + $"Secret<{typeof(T).Name}> was deserialized from plaintext JSON instead of an encrypted envelope. " + + "Pre-encrypted envelopes are required for security. Ensure your configuration source delivers " + + "secrets in encrypted envelope format with the '_cocoar_secret' marker."); + } + } + + private byte[] DecryptEnvelope(SecretEnvelopeWrapper env) + { + if (_resolver is null) + { + throw new InvalidOperationException( + $"Cannot decrypt Secret<{typeof(T).Name}>: secrets infrastructure not configured.\n\n" + + "To fix, add secrets setup when creating ConfigManager:\n\n" + + " ConfigManager.Create(c => c\n" + + " .UseConfiguration(rules => [...])\n" + + " .UseSecretsSetup(secrets => secrets\n" + + " .UseCertificateFromFile(\"cert.pfx\")));\n\n" + + "Or with DI:\n\n" + + " services.AddCocoarConfiguration(c => c\n" + + " .UseConfiguration(rules => [...])\n" + + " .UseSecretsSetup(secrets => secrets\n" + + " .UseCertificateFromFile(\"cert.pfx\")));"); + } + + var protector = _resolver.ResolveForKid(env.Kid); + var envelopeJson = env.Data.GetRawText(); + var envelope = protector.DeserializeEnvelope(envelopeJson); + + return protector.UnprotectInternal(envelope, env.Kid); + } + + private static SecretLease DeserializeAndCreateLease(byte[] bytes, bool needsCleanup) + { + // byte[] — uses Utf8JsonReader directly with zero-copy Base64 decode. + // All allocations tracked for cleanup. See ByteArraySecretDeserializer. + if (typeof(T) == typeof(byte[])) + { + return ByteArraySecretDeserializer.Deserialize(bytes, needsCleanup); + } + + // Deserialize directly from UTF-8 bytes — no intermediate string allocation. + // Security at rest: before Open(), secrets exist only as encrypted envelopes. + // At Open() time, the consumer needs the value (to pass to HttpClient, DB, etc.) + // so converting to T is unavoidable. The decrypted bytes are zeroed on lease dispose. + var value = JsonSerializer.Deserialize(new ReadOnlySpan(bytes), SecretValueSerialization.Options); + + if (value is null) + { + if (TypeAcceptsNull()) + { + return new SecretLease(value!, CreateCleanupAction(bytes, needsCleanup)); + } + + throw new JsonException($"Failed to deserialize secret value of type {typeof(T).Name} - result was null"); + } + + return new SecretLease(value, CreateCleanupAction(bytes, needsCleanup)); + } + + /// + /// Returns true if type T can legally hold a null value. + /// This includes reference types (string?, object?) and nullable value types (int?, bool?). + /// + private static bool TypeAcceptsNull() + { + // Reference types where default is null + if (!typeof(T).IsValueType) + return true; + // Nullable value types (int?, bool?, etc.) + return Nullable.GetUnderlyingType(typeof(T)) != null; + } + + private static Action CreateCleanupAction(byte[] bytes, bool needsCleanup) + { + return needsCleanup + ? () => Array.Clear(bytes, 0, bytes.Length) + : () => { }; + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + + if (_plainBytes is { } bytes) + { + Array.Clear(bytes, 0, bytes.Length); + _plainBytes = null; + } + + _envelope = null; + _disposed = true; + } + } + + /// + /// Creates a Secret from plaintext value. For testing/development only. + /// Use pre-encrypted envelopes in production. + /// + internal static Secret FromPlain(T value) => new(value, resolver: null, allowPlaintext: true); + + internal static Secret FromEnvelope(JsonElement element) + { + if (!SecretEnvelopeWrapper.TryParse(element, out var env) || env is null) + throw new FormatException($"Invalid secret envelope for Secret<{typeof(T).Name}>"); + + return new Secret(env, resolver: null); + } +} + + +public static class Secret +{ + public static Secret FromPlain(T value) => Secret.FromPlain(value); +} diff --git a/src/Cocoar.Configuration/Secrets/SecretsBuilderExtensions.cs b/src/Cocoar.Configuration/Secrets/SecretsBuilderExtensions.cs index 78be41f..9dca11f 100644 --- a/src/Cocoar.Configuration/Secrets/SecretsBuilderExtensions.cs +++ b/src/Cocoar.Configuration/Secrets/SecretsBuilderExtensions.cs @@ -1,72 +1,72 @@ -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Testing; - -namespace Cocoar.Configuration.Secrets; - -public static class SecretsBuilderExtensions -{ - /// - /// Configures secrets setup (encryption, certificates, plaintext policy). - /// When a test context with a secrets setup override is active, the override is used instead. - /// - /// - /// - /// ConfigManager.Create(c => c - /// .UseConfiguration(rules => [...]) - /// .UseSecretsSetup(secrets => secrets.AllowPlaintext())); - /// - /// ConfigManager.Create(c => c - /// .UseConfiguration(rules => [...]) - /// .UseSecretsSetup(secrets => secrets - /// .UseCertificateFromFile("cert.pfx") - /// .WithKeyId("my-key"))); - /// - /// - public static ConfigManagerBuilder UseSecretsSetup( - this ConfigManagerBuilder builder, - Func configure) - { - var scope = ConfigManagerBuilder.GetCapabilityScope(builder); - var secretsBuilder = new SecretsBuilder(scope); - - var testContext = CocoarTestConfiguration.Current; - var effectiveConfigure = testContext?.GetSecretsSetupOverride() ?? configure; - - var result = effectiveConfigure(secretsBuilder); - result.Build(); - return builder; - } - - /// - /// Replaces the secrets setup used during ConfigManager initialization in tests. - /// Extends so that it can be chained fluently alongside - /// ReplaceConfiguration / AppendConfiguration. - /// - /// - /// - /// using var _ = CocoarTestConfiguration - /// .ReplaceConfiguration(rules => [...]) - /// .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); - /// - /// - public static TestOverrideBuilder ReplaceSecretsSetup( - this TestOverrideBuilder builder, - Func configure) - { - ArgumentNullException.ThrowIfNull(configure); - builder.SetSecretsSetupOverride(configure); - return builder; - } -} - -internal static class TestConfigurationContextSecretsExtensions -{ - /// - /// Returns the secrets setup override cast to the correct strongly-typed delegate, - /// or null if no override has been registered. - /// - internal static Func? GetSecretsSetupOverride( - this TestConfigurationContext context) - => context.SecretsSetupOverride as Func; -} +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Testing; + +namespace Cocoar.Configuration.Secrets; + +public static class SecretsBuilderExtensions +{ + /// + /// Configures secrets setup (encryption, certificates, plaintext policy). + /// When a test context with a secrets setup override is active, the override is used instead. + /// + /// + /// + /// ConfigManager.Create(c => c + /// .UseConfiguration(rules => [...]) + /// .UseSecretsSetup(secrets => secrets.AllowPlaintext())); + /// + /// ConfigManager.Create(c => c + /// .UseConfiguration(rules => [...]) + /// .UseSecretsSetup(secrets => secrets + /// .UseCertificateFromFile("cert.pfx") + /// .WithKeyId("my-key"))); + /// + /// + public static ConfigManagerBuilder UseSecretsSetup( + this ConfigManagerBuilder builder, + Func configure) + { + var scope = ConfigManagerBuilder.GetCapabilityScope(builder); + var secretsBuilder = new SecretsBuilder(scope); + + var testContext = CocoarTestConfiguration.Current; + var effectiveConfigure = testContext?.GetSecretsSetupOverride() ?? configure; + + var result = effectiveConfigure(secretsBuilder); + result.Build(); + return builder; + } + + /// + /// Replaces the secrets setup used during ConfigManager initialization in tests. + /// Extends so that it can be chained fluently alongside + /// ReplaceConfiguration / AppendConfiguration. + /// + /// + /// + /// using var _ = CocoarTestConfiguration + /// .ReplaceConfiguration(rules => [...]) + /// .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); + /// + /// + public static TestOverrideBuilder ReplaceSecretsSetup( + this TestOverrideBuilder builder, + Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + builder.SetSecretsSetupOverride(configure); + return builder; + } +} + +internal static class TestConfigurationContextSecretsExtensions +{ + /// + /// Returns the secrets setup override cast to the correct strongly-typed delegate, + /// or null if no override has been registered. + /// + internal static Func? GetSecretsSetupOverride( + this TestConfigurationContext context) + => context.SecretsSetupOverride as Func; +} diff --git a/src/Cocoar.Configuration/Secrets/SecretsSetup.cs b/src/Cocoar.Configuration/Secrets/SecretsSetup.cs index 839f3bd..aef8b59 100644 --- a/src/Cocoar.Configuration/Secrets/SecretsSetup.cs +++ b/src/Cocoar.Configuration/Secrets/SecretsSetup.cs @@ -1,76 +1,76 @@ -using Cocoar.Capabilities; -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Extensibility; -using Cocoar.Configuration.Secrets.Converters; -using Cocoar.Configuration.Secrets.Core; -using Cocoar.Configuration.Secrets.Testing; -using Cocoar.Configuration.Testing; -using Cocoar.Configuration.X509Encryption; - -namespace Cocoar.Configuration.Secrets; - -public sealed class SecretsBuilder : SetupDefinition -{ - private readonly Composer _composer; - - internal SecretsBuilder(ConfigManagerCapabilityScope capabilityScope) - : base(capabilityScope) - { - _composer = CapabilityScope.Owner.GetRequiredComposer(); - - // Register test serialization if in test context - var testContext = CocoarTestConfiguration.Current; - if (testContext != null) - { - testContext.SerializerOptions ??= TestSecretSerialization.Options; - } - - if (!_composer.Has()) - { - _composer.AddAs<(IDeferredConfiguration, SecretsSetupDeferredConfiguration)>( - new SecretsSetupDeferredConfiguration(CapabilityScope)); - _composer.AddAs(new SecretsSerializerSetup(CapabilityScope)); - } - } - - internal override SetupDefinition Build() => this; - - internal static Composer GetComposerFor(SecretsBuilder builder) => builder._composer; - - internal static ConfigManagerCapabilityScope GetCapabilityScopeFor(SecretsBuilder builder) => builder.CapabilityScope; - - /// - /// Conditionally allows plaintext JSON values to be deserialized into Secret<T>. - /// - /// SECURITY WARNING: Only enable in development/test environments. - /// Production configurations should always use encrypted envelopes. - /// - /// - /// Whether to allow plaintext secrets. Defaults to true. - /// This builder for method chaining. - /// - /// - /// // Conditional based on environment (ASP.NET Core) - /// ConfigManager.Create(c => c - /// .UseConfiguration(rules => [...]) - /// .UseSecretsSetup(secrets => secrets.AllowPlaintext(isDevelopment))); - /// - /// // Always enable for tests - /// .UseSecretsSetup(secrets => secrets.AllowPlaintext()) // defaults to true - /// - /// - public SecretsBuilder AllowPlaintext(bool allow = true) - { - if (allow) - { - System.Diagnostics.Trace.TraceWarning( - "Cocoar.Configuration: AllowPlaintext() is enabled — Secret values will be deserialized " + - "from plaintext JSON without encryption. This is acceptable for development and testing " + - "but should not be used in production. Use encrypted envelopes for production deployments."); - } - - _composer.Add(new SecretsPolicy { AllowPlaintextSecrets = allow }); - return this; - } -} +using Cocoar.Capabilities; +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Extensibility; +using Cocoar.Configuration.Secrets.Converters; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.Secrets.Testing; +using Cocoar.Configuration.Testing; +using Cocoar.Configuration.X509Encryption; + +namespace Cocoar.Configuration.Secrets; + +public sealed class SecretsBuilder : SetupDefinition +{ + private readonly Composer _composer; + + internal SecretsBuilder(ConfigManagerCapabilityScope capabilityScope) + : base(capabilityScope) + { + _composer = CapabilityScope.Owner.GetRequiredComposer(); + + // Register test serialization if in test context + var testContext = CocoarTestConfiguration.Current; + if (testContext != null) + { + testContext.SerializerOptions ??= TestSecretSerialization.Options; + } + + if (!_composer.Has()) + { + _composer.AddAs<(IDeferredConfiguration, SecretsSetupDeferredConfiguration)>( + new SecretsSetupDeferredConfiguration(CapabilityScope)); + _composer.AddAs(new SecretsSerializerSetup(CapabilityScope)); + } + } + + internal override SetupDefinition Build() => this; + + internal static Composer GetComposerFor(SecretsBuilder builder) => builder._composer; + + internal static ConfigManagerCapabilityScope GetCapabilityScopeFor(SecretsBuilder builder) => builder.CapabilityScope; + + /// + /// Conditionally allows plaintext JSON values to be deserialized into Secret<T>. + /// + /// SECURITY WARNING: Only enable in development/test environments. + /// Production configurations should always use encrypted envelopes. + /// + /// + /// Whether to allow plaintext secrets. Defaults to true. + /// This builder for method chaining. + /// + /// + /// // Conditional based on environment (ASP.NET Core) + /// ConfigManager.Create(c => c + /// .UseConfiguration(rules => [...]) + /// .UseSecretsSetup(secrets => secrets.AllowPlaintext(isDevelopment))); + /// + /// // Always enable for tests + /// .UseSecretsSetup(secrets => secrets.AllowPlaintext()) // defaults to true + /// + /// + public SecretsBuilder AllowPlaintext(bool allow = true) + { + if (allow) + { + System.Diagnostics.Trace.TraceWarning( + "Cocoar.Configuration: AllowPlaintext() is enabled — Secret values will be deserialized " + + "from plaintext JSON without encryption. This is acceptable for development and testing " + + "but should not be used in production. Use encrypted envelopes for production deployments."); + } + + _composer.Add(new SecretsPolicy { AllowPlaintextSecrets = allow }); + return this; + } +} diff --git a/src/Cocoar.Configuration/Secrets/Testing/TestSecretSerialization.cs b/src/Cocoar.Configuration/Secrets/Testing/TestSecretSerialization.cs index 92ff922..953567a 100644 --- a/src/Cocoar.Configuration/Secrets/Testing/TestSecretSerialization.cs +++ b/src/Cocoar.Configuration/Secrets/Testing/TestSecretSerialization.cs @@ -1,23 +1,23 @@ -using System.Text.Json; -using Cocoar.Configuration.Secrets.Converters; - -namespace Cocoar.Configuration.Secrets.Testing; - -/// -/// Provides serialization options for test scenarios where secrets -/// need to survive FromStatic serialization. -/// -internal static class TestSecretSerialization -{ - private static readonly Lazy s_options = new(() => - { - var options = new JsonSerializerOptions(JsonSerializerOptions.Default); - options.Converters.Add(new PlaintextSecretJsonConverterFactory()); - return options; - }); - - /// - /// Gets JsonSerializerOptions that serialize Secret<T> as primitive values. - /// - public static JsonSerializerOptions Options => s_options.Value; -} +using System.Text.Json; +using Cocoar.Configuration.Secrets.Converters; + +namespace Cocoar.Configuration.Secrets.Testing; + +/// +/// Provides serialization options for test scenarios where secrets +/// need to survive FromStatic serialization. +/// +internal static class TestSecretSerialization +{ + private static readonly Lazy s_options = new(() => + { + var options = new JsonSerializerOptions(JsonSerializerOptions.Default); + options.Converters.Add(new PlaintextSecretJsonConverterFactory()); + return options; + }); + + /// + /// Gets JsonSerializerOptions that serialize Secret<T> as primitive values. + /// + public static JsonSerializerOptions Options => s_options.Value; +} diff --git a/src/Cocoar.Configuration/Secrets/X509Encryption/HybridSecretEnvelope.cs b/src/Cocoar.Configuration/Secrets/X509Encryption/HybridSecretEnvelope.cs index 457aa71..30cb8e9 100644 --- a/src/Cocoar.Configuration/Secrets/X509Encryption/HybridSecretEnvelope.cs +++ b/src/Cocoar.Configuration/Secrets/X509Encryption/HybridSecretEnvelope.cs @@ -1,41 +1,41 @@ -using System.Text.Json.Serialization; - -namespace Cocoar.Configuration.X509Encryption; - -/// -/// Hybrid RSA+AES encrypted envelope. -/// Contains encrypted data and the AES key wrapped with RSA. -/// Uses short property names to match Cocoar.Configuration.Secrets format. -/// -public sealed record HybridSecretEnvelope -{ - /// - /// The AES-256 key, encrypted with RSA-OAEP-SHA256 (base64-encoded). - /// - [JsonPropertyName("wk")] - public required string WrappedKey { get; init; } - - /// - /// Algorithm used to wrap the key (always "RSA-OAEP-256"). - /// - [JsonPropertyName("walg")] - public required string WrappingAlgorithm { get; init; } - - /// - /// AES-GCM initialization vector (12 bytes, base64-encoded). - /// - [JsonPropertyName("iv")] - public required string Iv { get; init; } - - /// - /// AES-GCM encrypted ciphertext (base64-encoded). - /// - [JsonPropertyName("ct")] - public required string Ciphertext { get; init; } - - /// - /// AES-GCM authentication tag (16 bytes, base64-encoded). - /// - [JsonPropertyName("tag")] - public required string Tag { get; init; } -} +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.X509Encryption; + +/// +/// Hybrid RSA+AES encrypted envelope. +/// Contains encrypted data and the AES key wrapped with RSA. +/// Uses short property names to match Cocoar.Configuration.Secrets format. +/// +public sealed record HybridSecretEnvelope +{ + /// + /// The AES-256 key, encrypted with RSA-OAEP-SHA256 (base64-encoded). + /// + [JsonPropertyName("wk")] + public required string WrappedKey { get; init; } + + /// + /// Algorithm used to wrap the key (always "RSA-OAEP-256"). + /// + [JsonPropertyName("walg")] + public required string WrappingAlgorithm { get; init; } + + /// + /// AES-GCM initialization vector (12 bytes, base64-encoded). + /// + [JsonPropertyName("iv")] + public required string Iv { get; init; } + + /// + /// AES-GCM encrypted ciphertext (base64-encoded). + /// + [JsonPropertyName("ct")] + public required string Ciphertext { get; init; } + + /// + /// AES-GCM authentication tag (16 bytes, base64-encoded). + /// + [JsonPropertyName("tag")] + public required string Tag { get; init; } +} diff --git a/src/Cocoar.Configuration/Secrets/X509Encryption/JsonSecretsEditor.cs b/src/Cocoar.Configuration/Secrets/X509Encryption/JsonSecretsEditor.cs index 952e467..aa8aa69 100644 --- a/src/Cocoar.Configuration/Secrets/X509Encryption/JsonSecretsEditor.cs +++ b/src/Cocoar.Configuration/Secrets/X509Encryption/JsonSecretsEditor.cs @@ -1,350 +1,350 @@ -using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace Cocoar.Configuration.X509Encryption; - -/// -/// Provides methods for encrypting and decrypting secrets directly in JSON configuration files. -/// Useful for CLI tools, PowerShell modules, and test scenarios. -/// -public static class JsonSecretsEditor -{ - private static readonly JsonSerializerOptions IndentedJsonOptions = new() - { - WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - - /// - /// Encrypts a value and sets it at the specified property path in a JSON file. - /// - /// Path to the JSON configuration file - /// Property path using colon separator (e.g., "Database:ConnectionString") - /// The plaintext value to encrypt - /// X.509 certificate with public key for encryption - /// Key identifier (kid) for the certificate - /// If true, creates the file and directories if they don't exist - /// True if file was created, false if it already existed - /// If file doesn't exist and createIfNotExists is false - /// If JSON file doesn't contain a root object - public static async Task EncryptValueInFileAsync( - string jsonFilePath, - string propertyPath, - string plaintext, - X509Certificate2 certificate, - string kid = "default", - bool createIfNotExists = false) - { - ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); - ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); - ArgumentNullException.ThrowIfNull(plaintext); - ArgumentNullException.ThrowIfNull(certificate); - ArgumentException.ThrowIfNullOrWhiteSpace(kid); - - var fileExists = File.Exists(jsonFilePath); - - // Validate file exists or creation is allowed - if (!fileExists && !createIfNotExists) - throw new FileNotFoundException($"JSON file not found: {jsonFilePath}. Enable createIfNotExists to create a new file."); - - // Encrypt the value - var crypto = new X509HybridCrypto(certificate); - var envelope = crypto.Encrypt(plaintext); - - // Read or create JSON file - JsonObject rootObject; - if (fileExists) - { - var jsonText = await File.ReadAllTextAsync(jsonFilePath); - var jsonNode = JsonNode.Parse(jsonText); - - if (jsonNode is not JsonObject obj) - throw new InvalidOperationException("JSON file must contain a root object"); - - rootObject = obj; - } - else - { - // Create directory if it doesn't exist - var directory = Path.GetDirectoryName(jsonFilePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - // Start with an empty JSON object - rootObject = new JsonObject(); - } - - // Set the encrypted value at the property path - SetValueAtPath(rootObject, propertyPath, envelope, kid); - - // Write back to file with nice formatting - var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); - await File.WriteAllTextAsync(jsonFilePath, updatedJson, Encoding.UTF8); - - return !fileExists; // Return true if we created the file - } - - /// - /// Encrypts an existing plaintext value in-place at the specified property path in a JSON file. - /// Reads the current value, encrypts it, and replaces it with the encrypted envelope. - /// - /// Path to the JSON configuration file - /// Property path using colon separator (e.g., "Database:ConnectionString") - /// X.509 certificate with public key for encryption - /// Key identifier (kid) for the certificate - /// Always returns false (file must exist) - /// If file doesn't exist - /// If JSON file doesn't contain a root object or property path doesn't exist - public static async Task EncryptExistingValueInFileAsync( - string jsonFilePath, - string propertyPath, - X509Certificate2 certificate, - string kid = "default") - { - ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); - ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); - ArgumentNullException.ThrowIfNull(certificate); - ArgumentException.ThrowIfNullOrWhiteSpace(kid); - - if (!File.Exists(jsonFilePath)) - throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); - - // Read JSON file - var jsonText = await File.ReadAllTextAsync(jsonFilePath); - var jsonNode = JsonNode.Parse(jsonText); - - if (jsonNode is not JsonObject rootObject) - throw new InvalidOperationException("JSON file must contain a root object"); - - // Get the existing value at the path - var existingValue = GetValueAtPath(rootObject, propertyPath); - if (existingValue is null) - throw new InvalidOperationException($"No value found at path: {propertyPath}"); - - // Serialize the existing JSON value to a string (preserving its JSON representation) - var jsonValueString = JsonSerializer.Serialize(existingValue); - - // Encrypt the JSON string - var crypto = new X509HybridCrypto(certificate); - var envelope = crypto.Encrypt(jsonValueString); - - // Replace the value with the encrypted envelope - SetValueAtPath(rootObject, propertyPath, envelope, kid); - - // Write back to file - var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); - await File.WriteAllTextAsync(jsonFilePath, updatedJson, Encoding.UTF8); - - return false; // File already existed - } - - /// - /// Decrypts a value from the specified property path in a JSON file. - /// - /// Path to the JSON configuration file - /// Property path using colon separator (e.g., "Database:ConnectionString") - /// X.509 certificate with private key for decryption - /// The decrypted plaintext value - /// If file doesn't exist - /// If JSON file doesn't contain a root object or property path is invalid - public static async Task DecryptValueFromFileAsync( - string jsonFilePath, - string propertyPath, - X509Certificate2 certificate) - { - ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); - ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); - ArgumentNullException.ThrowIfNull(certificate); - - if (!File.Exists(jsonFilePath)) - throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); - - // Read JSON file - var jsonText = await File.ReadAllTextAsync(jsonFilePath); - var jsonNode = JsonNode.Parse(jsonText); - - if (jsonNode is not JsonObject rootObject) - throw new InvalidOperationException("JSON file must contain a root object"); - - // Get the encrypted envelope from the path - var envelope = GetEnvelopeAtPath(rootObject, propertyPath); - - // Decrypt the value - var crypto = new X509HybridCrypto(certificate); - return crypto.DecryptToString(envelope); - } - - /// - /// Rotates the certificate for an encrypted value by decrypting with old certificate and re-encrypting with new certificate. - /// - /// Path to the JSON configuration file - /// Property path using colon separator (e.g., "Database:ConnectionString") - /// X.509 certificate with private key for decryption - /// X.509 certificate with public key for encryption - /// Optional new key identifier (if null, keeps the existing kid) - /// If file doesn't exist - /// If JSON file doesn't contain a root object or property path is invalid - public static async Task RotateCertificateInFileAsync( - string jsonFilePath, - string propertyPath, - X509Certificate2 oldCertificate, - X509Certificate2 newCertificate, - string? newKid = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); - ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); - ArgumentNullException.ThrowIfNull(oldCertificate); - ArgumentNullException.ThrowIfNull(newCertificate); - - if (!File.Exists(jsonFilePath)) - throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); - - // Read JSON file - var jsonText = await File.ReadAllTextAsync(jsonFilePath); - var jsonNode = JsonNode.Parse(jsonText); - - if (jsonNode is not JsonObject rootObject) - throw new InvalidOperationException("JSON file must contain a root object"); - - // Get the encrypted envelope and kid from the path - var (envelope, existingKid) = GetEnvelopeWithKidAtPath(rootObject, propertyPath); - - // Decrypt with old certificate - var oldCrypto = new X509HybridCrypto(oldCertificate); - var plaintext = oldCrypto.DecryptToString(envelope); - - // Re-encrypt with new certificate - var newCrypto = new X509HybridCrypto(newCertificate); - var newEnvelope = newCrypto.Encrypt(plaintext); - - // Use new kid if provided, otherwise keep existing - var kidToUse = newKid ?? existingKid; - - // Update the JSON file with the new envelope - SetValueAtPath(rootObject, propertyPath, newEnvelope, kidToUse); - - // Write back to file - var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); - await File.WriteAllTextAsync(jsonFilePath, updatedJson, Encoding.UTF8); - } - - private static void SetValueAtPath(JsonObject root, string path, HybridSecretEnvelope envelope, string kid) - { - var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) - throw new ArgumentException("Property path cannot be empty", nameof(path)); - - JsonObject current = root; - - // Navigate to parent, creating objects as needed - for (int i = 0; i < segments.Length - 1; i++) - { - var segment = segments[i]; - - if (current[segment] is JsonObject existingObject) - { - current = existingObject; - } - else - { - // Create new object if it doesn't exist - var newObject = new JsonObject(); - current[segment] = newObject; - current = newObject; - } - } - - // Wrap the envelope in the Cocoar secret structure - var wrappedEnvelope = new JsonObject - { - ["type"] = "cocoar.secret", - ["version"] = 1, - ["kid"] = kid, - ["alg"] = "RSA-OAEP-AES256-GCM" - }; - - // Merge the envelope properties into the wrapped structure - var envelopeNode = JsonSerializer.SerializeToNode(envelope); - if (envelopeNode is JsonObject envelopeObject) - { - foreach (var prop in envelopeObject) - { - wrappedEnvelope[prop.Key] = prop.Value?.DeepClone(); - } - } - - // Set the final property to the wrapped envelope - var finalSegment = segments[^1]; - current[finalSegment] = wrappedEnvelope; - } - - private static HybridSecretEnvelope GetEnvelopeAtPath(JsonObject root, string path) - { - var (envelope, _) = GetEnvelopeWithKidAtPath(root, path); - return envelope; - } - - private static (HybridSecretEnvelope Envelope, string Kid) GetEnvelopeWithKidAtPath(JsonObject root, string path) - { - var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) - throw new ArgumentException("Property path cannot be empty", nameof(path)); - - JsonNode? current = root; - - // Navigate to the property - foreach (var segment in segments) - { - if (current is not JsonObject obj) - throw new InvalidOperationException($"Path segment '{segment}' does not exist or is not an object"); - - current = obj[segment]; - if (current == null) - throw new InvalidOperationException($"Property '{segment}' not found in path '{path}'"); - } - - // Expect a typed secret envelope - if (current is not JsonObject envelopeObj) - throw new InvalidOperationException($"Value at '{path}' is not an encrypted envelope object"); - - var type = envelopeObj["type"]?.GetValue(); - var version = envelopeObj["version"]?.GetValue(); - - if (!string.Equals(type, "cocoar.secret", StringComparison.OrdinalIgnoreCase) || version is null or not 1) - throw new InvalidOperationException($"Invalid or missing Cocoar secret envelope at '{path}'"); - - var kid = envelopeObj["kid"]?.GetValue() ?? "default"; - - // Deserialize the envelope - var envelope = JsonSerializer.Deserialize(envelopeObj.ToJsonString()) - ?? throw new InvalidOperationException($"Failed to deserialize envelope at '{path}'"); - - return (envelope, kid); - } - - private static JsonNode? GetValueAtPath(JsonObject root, string path) - { - var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) - throw new ArgumentException("Property path cannot be empty", nameof(path)); - - JsonNode? current = root; - - // Navigate to the property - foreach (var segment in segments) - { - if (current is not JsonObject obj) - throw new InvalidOperationException($"Path segment '{segment}' does not exist or is not an object"); - - current = obj[segment]; - if (current == null) - throw new InvalidOperationException($"Property '{segment}' not found in path '{path}'"); - } - - return current; - } -} +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Cocoar.Configuration.X509Encryption; + +/// +/// Provides methods for encrypting and decrypting secrets directly in JSON configuration files. +/// Useful for CLI tools, PowerShell modules, and test scenarios. +/// +public static class JsonSecretsEditor +{ + private static readonly JsonSerializerOptions IndentedJsonOptions = new() + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + /// + /// Encrypts a value and sets it at the specified property path in a JSON file. + /// + /// Path to the JSON configuration file + /// Property path using colon separator (e.g., "Database:ConnectionString") + /// The plaintext value to encrypt + /// X.509 certificate with public key for encryption + /// Key identifier (kid) for the certificate + /// If true, creates the file and directories if they don't exist + /// True if file was created, false if it already existed + /// If file doesn't exist and createIfNotExists is false + /// If JSON file doesn't contain a root object + public static async Task EncryptValueInFileAsync( + string jsonFilePath, + string propertyPath, + string plaintext, + X509Certificate2 certificate, + string kid = "default", + bool createIfNotExists = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); + ArgumentNullException.ThrowIfNull(plaintext); + ArgumentNullException.ThrowIfNull(certificate); + ArgumentException.ThrowIfNullOrWhiteSpace(kid); + + var fileExists = File.Exists(jsonFilePath); + + // Validate file exists or creation is allowed + if (!fileExists && !createIfNotExists) + throw new FileNotFoundException($"JSON file not found: {jsonFilePath}. Enable createIfNotExists to create a new file."); + + // Encrypt the value + var crypto = new X509HybridCrypto(certificate); + var envelope = crypto.Encrypt(plaintext); + + // Read or create JSON file + JsonObject rootObject; + if (fileExists) + { + var jsonText = await File.ReadAllTextAsync(jsonFilePath); + var jsonNode = JsonNode.Parse(jsonText); + + if (jsonNode is not JsonObject obj) + throw new InvalidOperationException("JSON file must contain a root object"); + + rootObject = obj; + } + else + { + // Create directory if it doesn't exist + var directory = Path.GetDirectoryName(jsonFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // Start with an empty JSON object + rootObject = new JsonObject(); + } + + // Set the encrypted value at the property path + SetValueAtPath(rootObject, propertyPath, envelope, kid); + + // Write back to file with nice formatting + var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); + await File.WriteAllTextAsync(jsonFilePath, updatedJson, Encoding.UTF8); + + return !fileExists; // Return true if we created the file + } + + /// + /// Encrypts an existing plaintext value in-place at the specified property path in a JSON file. + /// Reads the current value, encrypts it, and replaces it with the encrypted envelope. + /// + /// Path to the JSON configuration file + /// Property path using colon separator (e.g., "Database:ConnectionString") + /// X.509 certificate with public key for encryption + /// Key identifier (kid) for the certificate + /// Always returns false (file must exist) + /// If file doesn't exist + /// If JSON file doesn't contain a root object or property path doesn't exist + public static async Task EncryptExistingValueInFileAsync( + string jsonFilePath, + string propertyPath, + X509Certificate2 certificate, + string kid = "default") + { + ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); + ArgumentNullException.ThrowIfNull(certificate); + ArgumentException.ThrowIfNullOrWhiteSpace(kid); + + if (!File.Exists(jsonFilePath)) + throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); + + // Read JSON file + var jsonText = await File.ReadAllTextAsync(jsonFilePath); + var jsonNode = JsonNode.Parse(jsonText); + + if (jsonNode is not JsonObject rootObject) + throw new InvalidOperationException("JSON file must contain a root object"); + + // Get the existing value at the path + var existingValue = GetValueAtPath(rootObject, propertyPath); + if (existingValue is null) + throw new InvalidOperationException($"No value found at path: {propertyPath}"); + + // Serialize the existing JSON value to a string (preserving its JSON representation) + var jsonValueString = JsonSerializer.Serialize(existingValue); + + // Encrypt the JSON string + var crypto = new X509HybridCrypto(certificate); + var envelope = crypto.Encrypt(jsonValueString); + + // Replace the value with the encrypted envelope + SetValueAtPath(rootObject, propertyPath, envelope, kid); + + // Write back to file + var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); + await File.WriteAllTextAsync(jsonFilePath, updatedJson, Encoding.UTF8); + + return false; // File already existed + } + + /// + /// Decrypts a value from the specified property path in a JSON file. + /// + /// Path to the JSON configuration file + /// Property path using colon separator (e.g., "Database:ConnectionString") + /// X.509 certificate with private key for decryption + /// The decrypted plaintext value + /// If file doesn't exist + /// If JSON file doesn't contain a root object or property path is invalid + public static async Task DecryptValueFromFileAsync( + string jsonFilePath, + string propertyPath, + X509Certificate2 certificate) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); + ArgumentNullException.ThrowIfNull(certificate); + + if (!File.Exists(jsonFilePath)) + throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); + + // Read JSON file + var jsonText = await File.ReadAllTextAsync(jsonFilePath); + var jsonNode = JsonNode.Parse(jsonText); + + if (jsonNode is not JsonObject rootObject) + throw new InvalidOperationException("JSON file must contain a root object"); + + // Get the encrypted envelope from the path + var envelope = GetEnvelopeAtPath(rootObject, propertyPath); + + // Decrypt the value + var crypto = new X509HybridCrypto(certificate); + return crypto.DecryptToString(envelope); + } + + /// + /// Rotates the certificate for an encrypted value by decrypting with old certificate and re-encrypting with new certificate. + /// + /// Path to the JSON configuration file + /// Property path using colon separator (e.g., "Database:ConnectionString") + /// X.509 certificate with private key for decryption + /// X.509 certificate with public key for encryption + /// Optional new key identifier (if null, keeps the existing kid) + /// If file doesn't exist + /// If JSON file doesn't contain a root object or property path is invalid + public static async Task RotateCertificateInFileAsync( + string jsonFilePath, + string propertyPath, + X509Certificate2 oldCertificate, + X509Certificate2 newCertificate, + string? newKid = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jsonFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyPath); + ArgumentNullException.ThrowIfNull(oldCertificate); + ArgumentNullException.ThrowIfNull(newCertificate); + + if (!File.Exists(jsonFilePath)) + throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); + + // Read JSON file + var jsonText = await File.ReadAllTextAsync(jsonFilePath); + var jsonNode = JsonNode.Parse(jsonText); + + if (jsonNode is not JsonObject rootObject) + throw new InvalidOperationException("JSON file must contain a root object"); + + // Get the encrypted envelope and kid from the path + var (envelope, existingKid) = GetEnvelopeWithKidAtPath(rootObject, propertyPath); + + // Decrypt with old certificate + var oldCrypto = new X509HybridCrypto(oldCertificate); + var plaintext = oldCrypto.DecryptToString(envelope); + + // Re-encrypt with new certificate + var newCrypto = new X509HybridCrypto(newCertificate); + var newEnvelope = newCrypto.Encrypt(plaintext); + + // Use new kid if provided, otherwise keep existing + var kidToUse = newKid ?? existingKid; + + // Update the JSON file with the new envelope + SetValueAtPath(rootObject, propertyPath, newEnvelope, kidToUse); + + // Write back to file + var updatedJson = JsonSerializer.Serialize(rootObject, IndentedJsonOptions); + await File.WriteAllTextAsync(jsonFilePath, updatedJson, Encoding.UTF8); + } + + private static void SetValueAtPath(JsonObject root, string path, HybridSecretEnvelope envelope, string kid) + { + var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + throw new ArgumentException("Property path cannot be empty", nameof(path)); + + JsonObject current = root; + + // Navigate to parent, creating objects as needed + for (int i = 0; i < segments.Length - 1; i++) + { + var segment = segments[i]; + + if (current[segment] is JsonObject existingObject) + { + current = existingObject; + } + else + { + // Create new object if it doesn't exist + var newObject = new JsonObject(); + current[segment] = newObject; + current = newObject; + } + } + + // Wrap the envelope in the Cocoar secret structure + var wrappedEnvelope = new JsonObject + { + ["type"] = "cocoar.secret", + ["version"] = 1, + ["kid"] = kid, + ["alg"] = "RSA-OAEP-AES256-GCM" + }; + + // Merge the envelope properties into the wrapped structure + var envelopeNode = JsonSerializer.SerializeToNode(envelope); + if (envelopeNode is JsonObject envelopeObject) + { + foreach (var prop in envelopeObject) + { + wrappedEnvelope[prop.Key] = prop.Value?.DeepClone(); + } + } + + // Set the final property to the wrapped envelope + var finalSegment = segments[^1]; + current[finalSegment] = wrappedEnvelope; + } + + private static HybridSecretEnvelope GetEnvelopeAtPath(JsonObject root, string path) + { + var (envelope, _) = GetEnvelopeWithKidAtPath(root, path); + return envelope; + } + + private static (HybridSecretEnvelope Envelope, string Kid) GetEnvelopeWithKidAtPath(JsonObject root, string path) + { + var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + throw new ArgumentException("Property path cannot be empty", nameof(path)); + + JsonNode? current = root; + + // Navigate to the property + foreach (var segment in segments) + { + if (current is not JsonObject obj) + throw new InvalidOperationException($"Path segment '{segment}' does not exist or is not an object"); + + current = obj[segment]; + if (current == null) + throw new InvalidOperationException($"Property '{segment}' not found in path '{path}'"); + } + + // Expect a typed secret envelope + if (current is not JsonObject envelopeObj) + throw new InvalidOperationException($"Value at '{path}' is not an encrypted envelope object"); + + var type = envelopeObj["type"]?.GetValue(); + var version = envelopeObj["version"]?.GetValue(); + + if (!string.Equals(type, "cocoar.secret", StringComparison.OrdinalIgnoreCase) || version is null or not 1) + throw new InvalidOperationException($"Invalid or missing Cocoar secret envelope at '{path}'"); + + var kid = envelopeObj["kid"]?.GetValue() ?? "default"; + + // Deserialize the envelope + var envelope = JsonSerializer.Deserialize(envelopeObj.ToJsonString()) + ?? throw new InvalidOperationException($"Failed to deserialize envelope at '{path}'"); + + return (envelope, kid); + } + + private static JsonNode? GetValueAtPath(JsonObject root, string path) + { + var segments = path.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + throw new ArgumentException("Property path cannot be empty", nameof(path)); + + JsonNode? current = root; + + // Navigate to the property + foreach (var segment in segments) + { + if (current is not JsonObject obj) + throw new InvalidOperationException($"Path segment '{segment}' does not exist or is not an object"); + + current = obj[segment]; + if (current == null) + throw new InvalidOperationException($"Property '{segment}' not found in path '{path}'"); + } + + return current; + } +} diff --git a/src/Cocoar.Configuration/Secrets/X509Encryption/X509CertificateGenerator.cs b/src/Cocoar.Configuration/Secrets/X509Encryption/X509CertificateGenerator.cs index f789fc6..1544043 100644 --- a/src/Cocoar.Configuration/Secrets/X509Encryption/X509CertificateGenerator.cs +++ b/src/Cocoar.Configuration/Secrets/X509Encryption/X509CertificateGenerator.cs @@ -1,336 +1,336 @@ -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; - -namespace Cocoar.Configuration.X509Encryption; - -/// -/// Generates self-signed X.509 certificates for encryption purposes. -/// -public static class X509CertificateGenerator -{ - /// - /// Generates a self-signed certificate. - /// - /// Certificate subject (e.g., "CN=MyApp"). - /// Validity period in years (default: 1). - /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). - /// A new self-signed X509Certificate2 with private key. - /// If keySize is not 2048, 3072, or 4096. - public static X509Certificate2 GenerateSelfSigned( - string subject, - int validYears = 1, - int keySize = 2048) - { - ArgumentException.ThrowIfNullOrWhiteSpace(subject); - - if (keySize != 2048 && keySize != 3072 && keySize != 4096) - throw new ArgumentException($"Key size must be 2048, 3072, or 4096, got {keySize}", nameof(keySize)); - - if (validYears < 1) - throw new ArgumentException("Validity period must be at least 1 year", nameof(validYears)); - - using var rsa = RSA.Create(keySize); - var request = new CertificateRequest( - subject, - rsa, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - - // Add basic constraints - request.CertificateExtensions.Add( - new X509BasicConstraintsExtension( - certificateAuthority: false, - hasPathLengthConstraint: false, - pathLengthConstraint: 0, - critical: false)); - - // Add key usage - request.CertificateExtensions.Add( - new X509KeyUsageExtension( - X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment, - critical: false)); - - var notBefore = DateTimeOffset.UtcNow; - var notAfter = notBefore.AddYears(validYears); - - return request.CreateSelfSigned(notBefore, notAfter); - } - - /// - /// Generates a self-signed certificate and saves it as a PFX file. - /// - /// Path where the PFX file will be saved. - /// Password to protect the PFX file. - /// Certificate subject (e.g., "CN=MyApp"). - /// Validity period in years (default: 1). - /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). - /// If true, overwrites existing file; otherwise throws if file exists. - /// The generated certificate. - /// If file exists and overwrite is false. - public static X509Certificate2 GenerateAndSavePfx( - string outputPath, - string? password, - string subject, - int validYears = 1, - int keySize = 2048, - bool overwrite = false) - { - ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); - - if (File.Exists(outputPath) && !overwrite) - throw new IOException($"File already exists: {outputPath}. Use overwrite=true to replace."); - - var cert = GenerateSelfSigned(subject, validYears, keySize); - - try - { - var pfxBytes = cert.Export(X509ContentType.Pfx, password); - File.WriteAllBytes(outputPath, pfxBytes); - RestrictFilePermissions(outputPath); - return cert; - } - catch - { - cert.Dispose(); - throw; - } - } - - /// - /// Generates a self-signed certificate and saves it in PEM format (.crt + .key files). - /// - /// Path where the certificate file will be saved (.crt). - /// Path where the private key file will be saved (.key). If null, uses certPath with .key extension. - /// Certificate subject (e.g., "CN=MyApp"). - /// Validity period in years (default: 1). - /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). - /// If true, overwrites existing files; otherwise throws if files exist. - /// The generated certificate (without private key - use keyPath to load full cert). - /// If files exist and overwrite is false. - public static X509Certificate2 GenerateAndSavePem( - string certPath, - string? keyPath = null, - string subject = "CN=Cocoar Secrets", - int validYears = 1, - int keySize = 2048, - bool overwrite = false) - { - ArgumentException.ThrowIfNullOrWhiteSpace(certPath); - - keyPath ??= Path.ChangeExtension(certPath, ".key"); - - if (File.Exists(certPath) && !overwrite) - throw new IOException($"Certificate file already exists: {certPath}. Use overwrite=true to replace."); - - if (File.Exists(keyPath) && !overwrite) - throw new IOException($"Private key file already exists: {keyPath}. Use overwrite=true to replace."); - - var cert = GenerateSelfSigned(subject, validYears, keySize); - - try - { - // Export certificate (public key only) as PEM - var certPem = cert.ExportCertificatePem(); - File.WriteAllText(certPath, certPem); - - // Export private key as PEM - var key = cert.GetRSAPrivateKey(); - if (key == null) - throw new InvalidOperationException("Certificate does not contain an RSA private key."); - - var keyPem = key.ExportRSAPrivateKeyPem(); - File.WriteAllText(keyPath, keyPem); - RestrictFilePermissions(keyPath); - - return cert; - } - catch - { - cert.Dispose(); - throw; - } - } - - /// - /// Generates a self-signed certificate and saves it as a PFX file. - /// - /// Path where the certificate will be saved. - /// Password for PFX file. - /// Certificate subject (e.g., "CN=MyApp"). - /// Validity period in years (default: 1). - /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). - /// If true, overwrites existing files; otherwise throws if files exist. - /// The generated certificate. - [Obsolete("Use GenerateAndSavePfx or GenerateAndSavePem instead.")] - public static X509Certificate2 GenerateAndSave( - string outputPath, - string password, - string subject, - int validYears = 1, - int keySize = 2048, - bool overwrite = false) - { - return GenerateAndSavePfx(outputPath, password, subject, validYears, keySize, overwrite); - } - - /// - /// Converts a certificate from PFX to PEM format. - /// - /// Path to input PFX file. - /// Password for PFX file. - /// Path where certificate will be saved (.crt). - /// Path where private key will be saved (.key). If null, uses certPath with .key extension. - /// If true, overwrites existing files; otherwise throws if files exist. - /// The certificate (without private key - use keyPath to load full cert). - /// If files exist and overwrite is false. - /// If PFX cannot be loaded or password is incorrect. - public static X509Certificate2 ConvertPfxToPem( - string pfxPath, - string pfxPassword, - string certPath, - string? keyPath = null, - bool overwrite = false) - { - ArgumentException.ThrowIfNullOrWhiteSpace(pfxPath); - ArgumentException.ThrowIfNullOrWhiteSpace(pfxPassword); - ArgumentException.ThrowIfNullOrWhiteSpace(certPath); - - keyPath ??= Path.ChangeExtension(certPath, ".key"); - - if (File.Exists(certPath) && !overwrite) - throw new IOException($"Certificate file already exists: {certPath}. Use overwrite=true to replace."); - - if (File.Exists(keyPath) && !overwrite) - throw new IOException($"Private key file already exists: {keyPath}. Use overwrite=true to replace."); - - // Load PFX with private key -#if NET9_0_OR_GREATER - var cert = X509CertificateLoader.LoadPkcs12FromFile(pfxPath, pfxPassword, X509KeyStorageFlags.Exportable); -#else - var cert = new X509Certificate2(pfxPath, pfxPassword, X509KeyStorageFlags.Exportable); -#endif - - if (!cert.HasPrivateKey) - { - cert.Dispose(); - throw new InvalidOperationException("PFX file does not contain a private key."); - } - - try - { - // Export certificate (public key only) - var certPem = cert.ExportCertificatePem(); - File.WriteAllText(certPath, certPem); - - // Export private key - var key = cert.GetRSAPrivateKey(); - if (key == null) - { - cert.Dispose(); - throw new InvalidOperationException("Certificate does not contain an RSA private key."); - } - - var keyPem = key.ExportRSAPrivateKeyPem(); - File.WriteAllText(keyPath, keyPem); - RestrictFilePermissions(keyPath); - - return cert; - } - catch - { - cert.Dispose(); - throw; - } - } - - /// - /// Converts a certificate from PEM to PFX format. - /// - /// Path to input certificate file (.crt, .pem, .cer). - /// Path to private key file (.key). If null, uses certPath with .key extension. - /// Path where PFX file will be saved. - /// Password for output PFX file. - /// If true, overwrites existing file; otherwise throws if file exists. - /// The certificate with private key. - /// If certificate or key file not found. - /// If output file exists and overwrite is false. - public static X509Certificate2 ConvertPemToPfx( - string certPath, - string? keyPath, - string pfxPath, - string pfxPassword, - bool overwrite = false) - { - ArgumentException.ThrowIfNullOrWhiteSpace(certPath); - ArgumentException.ThrowIfNullOrWhiteSpace(pfxPath); - ArgumentException.ThrowIfNullOrWhiteSpace(pfxPassword); - - keyPath ??= Path.ChangeExtension(certPath, ".key"); - - if (!File.Exists(certPath)) - throw new FileNotFoundException($"Certificate file not found: {certPath}"); - - if (!File.Exists(keyPath)) - throw new FileNotFoundException($"Private key file not found: {keyPath}"); - - if (File.Exists(pfxPath) && !overwrite) - throw new IOException($"PFX file already exists: {pfxPath}. Use overwrite=true to replace."); - - // Load PEM certificate with private key - var cert = X509Certificate2.CreateFromPemFile(certPath, keyPath); - - if (!cert.HasPrivateKey) - { - cert.Dispose(); - throw new InvalidOperationException("Certificate does not contain a private key."); - } - - try - { - // Export as PFX - var pfxBytes = cert.Export(X509ContentType.Pfx, pfxPassword); - File.WriteAllBytes(pfxPath, pfxBytes); - - return cert; - } - catch - { - cert.Dispose(); - throw; - } - } - - /// - /// Restricts file permissions to owner-only on non-Windows platforms. - /// On Unix/macOS sets mode 600 (owner read+write only). - /// On Windows this is a no-op — file ACLs should be configured separately. - /// - private static void RestrictFilePermissions(string filePath) - { - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); - } - } -} - -/// -/// Certificate output format. -/// -public enum CertificateFormat -{ - /// - /// PKCS#12 format (.pfx, .p12) - certificate + private key in single password-protected file. - /// - Pfx, - - /// - /// PEM format (.crt + .key) - certificate and private key in separate text files. - /// - Pem, - - /// - /// Auto-detect from file extension (.pfx/.p12 = Pfx, .crt/.pem/.cer = Pem). - /// - Auto -} +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Cocoar.Configuration.X509Encryption; + +/// +/// Generates self-signed X.509 certificates for encryption purposes. +/// +public static class X509CertificateGenerator +{ + /// + /// Generates a self-signed certificate. + /// + /// Certificate subject (e.g., "CN=MyApp"). + /// Validity period in years (default: 1). + /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). + /// A new self-signed X509Certificate2 with private key. + /// If keySize is not 2048, 3072, or 4096. + public static X509Certificate2 GenerateSelfSigned( + string subject, + int validYears = 1, + int keySize = 2048) + { + ArgumentException.ThrowIfNullOrWhiteSpace(subject); + + if (keySize != 2048 && keySize != 3072 && keySize != 4096) + throw new ArgumentException($"Key size must be 2048, 3072, or 4096, got {keySize}", nameof(keySize)); + + if (validYears < 1) + throw new ArgumentException("Validity period must be at least 1 year", nameof(validYears)); + + using var rsa = RSA.Create(keySize); + var request = new CertificateRequest( + subject, + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + // Add basic constraints + request.CertificateExtensions.Add( + new X509BasicConstraintsExtension( + certificateAuthority: false, + hasPathLengthConstraint: false, + pathLengthConstraint: 0, + critical: false)); + + // Add key usage + request.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment, + critical: false)); + + var notBefore = DateTimeOffset.UtcNow; + var notAfter = notBefore.AddYears(validYears); + + return request.CreateSelfSigned(notBefore, notAfter); + } + + /// + /// Generates a self-signed certificate and saves it as a PFX file. + /// + /// Path where the PFX file will be saved. + /// Password to protect the PFX file. + /// Certificate subject (e.g., "CN=MyApp"). + /// Validity period in years (default: 1). + /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). + /// If true, overwrites existing file; otherwise throws if file exists. + /// The generated certificate. + /// If file exists and overwrite is false. + public static X509Certificate2 GenerateAndSavePfx( + string outputPath, + string? password, + string subject, + int validYears = 1, + int keySize = 2048, + bool overwrite = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + + if (File.Exists(outputPath) && !overwrite) + throw new IOException($"File already exists: {outputPath}. Use overwrite=true to replace."); + + var cert = GenerateSelfSigned(subject, validYears, keySize); + + try + { + var pfxBytes = cert.Export(X509ContentType.Pfx, password); + File.WriteAllBytes(outputPath, pfxBytes); + RestrictFilePermissions(outputPath); + return cert; + } + catch + { + cert.Dispose(); + throw; + } + } + + /// + /// Generates a self-signed certificate and saves it in PEM format (.crt + .key files). + /// + /// Path where the certificate file will be saved (.crt). + /// Path where the private key file will be saved (.key). If null, uses certPath with .key extension. + /// Certificate subject (e.g., "CN=MyApp"). + /// Validity period in years (default: 1). + /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). + /// If true, overwrites existing files; otherwise throws if files exist. + /// The generated certificate (without private key - use keyPath to load full cert). + /// If files exist and overwrite is false. + public static X509Certificate2 GenerateAndSavePem( + string certPath, + string? keyPath = null, + string subject = "CN=Cocoar Secrets", + int validYears = 1, + int keySize = 2048, + bool overwrite = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(certPath); + + keyPath ??= Path.ChangeExtension(certPath, ".key"); + + if (File.Exists(certPath) && !overwrite) + throw new IOException($"Certificate file already exists: {certPath}. Use overwrite=true to replace."); + + if (File.Exists(keyPath) && !overwrite) + throw new IOException($"Private key file already exists: {keyPath}. Use overwrite=true to replace."); + + var cert = GenerateSelfSigned(subject, validYears, keySize); + + try + { + // Export certificate (public key only) as PEM + var certPem = cert.ExportCertificatePem(); + File.WriteAllText(certPath, certPem); + + // Export private key as PEM + var key = cert.GetRSAPrivateKey(); + if (key == null) + throw new InvalidOperationException("Certificate does not contain an RSA private key."); + + var keyPem = key.ExportRSAPrivateKeyPem(); + File.WriteAllText(keyPath, keyPem); + RestrictFilePermissions(keyPath); + + return cert; + } + catch + { + cert.Dispose(); + throw; + } + } + + /// + /// Generates a self-signed certificate and saves it as a PFX file. + /// + /// Path where the certificate will be saved. + /// Password for PFX file. + /// Certificate subject (e.g., "CN=MyApp"). + /// Validity period in years (default: 1). + /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). + /// If true, overwrites existing files; otherwise throws if files exist. + /// The generated certificate. + [Obsolete("Use GenerateAndSavePfx or GenerateAndSavePem instead.")] + public static X509Certificate2 GenerateAndSave( + string outputPath, + string password, + string subject, + int validYears = 1, + int keySize = 2048, + bool overwrite = false) + { + return GenerateAndSavePfx(outputPath, password, subject, validYears, keySize, overwrite); + } + + /// + /// Converts a certificate from PFX to PEM format. + /// + /// Path to input PFX file. + /// Password for PFX file. + /// Path where certificate will be saved (.crt). + /// Path where private key will be saved (.key). If null, uses certPath with .key extension. + /// If true, overwrites existing files; otherwise throws if files exist. + /// The certificate (without private key - use keyPath to load full cert). + /// If files exist and overwrite is false. + /// If PFX cannot be loaded or password is incorrect. + public static X509Certificate2 ConvertPfxToPem( + string pfxPath, + string pfxPassword, + string certPath, + string? keyPath = null, + bool overwrite = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pfxPath); + ArgumentException.ThrowIfNullOrWhiteSpace(pfxPassword); + ArgumentException.ThrowIfNullOrWhiteSpace(certPath); + + keyPath ??= Path.ChangeExtension(certPath, ".key"); + + if (File.Exists(certPath) && !overwrite) + throw new IOException($"Certificate file already exists: {certPath}. Use overwrite=true to replace."); + + if (File.Exists(keyPath) && !overwrite) + throw new IOException($"Private key file already exists: {keyPath}. Use overwrite=true to replace."); + + // Load PFX with private key +#if NET9_0_OR_GREATER + var cert = X509CertificateLoader.LoadPkcs12FromFile(pfxPath, pfxPassword, X509KeyStorageFlags.Exportable); +#else + var cert = new X509Certificate2(pfxPath, pfxPassword, X509KeyStorageFlags.Exportable); +#endif + + if (!cert.HasPrivateKey) + { + cert.Dispose(); + throw new InvalidOperationException("PFX file does not contain a private key."); + } + + try + { + // Export certificate (public key only) + var certPem = cert.ExportCertificatePem(); + File.WriteAllText(certPath, certPem); + + // Export private key + var key = cert.GetRSAPrivateKey(); + if (key == null) + { + cert.Dispose(); + throw new InvalidOperationException("Certificate does not contain an RSA private key."); + } + + var keyPem = key.ExportRSAPrivateKeyPem(); + File.WriteAllText(keyPath, keyPem); + RestrictFilePermissions(keyPath); + + return cert; + } + catch + { + cert.Dispose(); + throw; + } + } + + /// + /// Converts a certificate from PEM to PFX format. + /// + /// Path to input certificate file (.crt, .pem, .cer). + /// Path to private key file (.key). If null, uses certPath with .key extension. + /// Path where PFX file will be saved. + /// Password for output PFX file. + /// If true, overwrites existing file; otherwise throws if file exists. + /// The certificate with private key. + /// If certificate or key file not found. + /// If output file exists and overwrite is false. + public static X509Certificate2 ConvertPemToPfx( + string certPath, + string? keyPath, + string pfxPath, + string pfxPassword, + bool overwrite = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(certPath); + ArgumentException.ThrowIfNullOrWhiteSpace(pfxPath); + ArgumentException.ThrowIfNullOrWhiteSpace(pfxPassword); + + keyPath ??= Path.ChangeExtension(certPath, ".key"); + + if (!File.Exists(certPath)) + throw new FileNotFoundException($"Certificate file not found: {certPath}"); + + if (!File.Exists(keyPath)) + throw new FileNotFoundException($"Private key file not found: {keyPath}"); + + if (File.Exists(pfxPath) && !overwrite) + throw new IOException($"PFX file already exists: {pfxPath}. Use overwrite=true to replace."); + + // Load PEM certificate with private key + var cert = X509Certificate2.CreateFromPemFile(certPath, keyPath); + + if (!cert.HasPrivateKey) + { + cert.Dispose(); + throw new InvalidOperationException("Certificate does not contain a private key."); + } + + try + { + // Export as PFX + var pfxBytes = cert.Export(X509ContentType.Pfx, pfxPassword); + File.WriteAllBytes(pfxPath, pfxBytes); + + return cert; + } + catch + { + cert.Dispose(); + throw; + } + } + + /// + /// Restricts file permissions to owner-only on non-Windows platforms. + /// On Unix/macOS sets mode 600 (owner read+write only). + /// On Windows this is a no-op — file ACLs should be configured separately. + /// + private static void RestrictFilePermissions(string filePath) + { + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } +} + +/// +/// Certificate output format. +/// +public enum CertificateFormat +{ + /// + /// PKCS#12 format (.pfx, .p12) - certificate + private key in single password-protected file. + /// + Pfx, + + /// + /// PEM format (.crt + .key) - certificate and private key in separate text files. + /// + Pem, + + /// + /// Auto-detect from file extension (.pfx/.p12 = Pfx, .crt/.pem/.cer = Pem). + /// + Auto +} diff --git a/src/Cocoar.Configuration/Secrets/X509Encryption/X509HybridCrypto.cs b/src/Cocoar.Configuration/Secrets/X509Encryption/X509HybridCrypto.cs index 6ffcc74..5b6e0b4 100644 --- a/src/Cocoar.Configuration/Secrets/X509Encryption/X509HybridCrypto.cs +++ b/src/Cocoar.Configuration/Secrets/X509Encryption/X509HybridCrypto.cs @@ -1,155 +1,155 @@ -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text; - -namespace Cocoar.Configuration.X509Encryption; - -/// -/// Hybrid RSA+AES encryption using X.509 certificates. -/// Uses RSA-OAEP-SHA256 for key wrapping and AES-256-GCM for data encryption. -/// -public sealed class X509HybridCrypto -{ - private readonly X509Certificate2 _certificate; - private readonly RSA _rsa; - - /// - /// Create a crypto instance from a certificate with private key. - /// - public X509HybridCrypto(X509Certificate2 certificate) - { - if (!certificate.HasPrivateKey) - throw new ArgumentException("Certificate must have a private key for encryption/decryption", nameof(certificate)); - - _certificate = certificate ?? throw new ArgumentNullException(nameof(certificate)); - _rsa = certificate.GetRSAPrivateKey() - ?? throw new InvalidOperationException("RSA private key not available on certificate"); - } - - /// - /// Encrypt plaintext bytes into a hybrid envelope. - /// - public HybridSecretEnvelope Encrypt(ReadOnlySpan plaintext) - { - // Generate random 256-bit AES key (DEK - Data Encryption Key) - Span dek = stackalloc byte[32]; - RandomNumberGenerator.Fill(dek); - - // Heap copy of DEK needed for RSA API (accepts byte[], not Span) - var dekArray = dek.ToArray(); - try - { - // Generate random 96-bit IV for AES-GCM - byte[] iv = new byte[12]; - RandomNumberGenerator.Fill(iv); - - // Encrypt with AES-256-GCM - byte[] ciphertext = new byte[plaintext.Length]; - byte[] tag = new byte[16]; - - using (var aes = new AesGcm(dek, tag.Length)) - { - aes.Encrypt(iv, plaintext, ciphertext, tag, associatedData: null); - } - - // Wrap DEK with RSA-OAEP-SHA256 - var wrappedKey = _rsa.Encrypt(dekArray, RSAEncryptionPadding.OaepSHA256); - - return new HybridSecretEnvelope - { - WrappedKey = Convert.ToBase64String(wrappedKey), - WrappingAlgorithm = "RSA-OAEP-256", - Iv = Convert.ToBase64String(iv), - Ciphertext = Convert.ToBase64String(ciphertext), - Tag = Convert.ToBase64String(tag) - }; - } - finally - { - // Zero out DEK from both stack and heap - CryptographicOperations.ZeroMemory(dek); - CryptographicOperations.ZeroMemory(dekArray); - } - } - - /// - /// Encrypt a UTF-8 string into a hybrid envelope. - /// - public HybridSecretEnvelope Encrypt(string plaintext) - { - var bytes = Encoding.UTF8.GetBytes(plaintext); - try - { - return Encrypt(bytes.AsSpan()); - } - finally - { - Array.Clear(bytes); - } - } - - /// - /// Decrypt a hybrid envelope back to plaintext bytes. - /// - public byte[] Decrypt(HybridSecretEnvelope envelope) - { - ArgumentNullException.ThrowIfNull(envelope); - - // Unwrap the AES key with RSA - var wrappedKey = Convert.FromBase64String(envelope.WrappedKey); - var dek = _rsa.Decrypt(wrappedKey, RSAEncryptionPadding.OaepSHA256); - - try - { - // Decrypt with AES-256-GCM - var iv = Convert.FromBase64String(envelope.Iv); - var ciphertext = Convert.FromBase64String(envelope.Ciphertext); - var tag = Convert.FromBase64String(envelope.Tag); - - var plaintext = new byte[ciphertext.Length]; - - using (var aes = new AesGcm(dek, tag.Length)) - { - aes.Decrypt(iv, ciphertext, tag, plaintext, associatedData: null); - } - - return plaintext; - } - finally - { - // Zero out the DEK from memory - Array.Clear(dek); - } - } - - /// - /// Decrypt a hybrid envelope to a UTF-8 string. - /// - public string DecryptToString(HybridSecretEnvelope envelope) - { - var bytes = Decrypt(envelope); - try - { - return Encoding.UTF8.GetString(bytes); - } - finally - { - Array.Clear(bytes); - } - } - - /// - /// Load a certificate from a PFX file. - /// - public static X509Certificate2 LoadCertificate(string pfxPath, string password) - { - if (!File.Exists(pfxPath)) - throw new FileNotFoundException($"Certificate file not found: {pfxPath}", pfxPath); - -#if NET9_0_OR_GREATER - return X509CertificateLoader.LoadPkcs12FromFile(pfxPath, password, X509KeyStorageFlags.Exportable); -#else - return new X509Certificate2(pfxPath, password, X509KeyStorageFlags.Exportable); -#endif - } -} +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Cocoar.Configuration.X509Encryption; + +/// +/// Hybrid RSA+AES encryption using X.509 certificates. +/// Uses RSA-OAEP-SHA256 for key wrapping and AES-256-GCM for data encryption. +/// +public sealed class X509HybridCrypto +{ + private readonly X509Certificate2 _certificate; + private readonly RSA _rsa; + + /// + /// Create a crypto instance from a certificate with private key. + /// + public X509HybridCrypto(X509Certificate2 certificate) + { + if (!certificate.HasPrivateKey) + throw new ArgumentException("Certificate must have a private key for encryption/decryption", nameof(certificate)); + + _certificate = certificate ?? throw new ArgumentNullException(nameof(certificate)); + _rsa = certificate.GetRSAPrivateKey() + ?? throw new InvalidOperationException("RSA private key not available on certificate"); + } + + /// + /// Encrypt plaintext bytes into a hybrid envelope. + /// + public HybridSecretEnvelope Encrypt(ReadOnlySpan plaintext) + { + // Generate random 256-bit AES key (DEK - Data Encryption Key) + Span dek = stackalloc byte[32]; + RandomNumberGenerator.Fill(dek); + + // Heap copy of DEK needed for RSA API (accepts byte[], not Span) + var dekArray = dek.ToArray(); + try + { + // Generate random 96-bit IV for AES-GCM + byte[] iv = new byte[12]; + RandomNumberGenerator.Fill(iv); + + // Encrypt with AES-256-GCM + byte[] ciphertext = new byte[plaintext.Length]; + byte[] tag = new byte[16]; + + using (var aes = new AesGcm(dek, tag.Length)) + { + aes.Encrypt(iv, plaintext, ciphertext, tag, associatedData: null); + } + + // Wrap DEK with RSA-OAEP-SHA256 + var wrappedKey = _rsa.Encrypt(dekArray, RSAEncryptionPadding.OaepSHA256); + + return new HybridSecretEnvelope + { + WrappedKey = Convert.ToBase64String(wrappedKey), + WrappingAlgorithm = "RSA-OAEP-256", + Iv = Convert.ToBase64String(iv), + Ciphertext = Convert.ToBase64String(ciphertext), + Tag = Convert.ToBase64String(tag) + }; + } + finally + { + // Zero out DEK from both stack and heap + CryptographicOperations.ZeroMemory(dek); + CryptographicOperations.ZeroMemory(dekArray); + } + } + + /// + /// Encrypt a UTF-8 string into a hybrid envelope. + /// + public HybridSecretEnvelope Encrypt(string plaintext) + { + var bytes = Encoding.UTF8.GetBytes(plaintext); + try + { + return Encrypt(bytes.AsSpan()); + } + finally + { + Array.Clear(bytes); + } + } + + /// + /// Decrypt a hybrid envelope back to plaintext bytes. + /// + public byte[] Decrypt(HybridSecretEnvelope envelope) + { + ArgumentNullException.ThrowIfNull(envelope); + + // Unwrap the AES key with RSA + var wrappedKey = Convert.FromBase64String(envelope.WrappedKey); + var dek = _rsa.Decrypt(wrappedKey, RSAEncryptionPadding.OaepSHA256); + + try + { + // Decrypt with AES-256-GCM + var iv = Convert.FromBase64String(envelope.Iv); + var ciphertext = Convert.FromBase64String(envelope.Ciphertext); + var tag = Convert.FromBase64String(envelope.Tag); + + var plaintext = new byte[ciphertext.Length]; + + using (var aes = new AesGcm(dek, tag.Length)) + { + aes.Decrypt(iv, ciphertext, tag, plaintext, associatedData: null); + } + + return plaintext; + } + finally + { + // Zero out the DEK from memory + Array.Clear(dek); + } + } + + /// + /// Decrypt a hybrid envelope to a UTF-8 string. + /// + public string DecryptToString(HybridSecretEnvelope envelope) + { + var bytes = Decrypt(envelope); + try + { + return Encoding.UTF8.GetString(bytes); + } + finally + { + Array.Clear(bytes); + } + } + + /// + /// Load a certificate from a PFX file. + /// + public static X509Certificate2 LoadCertificate(string pfxPath, string password) + { + if (!File.Exists(pfxPath)) + throw new FileNotFoundException($"Certificate file not found: {pfxPath}", pfxPath); + +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12FromFile(pfxPath, password, X509KeyStorageFlags.Exportable); +#else + return new X509Certificate2(pfxPath, password, X509KeyStorageFlags.Exportable); +#endif + } +} diff --git a/src/Cocoar.Configuration/Testing/CocoarTestConfiguration.cs b/src/Cocoar.Configuration/Testing/CocoarTestConfiguration.cs index 23f80a6..e36a0b3 100644 --- a/src/Cocoar.Configuration/Testing/CocoarTestConfiguration.cs +++ b/src/Cocoar.Configuration/Testing/CocoarTestConfiguration.cs @@ -1,366 +1,366 @@ -using System.Text.Json; -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Rules; - -namespace Cocoar.Configuration.Testing; - -/// -/// Provides test-friendly configuration overrides using AsyncLocal for isolated test contexts. -/// Use this in integration tests to override configuration rules without modifying application code. -/// -/// -/// -/// AsyncLocal Context Behavior: AsyncLocal flows automatically through async/await within the same -/// async context. However, xUnit creates separate async contexts for fixture setup vs test methods. -/// Configuration set in a fixture's InitializeAsync() will NOT be visible in test methods. -/// -/// -/// Solution: For fixture-based patterns, store the in the fixture, -/// then call in each test class constructor to bridge the context gap. -/// See and -/// for convenient factory methods. -/// -/// -public static class CocoarTestConfiguration -{ - private static readonly AsyncLocal s_testContext = new(); - - /// - /// Replaces all configured rules with test-specific rules. - /// Original rules are completely skipped — ideal when providers would fail in test environment. - /// Returns a that can be further composed (e.g. chaining - /// .ReplaceSecretsSetup(...)) and acts as a disposable scope. - /// - /// Function to build test rules using the fluent API. - /// Optional function to build test setup using the fluent API. - /// An auto-activating builder / disposable scope. - /// - /// - /// using var _ = CocoarTestConfiguration.ReplaceConfiguration( - /// rule => [ - /// rule.For<DbConfig>().FromStatic(_ => new DbConfig { Connection = testDb }) - /// ]); - /// - /// - public static TestOverrideBuilder ReplaceConfiguration( - Func rules, - Func? setup = null) - => new TestOverrideBuilder(autoActivate: true).ReplaceConfiguration(rules, setup); - - /// - /// Appends test rules to the end of configured rules (last-write-wins). - /// Original rules execute first, then test rules override specific values. - /// Returns a that can be further composed and acts as a disposable scope. - /// - /// Function to build test rules using the fluent API. - /// Optional function to build test setup using the fluent API. - /// An auto-activating builder / disposable scope. - /// - /// - /// using var _ = CocoarTestConfiguration.AppendConfiguration( - /// rule => [ - /// rule.For<DbConfig>().FromStatic(_ => new DbConfig { Connection = testDb }) - /// ]); - /// - /// - public static TestOverrideBuilder AppendConfiguration( - Func rules, - Func? setup = null) - => new TestOverrideBuilder(autoActivate: true).AppendConfiguration(rules, setup); - - /// - /// Replaces the secrets setup used during ConfigManager initialization. - /// Returns a that can be further composed and acts as a disposable scope. - /// - /// Raw delegate that receives a SecretsBuilder (typed by Secrets package extension). - /// An auto-activating builder / disposable scope. - public static TestOverrideBuilder ReplaceSecretsSetup(Delegate configure) - => new TestOverrideBuilder(autoActivate: true).ReplaceSecretsSetupCore(configure); - - /// - /// Applies an existing to the current async context. - /// Use this in test class constructors to bridge the AsyncLocal gap between fixtures and test methods. - /// - /// The test configuration context to apply. - /// A scope that clears the test configuration when disposed. - /// - /// - /// public class MyTests : IClassFixture<IntegrationTestFixture>, IDisposable - /// { - /// public MyTests(IntegrationTestFixture fixture) - /// { - /// CocoarTestConfiguration.Apply(fixture.TestContext); - /// } - /// - /// public void Dispose() => CocoarTestConfiguration.Clear(); - /// } - /// - /// - public static TestConfigurationScope Apply(TestConfigurationContext context) - { - ArgumentNullException.ThrowIfNull(context); - s_testContext.Value = context; - return new TestConfigurationScope(); - } - - /// - /// Clears the test configuration context, restoring normal configuration behavior. - /// - public static void Clear() - { - s_testContext.Value = null; - } - - /// - /// Gets the current test configuration context, if any. - /// - public static TestConfigurationContext? Current => s_testContext.Value; - - /// - /// Checks if test configuration is active in the current async context. - /// - public static bool IsActive => s_testContext.Value != null; - - /// - /// Sets the active context. Called by after each mutation. - /// - internal static void SetContext(TestConfigurationContext context) => - s_testContext.Value = context; -} - -/// -/// Represents the mode for test configuration override. -/// -public enum TestConfigurationMode -{ - /// - /// Replace all configured rules with test rules (original rules are skipped). - /// - Replace, - - /// - /// Append test rules to the end of configured rules (last-write-wins merging). - /// - Append -} - -/// -/// Holds the test configuration context stored in AsyncLocal. -/// -/// -/// Store instances of this class in test fixtures and use -/// in test class constructors to bridge the async context gap between fixture setup and test methods. -/// Use or -/// as convenient factory methods, or build via for the fixture pattern. -/// -public sealed class TestConfigurationContext -{ - /// - /// Gets the rules builder function for this test configuration, or null if no rules override is set. - /// - public Func? Rules { get; } - - /// - /// Gets the mode for this test configuration (Replace or Append), or null if no rules override is set. - /// - public TestConfigurationMode? ConfigurationMode { get; } - - /// - /// Gets the optional setup builder function for this test configuration. - /// - public Func? Setup { get; } - - /// - /// Optional custom serializer options for this test configuration context. - /// - public JsonSerializerOptions? SerializerOptions { get; set; } - - /// - /// Raw secrets setup override delegate. Typed as to avoid a hard dependency - /// on the Secrets package from the core library. The Secrets package casts this to the correct type. - /// - internal Delegate? SecretsSetupOverride { get; init; } - - internal TestConfigurationContext( - Func? rules = null, - Func? setup = null, - TestConfigurationMode? configurationMode = null, - Delegate? secretsSetupOverride = null) - { - Rules = rules; - Setup = setup; - ConfigurationMode = configurationMode; - SecretsSetupOverride = secretsSetupOverride; - } - - /// - /// Creates a test configuration context that replaces all configured rules. - /// Original rules are completely skipped — ideal when providers would fail in the test environment. - /// - /// Function to build test rules using the fluent API. - /// Optional function to build test setup using the fluent API. - /// A new test configuration context in Replace mode. - /// - /// - /// public class IntegrationTestFixture - /// { - /// public TestConfigurationContext TestContext { get; } = - /// TestConfigurationContext.Replace( - /// rule => [ - /// rule.For<DbConfig>().FromStatic(_ => new DbConfig { Connection = "test-db" }) - /// ]); - /// } - /// - /// - public static TestConfigurationContext Replace( - Func rules, - Func? setup = null) - { - ArgumentNullException.ThrowIfNull(rules); - return new(rules, setup, TestConfigurationMode.Replace); - } - - /// - /// Creates a test configuration context that appends test rules to configured rules. - /// Original rules execute first, then test rules override specific values (last-write-wins). - /// - /// Function to build test rules using the fluent API. - /// Optional function to build test setup using the fluent API. - /// A new test configuration context in Append mode. - /// - /// - /// public class IntegrationTestFixture - /// { - /// public TestConfigurationContext TestContext { get; } = - /// TestConfigurationContext.Append( - /// rule => [ - /// rule.For<DbConfig>().FromStatic(_ => new DbConfig { MaxConnections = 5 }) - /// ]); - /// } - /// - /// - public static TestConfigurationContext Append( - Func rules, - Func? setup = null) - { - ArgumentNullException.ThrowIfNull(rules); - return new(rules, setup, TestConfigurationMode.Append); - } -} - -/// -/// Fluent builder for composing test configuration overrides. -/// Returned by , -/// , and -/// . -/// When constructed via those static methods (auto-activate mode) each chained call immediately -/// activates the accumulated context in the current async scope. -/// Use new TestOverrideBuilder() (no-arg) for the fixture pattern — context is only activated -/// when you later call . -/// Disposing a builder created in auto-activate mode calls . -/// -public sealed class TestOverrideBuilder : IDisposable -{ - private readonly bool _autoActivate; - private Func? _rules; - private Func? _setup; - private TestConfigurationMode? _configurationMode; - private Delegate? _secretsSetupOverride; - - /// - /// Creates a builder for the fixture pattern. Does NOT auto-activate. - /// Call and then pass the result to . - /// - public TestOverrideBuilder() { } - - internal TestOverrideBuilder(bool autoActivate) => _autoActivate = autoActivate; - - /// - /// Sets the rules override to Replace mode (original rules are skipped). - /// - public TestOverrideBuilder ReplaceConfiguration( - Func rules, - Func? setup = null) - { - ArgumentNullException.ThrowIfNull(rules); - _rules = rules; - _setup = setup ?? _setup; - _configurationMode = TestConfigurationMode.Replace; - if (_autoActivate) CocoarTestConfiguration.SetContext(Build()); - return this; - } - - /// - /// Sets the rules override to Append mode (test rules follow original rules, last-write-wins). - /// - public TestOverrideBuilder AppendConfiguration( - Func rules, - Func? setup = null) - { - ArgumentNullException.ThrowIfNull(rules); - _rules = rules; - _setup = setup ?? _setup; - _configurationMode = TestConfigurationMode.Append; - if (_autoActivate) CocoarTestConfiguration.SetContext(Build()); - return this; - } - - /// - /// Sets a raw secrets setup override delegate (used internally and by the Secrets package extension). - /// - internal TestOverrideBuilder ReplaceSecretsSetupCore(Delegate configure) - { - ArgumentNullException.ThrowIfNull(configure); - _secretsSetupOverride = configure; - if (_autoActivate) CocoarTestConfiguration.SetContext(Build()); - return this; - } - - /// - /// Called by the Secrets package extension to store a strongly-typed override. - /// - public void SetSecretsSetupOverride(Delegate configure) - { - ArgumentNullException.ThrowIfNull(configure); - _secretsSetupOverride = configure; - if (_autoActivate) CocoarTestConfiguration.SetContext(Build()); - } - - /// - /// Builds a snapshot without activating it. - /// - public TestConfigurationContext Build() => - new(_rules, _setup, _configurationMode, _secretsSetupOverride); - - /// - /// Clears the active test configuration context. Only meaningful in auto-activate mode. - /// - public void Dispose() => CocoarTestConfiguration.Clear(); -} - -/// -/// A disposable scope that clears test configuration when disposed. -/// Use with using statements for exception-safe cleanup. -/// -/// -/// -/// using var _ = CocoarTestConfiguration.Apply(context); -/// // Test runs here -/// // Configuration automatically cleared when scope is disposed -/// -/// -public readonly struct TestConfigurationScope : IDisposable -{ - /// - /// Gets whether test configuration is currently active. - /// -#pragma warning disable CA1822 // Member does not access instance data - intentional instance property for API convenience - public bool IsActive => CocoarTestConfiguration.IsActive; -#pragma warning restore CA1822 - - /// - /// Clears the test configuration context. - /// - public void Dispose() => CocoarTestConfiguration.Clear(); -} +using System.Text.Json; +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Rules; + +namespace Cocoar.Configuration.Testing; + +/// +/// Provides test-friendly configuration overrides using AsyncLocal for isolated test contexts. +/// Use this in integration tests to override configuration rules without modifying application code. +/// +/// +/// +/// AsyncLocal Context Behavior: AsyncLocal flows automatically through async/await within the same +/// async context. However, xUnit creates separate async contexts for fixture setup vs test methods. +/// Configuration set in a fixture's InitializeAsync() will NOT be visible in test methods. +/// +/// +/// Solution: For fixture-based patterns, store the in the fixture, +/// then call in each test class constructor to bridge the context gap. +/// See and +/// for convenient factory methods. +/// +/// +public static class CocoarTestConfiguration +{ + private static readonly AsyncLocal s_testContext = new(); + + /// + /// Replaces all configured rules with test-specific rules. + /// Original rules are completely skipped — ideal when providers would fail in test environment. + /// Returns a that can be further composed (e.g. chaining + /// .ReplaceSecretsSetup(...)) and acts as a disposable scope. + /// + /// Function to build test rules using the fluent API. + /// Optional function to build test setup using the fluent API. + /// An auto-activating builder / disposable scope. + /// + /// + /// using var _ = CocoarTestConfiguration.ReplaceConfiguration( + /// rule => [ + /// rule.For<DbConfig>().FromStatic(_ => new DbConfig { Connection = testDb }) + /// ]); + /// + /// + public static TestOverrideBuilder ReplaceConfiguration( + Func rules, + Func? setup = null) + => new TestOverrideBuilder(autoActivate: true).ReplaceConfiguration(rules, setup); + + /// + /// Appends test rules to the end of configured rules (last-write-wins). + /// Original rules execute first, then test rules override specific values. + /// Returns a that can be further composed and acts as a disposable scope. + /// + /// Function to build test rules using the fluent API. + /// Optional function to build test setup using the fluent API. + /// An auto-activating builder / disposable scope. + /// + /// + /// using var _ = CocoarTestConfiguration.AppendConfiguration( + /// rule => [ + /// rule.For<DbConfig>().FromStatic(_ => new DbConfig { Connection = testDb }) + /// ]); + /// + /// + public static TestOverrideBuilder AppendConfiguration( + Func rules, + Func? setup = null) + => new TestOverrideBuilder(autoActivate: true).AppendConfiguration(rules, setup); + + /// + /// Replaces the secrets setup used during ConfigManager initialization. + /// Returns a that can be further composed and acts as a disposable scope. + /// + /// Raw delegate that receives a SecretsBuilder (typed by Secrets package extension). + /// An auto-activating builder / disposable scope. + public static TestOverrideBuilder ReplaceSecretsSetup(Delegate configure) + => new TestOverrideBuilder(autoActivate: true).ReplaceSecretsSetupCore(configure); + + /// + /// Applies an existing to the current async context. + /// Use this in test class constructors to bridge the AsyncLocal gap between fixtures and test methods. + /// + /// The test configuration context to apply. + /// A scope that clears the test configuration when disposed. + /// + /// + /// public class MyTests : IClassFixture<IntegrationTestFixture>, IDisposable + /// { + /// public MyTests(IntegrationTestFixture fixture) + /// { + /// CocoarTestConfiguration.Apply(fixture.TestContext); + /// } + /// + /// public void Dispose() => CocoarTestConfiguration.Clear(); + /// } + /// + /// + public static TestConfigurationScope Apply(TestConfigurationContext context) + { + ArgumentNullException.ThrowIfNull(context); + s_testContext.Value = context; + return new TestConfigurationScope(); + } + + /// + /// Clears the test configuration context, restoring normal configuration behavior. + /// + public static void Clear() + { + s_testContext.Value = null; + } + + /// + /// Gets the current test configuration context, if any. + /// + public static TestConfigurationContext? Current => s_testContext.Value; + + /// + /// Checks if test configuration is active in the current async context. + /// + public static bool IsActive => s_testContext.Value != null; + + /// + /// Sets the active context. Called by after each mutation. + /// + internal static void SetContext(TestConfigurationContext context) => + s_testContext.Value = context; +} + +/// +/// Represents the mode for test configuration override. +/// +public enum TestConfigurationMode +{ + /// + /// Replace all configured rules with test rules (original rules are skipped). + /// + Replace, + + /// + /// Append test rules to the end of configured rules (last-write-wins merging). + /// + Append +} + +/// +/// Holds the test configuration context stored in AsyncLocal. +/// +/// +/// Store instances of this class in test fixtures and use +/// in test class constructors to bridge the async context gap between fixture setup and test methods. +/// Use or +/// as convenient factory methods, or build via for the fixture pattern. +/// +public sealed class TestConfigurationContext +{ + /// + /// Gets the rules builder function for this test configuration, or null if no rules override is set. + /// + public Func? Rules { get; } + + /// + /// Gets the mode for this test configuration (Replace or Append), or null if no rules override is set. + /// + public TestConfigurationMode? ConfigurationMode { get; } + + /// + /// Gets the optional setup builder function for this test configuration. + /// + public Func? Setup { get; } + + /// + /// Optional custom serializer options for this test configuration context. + /// + public JsonSerializerOptions? SerializerOptions { get; set; } + + /// + /// Raw secrets setup override delegate. Typed as to avoid a hard dependency + /// on the Secrets package from the core library. The Secrets package casts this to the correct type. + /// + internal Delegate? SecretsSetupOverride { get; init; } + + internal TestConfigurationContext( + Func? rules = null, + Func? setup = null, + TestConfigurationMode? configurationMode = null, + Delegate? secretsSetupOverride = null) + { + Rules = rules; + Setup = setup; + ConfigurationMode = configurationMode; + SecretsSetupOverride = secretsSetupOverride; + } + + /// + /// Creates a test configuration context that replaces all configured rules. + /// Original rules are completely skipped — ideal when providers would fail in the test environment. + /// + /// Function to build test rules using the fluent API. + /// Optional function to build test setup using the fluent API. + /// A new test configuration context in Replace mode. + /// + /// + /// public class IntegrationTestFixture + /// { + /// public TestConfigurationContext TestContext { get; } = + /// TestConfigurationContext.Replace( + /// rule => [ + /// rule.For<DbConfig>().FromStatic(_ => new DbConfig { Connection = "test-db" }) + /// ]); + /// } + /// + /// + public static TestConfigurationContext Replace( + Func rules, + Func? setup = null) + { + ArgumentNullException.ThrowIfNull(rules); + return new(rules, setup, TestConfigurationMode.Replace); + } + + /// + /// Creates a test configuration context that appends test rules to configured rules. + /// Original rules execute first, then test rules override specific values (last-write-wins). + /// + /// Function to build test rules using the fluent API. + /// Optional function to build test setup using the fluent API. + /// A new test configuration context in Append mode. + /// + /// + /// public class IntegrationTestFixture + /// { + /// public TestConfigurationContext TestContext { get; } = + /// TestConfigurationContext.Append( + /// rule => [ + /// rule.For<DbConfig>().FromStatic(_ => new DbConfig { MaxConnections = 5 }) + /// ]); + /// } + /// + /// + public static TestConfigurationContext Append( + Func rules, + Func? setup = null) + { + ArgumentNullException.ThrowIfNull(rules); + return new(rules, setup, TestConfigurationMode.Append); + } +} + +/// +/// Fluent builder for composing test configuration overrides. +/// Returned by , +/// , and +/// . +/// When constructed via those static methods (auto-activate mode) each chained call immediately +/// activates the accumulated context in the current async scope. +/// Use new TestOverrideBuilder() (no-arg) for the fixture pattern — context is only activated +/// when you later call . +/// Disposing a builder created in auto-activate mode calls . +/// +public sealed class TestOverrideBuilder : IDisposable +{ + private readonly bool _autoActivate; + private Func? _rules; + private Func? _setup; + private TestConfigurationMode? _configurationMode; + private Delegate? _secretsSetupOverride; + + /// + /// Creates a builder for the fixture pattern. Does NOT auto-activate. + /// Call and then pass the result to . + /// + public TestOverrideBuilder() { } + + internal TestOverrideBuilder(bool autoActivate) => _autoActivate = autoActivate; + + /// + /// Sets the rules override to Replace mode (original rules are skipped). + /// + public TestOverrideBuilder ReplaceConfiguration( + Func rules, + Func? setup = null) + { + ArgumentNullException.ThrowIfNull(rules); + _rules = rules; + _setup = setup ?? _setup; + _configurationMode = TestConfigurationMode.Replace; + if (_autoActivate) CocoarTestConfiguration.SetContext(Build()); + return this; + } + + /// + /// Sets the rules override to Append mode (test rules follow original rules, last-write-wins). + /// + public TestOverrideBuilder AppendConfiguration( + Func rules, + Func? setup = null) + { + ArgumentNullException.ThrowIfNull(rules); + _rules = rules; + _setup = setup ?? _setup; + _configurationMode = TestConfigurationMode.Append; + if (_autoActivate) CocoarTestConfiguration.SetContext(Build()); + return this; + } + + /// + /// Sets a raw secrets setup override delegate (used internally and by the Secrets package extension). + /// + internal TestOverrideBuilder ReplaceSecretsSetupCore(Delegate configure) + { + ArgumentNullException.ThrowIfNull(configure); + _secretsSetupOverride = configure; + if (_autoActivate) CocoarTestConfiguration.SetContext(Build()); + return this; + } + + /// + /// Called by the Secrets package extension to store a strongly-typed override. + /// + public void SetSecretsSetupOverride(Delegate configure) + { + ArgumentNullException.ThrowIfNull(configure); + _secretsSetupOverride = configure; + if (_autoActivate) CocoarTestConfiguration.SetContext(Build()); + } + + /// + /// Builds a snapshot without activating it. + /// + public TestConfigurationContext Build() => + new(_rules, _setup, _configurationMode, _secretsSetupOverride); + + /// + /// Clears the active test configuration context. Only meaningful in auto-activate mode. + /// + public void Dispose() => CocoarTestConfiguration.Clear(); +} + +/// +/// A disposable scope that clears test configuration when disposed. +/// Use with using statements for exception-safe cleanup. +/// +/// +/// +/// using var _ = CocoarTestConfiguration.Apply(context); +/// // Test runs here +/// // Configuration automatically cleared when scope is disposed +/// +/// +public readonly struct TestConfigurationScope : IDisposable +{ + /// + /// Gets whether test configuration is currently active. + /// +#pragma warning disable CA1822 // Member does not access instance data - intentional instance property for API convenience + public bool IsActive => CocoarTestConfiguration.IsActive; +#pragma warning restore CA1822 + + /// + /// Clears the test configuration context. + /// + public void Dispose() => CocoarTestConfiguration.Clear(); +} diff --git a/src/Cocoar.Configuration/Utilities/ConfigurationDeserializer.cs b/src/Cocoar.Configuration/Utilities/ConfigurationDeserializer.cs index e9e60f1..1219fdf 100644 --- a/src/Cocoar.Configuration/Utilities/ConfigurationDeserializer.cs +++ b/src/Cocoar.Configuration/Utilities/ConfigurationDeserializer.cs @@ -1,75 +1,75 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Cocoar.Capabilities; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Extensibility; - -namespace Cocoar.Configuration.Utilities; - - -internal static class ConfigurationDeserializer -{ - public static T? Deserialize(JsonElement element, ConfigManagerCapabilityScope? capabilityScope = null) - => element.Deserialize(CreateOptions(capabilityScope)); - - public static object? Deserialize(JsonElement element, Type type, ConfigManagerCapabilityScope? capabilityScope = null) - => element.Deserialize(type, CreateOptions(capabilityScope)); - - public static object? Deserialize(JsonElement element, Type type, IReadOnlyDictionary? deserializationMap, ConfigManagerCapabilityScope? capabilityScope = null) - { - if (deserializationMap == null || deserializationMap.Count == 0) - { - return Deserialize(element, type, capabilityScope); - } - - var options = CreateOptionsWithInterfaceMapping(deserializationMap, capabilityScope); - return element.Deserialize(type, options); - } - - private static JsonSerializerOptions CreateOptions(ConfigManagerCapabilityScope? capabilityScope) - { - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new JsonStringEnumConverter()); - - ApplySerializerCapabilities(options, capabilityScope); - - return options; - } - - private static JsonSerializerOptions CreateOptionsWithInterfaceMapping(IReadOnlyDictionary deserializationMap, ConfigManagerCapabilityScope? capabilityScope) - { - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new JsonStringEnumConverter()); - - options.Converters.Add(new InterfaceConverter(new Dictionary(deserializationMap))); - - ApplySerializerCapabilities(options, capabilityScope); - - return options; - } - - private static void ApplySerializerCapabilities(JsonSerializerOptions options, ConfigManagerCapabilityScope? capabilityScope) - { - capabilityScope?.Owner.GetComposition()?.UsingEach(c => c.Configure(options)); - } -} +using System.Text.Json; +using System.Text.Json.Serialization; +using Cocoar.Capabilities; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Extensibility; + +namespace Cocoar.Configuration.Utilities; + + +internal static class ConfigurationDeserializer +{ + public static T? Deserialize(JsonElement element, ConfigManagerCapabilityScope? capabilityScope = null) + => element.Deserialize(CreateOptions(capabilityScope)); + + public static object? Deserialize(JsonElement element, Type type, ConfigManagerCapabilityScope? capabilityScope = null) + => element.Deserialize(type, CreateOptions(capabilityScope)); + + public static object? Deserialize(JsonElement element, Type type, IReadOnlyDictionary? deserializationMap, ConfigManagerCapabilityScope? capabilityScope = null) + { + if (deserializationMap == null || deserializationMap.Count == 0) + { + return Deserialize(element, type, capabilityScope); + } + + var options = CreateOptionsWithInterfaceMapping(deserializationMap, capabilityScope); + return element.Deserialize(type, options); + } + + private static JsonSerializerOptions CreateOptions(ConfigManagerCapabilityScope? capabilityScope) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new JsonStringEnumConverter()); + + ApplySerializerCapabilities(options, capabilityScope); + + return options; + } + + private static JsonSerializerOptions CreateOptionsWithInterfaceMapping(IReadOnlyDictionary deserializationMap, ConfigManagerCapabilityScope? capabilityScope) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new JsonStringEnumConverter()); + + options.Converters.Add(new InterfaceConverter(new Dictionary(deserializationMap))); + + ApplySerializerCapabilities(options, capabilityScope); + + return options; + } + + private static void ApplySerializerCapabilities(JsonSerializerOptions options, ConfigManagerCapabilityScope? capabilityScope) + { + capabilityScope?.Owner.GetComposition()?.UsingEach(c => c.Configure(options)); + } +} diff --git a/src/Cocoar.Configuration/Utilities/InterfaceConverter.cs b/src/Cocoar.Configuration/Utilities/InterfaceConverter.cs index 4733164..5812d1a 100644 --- a/src/Cocoar.Configuration/Utilities/InterfaceConverter.cs +++ b/src/Cocoar.Configuration/Utilities/InterfaceConverter.cs @@ -1,44 +1,44 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Cocoar.Configuration.Utilities; - -internal class InterfaceConverter : JsonConverterFactory -{ - private readonly Dictionary _interfaceToConcreteMap; - - public InterfaceConverter(Dictionary interfaceToConcreteMap) - { - _interfaceToConcreteMap = interfaceToConcreteMap; - } - - public override bool CanConvert(Type typeToConvert) - { - return typeToConvert.IsInterface && _interfaceToConcreteMap.ContainsKey(typeToConvert); - } - - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) - { - if (_interfaceToConcreteMap.TryGetValue(typeToConvert, out var concreteType)) - { - var converterType = typeof(InterfaceConverterInner<,>).MakeGenericType(typeToConvert, concreteType); - return (JsonConverter?)Activator.CreateInstance(converterType); - } - - return null; - } - - private class InterfaceConverterInner : JsonConverter - where TConcrete : TInterface - { - public override TInterface? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - return JsonSerializer.Deserialize(ref reader, options); - } - - public override void Write(Utf8JsonWriter writer, TInterface value, JsonSerializerOptions options) - { - JsonSerializer.Serialize(writer, value, options); - } - } -} +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.Utilities; + +internal class InterfaceConverter : JsonConverterFactory +{ + private readonly Dictionary _interfaceToConcreteMap; + + public InterfaceConverter(Dictionary interfaceToConcreteMap) + { + _interfaceToConcreteMap = interfaceToConcreteMap; + } + + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsInterface && _interfaceToConcreteMap.ContainsKey(typeToConvert); + } + + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + if (_interfaceToConcreteMap.TryGetValue(typeToConvert, out var concreteType)) + { + var converterType = typeof(InterfaceConverterInner<,>).MakeGenericType(typeToConvert, concreteType); + return (JsonConverter?)Activator.CreateInstance(converterType); + } + + return null; + } + + private class InterfaceConverterInner : JsonConverter + where TConcrete : TInterface + { + public override TInterface? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return JsonSerializer.Deserialize(ref reader, options); + } + + public override void Write(Utf8JsonWriter writer, TInterface value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, options); + } + } +} diff --git a/src/Cocoar.Configuration/Utilities/Safety.cs b/src/Cocoar.Configuration/Utilities/Safety.cs index 386fca7..9200591 100644 --- a/src/Cocoar.Configuration/Utilities/Safety.cs +++ b/src/Cocoar.Configuration/Utilities/Safety.cs @@ -1,53 +1,53 @@ -using System; -using System.Threading; - -namespace Cocoar.Configuration.Utilities; - -public static class Safety -{ - public static void DisposeQuietly(IDisposable? disposable) - { - if (disposable is null) - { - return; - } - - try - { - disposable.Dispose(); - } - catch { } - } - - public static void CancelAndDisposeQuietly(CancellationTokenSource? cts) - { - if (cts is null) - { - return; - } - - try - { - cts.Cancel(); - } - catch { } - finally - { - DisposeQuietly(cts); - } - } - - public static void NotifyQuietly(IObserver? observer, T value) - { - if (observer is null) - { - return; - } - - try - { - observer.OnNext(value); - } - catch { } - } -} +using System; +using System.Threading; + +namespace Cocoar.Configuration.Utilities; + +public static class Safety +{ + public static void DisposeQuietly(IDisposable? disposable) + { + if (disposable is null) + { + return; + } + + try + { + disposable.Dispose(); + } + catch { } + } + + public static void CancelAndDisposeQuietly(CancellationTokenSource? cts) + { + if (cts is null) + { + return; + } + + try + { + cts.Cancel(); + } + catch { } + finally + { + DisposeQuietly(cts); + } + } + + public static void NotifyQuietly(IObserver? observer, T value) + { + if (observer is null) + { + return; + } + + try + { + observer.OnNext(value); + } + catch { } + } +} diff --git a/src/Cocoar.Configuration/Utilities/SecureBytes.cs b/src/Cocoar.Configuration/Utilities/SecureBytes.cs index 776d6ca..f1d2855 100644 --- a/src/Cocoar.Configuration/Utilities/SecureBytes.cs +++ b/src/Cocoar.Configuration/Utilities/SecureBytes.cs @@ -1,155 +1,155 @@ -using System; -using System.Buffers; -using System.Security.Cryptography; - -namespace Cocoar.Configuration.Utilities; - -/// -/// A small utility wrapper that owns a byte buffer and guarantees zeroization -/// when replaced or disposed. Intended for holding sensitive payloads in-memory -/// for a bounded time. Not thread-safe. -/// -internal sealed class SecureBytes : IDisposable -{ - private byte[] _buffer; - private int _length; - private bool _disposed; - private readonly bool _isPooled; - - private SecureBytes(int capacity, bool isPooled = false) - { - _isPooled = isPooled; - - if (isPooled) - { - _buffer = ArrayPool.Shared.Rent(capacity); - } - else - { - // Use pinned allocation to prevent buffer movement in memory - _buffer = GC.AllocateUninitializedArray(capacity, pinned: true); - } - - _length = 0; - if (!isPooled && capacity > 0) - { - GC.AddMemoryPressure(capacity); - } - } - - public static SecureBytes From(ReadOnlySpan data) - { - var s = new SecureBytes(data.Length, isPooled: false); - data.CopyTo(s._buffer); - s._length = data.Length; - return s; - } - - /// - /// Creates a SecureBytes instance using ArrayPool for better performance with large buffers. - /// Recommended for transient data that doesn't require pinned memory. - /// - public static SecureBytes FromPooled(ReadOnlySpan data) - { - var s = new SecureBytes(data.Length, isPooled: true); - data.CopyTo(s._buffer.AsSpan(0, data.Length)); - s._length = data.Length; - return s; - } - - /// - /// Replace the contents with . Zeroizes previous bytes - /// even when the capacity matches and we reuse the same array. - /// - public void Replace(ReadOnlySpan data) - { - ThrowIfDisposed(); - - if (_buffer.Length >= data.Length) - { - // Always zero the ENTIRE buffer, not just _length, to prevent residual data leaks - CryptographicOperations.ZeroMemory(_buffer); - data.CopyTo(_buffer); - _length = data.Length; - } - else - { - CryptographicOperations.ZeroMemory(_buffer); - - if (_isPooled) - { - ArrayPool.Shared.Return(_buffer); - _buffer = ArrayPool.Shared.Rent(data.Length); - } - else - { - if (_buffer.Length > 0) - { - GC.RemoveMemoryPressure(_buffer.Length); - } - _buffer = GC.AllocateUninitializedArray(data.Length, pinned: true); - if (data.Length > 0) - { - GC.AddMemoryPressure(data.Length); - } - } - - data.CopyTo(_buffer); - _length = data.Length; - } - } - - /// - /// Returns a read-only view of the current bytes. The view remains valid until - /// the next Replace or Dispose call. - /// - public ReadOnlyMemory AsReadOnlyMemory() - { - ThrowIfDisposed(); - return new ReadOnlyMemory(_buffer, 0, _length); - } - - /// - /// Zeroizes the current contents but keeps capacity. - /// - public void Clear() - { - ThrowIfDisposed(); - // Zero entire buffer for security - CryptographicOperations.ZeroMemory(_buffer); - _length = 0; - } - - private void ThrowIfDisposed() - { - ObjectDisposedException.ThrowIf(_disposed, this); - } - - public void Dispose() - { - if (_disposed) return; - try - { - // Use cryptographically secure zeroization - CryptographicOperations.ZeroMemory(_buffer); - - if (_isPooled) - { - ArrayPool.Shared.Return(_buffer); - } - else - { - if (_buffer.Length > 0) - { - GC.RemoveMemoryPressure(_buffer.Length); - } - } - } - catch { } - finally - { - _length = 0; - _disposed = true; - } - } -} +using System; +using System.Buffers; +using System.Security.Cryptography; + +namespace Cocoar.Configuration.Utilities; + +/// +/// A small utility wrapper that owns a byte buffer and guarantees zeroization +/// when replaced or disposed. Intended for holding sensitive payloads in-memory +/// for a bounded time. Not thread-safe. +/// +internal sealed class SecureBytes : IDisposable +{ + private byte[] _buffer; + private int _length; + private bool _disposed; + private readonly bool _isPooled; + + private SecureBytes(int capacity, bool isPooled = false) + { + _isPooled = isPooled; + + if (isPooled) + { + _buffer = ArrayPool.Shared.Rent(capacity); + } + else + { + // Use pinned allocation to prevent buffer movement in memory + _buffer = GC.AllocateUninitializedArray(capacity, pinned: true); + } + + _length = 0; + if (!isPooled && capacity > 0) + { + GC.AddMemoryPressure(capacity); + } + } + + public static SecureBytes From(ReadOnlySpan data) + { + var s = new SecureBytes(data.Length, isPooled: false); + data.CopyTo(s._buffer); + s._length = data.Length; + return s; + } + + /// + /// Creates a SecureBytes instance using ArrayPool for better performance with large buffers. + /// Recommended for transient data that doesn't require pinned memory. + /// + public static SecureBytes FromPooled(ReadOnlySpan data) + { + var s = new SecureBytes(data.Length, isPooled: true); + data.CopyTo(s._buffer.AsSpan(0, data.Length)); + s._length = data.Length; + return s; + } + + /// + /// Replace the contents with . Zeroizes previous bytes + /// even when the capacity matches and we reuse the same array. + /// + public void Replace(ReadOnlySpan data) + { + ThrowIfDisposed(); + + if (_buffer.Length >= data.Length) + { + // Always zero the ENTIRE buffer, not just _length, to prevent residual data leaks + CryptographicOperations.ZeroMemory(_buffer); + data.CopyTo(_buffer); + _length = data.Length; + } + else + { + CryptographicOperations.ZeroMemory(_buffer); + + if (_isPooled) + { + ArrayPool.Shared.Return(_buffer); + _buffer = ArrayPool.Shared.Rent(data.Length); + } + else + { + if (_buffer.Length > 0) + { + GC.RemoveMemoryPressure(_buffer.Length); + } + _buffer = GC.AllocateUninitializedArray(data.Length, pinned: true); + if (data.Length > 0) + { + GC.AddMemoryPressure(data.Length); + } + } + + data.CopyTo(_buffer); + _length = data.Length; + } + } + + /// + /// Returns a read-only view of the current bytes. The view remains valid until + /// the next Replace or Dispose call. + /// + public ReadOnlyMemory AsReadOnlyMemory() + { + ThrowIfDisposed(); + return new ReadOnlyMemory(_buffer, 0, _length); + } + + /// + /// Zeroizes the current contents but keeps capacity. + /// + public void Clear() + { + ThrowIfDisposed(); + // Zero entire buffer for security + CryptographicOperations.ZeroMemory(_buffer); + _length = 0; + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + public void Dispose() + { + if (_disposed) return; + try + { + // Use cryptographically secure zeroization + CryptographicOperations.ZeroMemory(_buffer); + + if (_isPooled) + { + ArrayPool.Shared.Return(_buffer); + } + else + { + if (_buffer.Length > 0) + { + GC.RemoveMemoryPressure(_buffer.Length); + } + } + } + catch { } + finally + { + _length = 0; + _disposed = true; + } + } +} diff --git a/src/Cocoar.Configuration/Utilities/StringToPrimitiveConverter.cs b/src/Cocoar.Configuration/Utilities/StringToPrimitiveConverter.cs index 8c08f69..518ab94 100644 --- a/src/Cocoar.Configuration/Utilities/StringToPrimitiveConverter.cs +++ b/src/Cocoar.Configuration/Utilities/StringToPrimitiveConverter.cs @@ -1,103 +1,103 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Cocoar.Configuration.Utilities; - -public class StringToPrimitiveConverter : JsonConverter -{ - public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.String) - { - var str = reader.GetString(); - if (str is null) - { - return default; - } - - var target = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert; - if (target == typeof(bool) && bool.TryParse(str, out var b)) - { - return (T?)(object)b; - } - - if (target == typeof(int) && int.TryParse(str, out var i)) - { - return (T?)(object)i; - } - - if (target == typeof(double) && double.TryParse(str, out var d)) - { - return (T?)(object)d; - } - - if (target == typeof(float) && float.TryParse(str, out var f)) - { - return (T?)(object)f; - } - - if (target == typeof(long) && long.TryParse(str, out var l)) - { - return (T?)(object)l; - } - - if (target == typeof(DateTime) && DateTime.TryParse(str, out var dt)) - { - return (T?)(object)dt; - } - - var converted = Convert.ChangeType(str, target, System.Globalization.CultureInfo.InvariantCulture); - return (T?)converted; - } - if (reader.TokenType is JsonTokenType.True or JsonTokenType.False) - { - var target = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert; - if (target == typeof(bool)) - { - return (T?)(object)reader.GetBoolean(); - } - } - if (reader.TokenType == JsonTokenType.Number) - { - var target = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert; - if (target == typeof(int)) - { - return (T?)(object)reader.GetInt32(); - } - - if (target == typeof(long)) - { - return (T?)(object)reader.GetInt64(); - } - - if (target == typeof(float)) - { - return (T?)(object)reader.GetSingle(); - } - - if (target == typeof(double)) - { - return (T?)(object)reader.GetDouble(); - } - - var dbl = reader.GetDouble(); - var converted = Convert.ChangeType(dbl, target, System.Globalization.CultureInfo.InvariantCulture); - return (T?)converted; - } - if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) - { - return JsonSerializer.Deserialize(ref reader, options); - } - if (reader.TokenType == JsonTokenType.Null) - { - return default; - } - - throw new JsonException($"Unsupported token type: {reader.TokenType}"); - } - - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) - { - JsonSerializer.Serialize(writer, value, options); - } -} +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.Utilities; + +public class StringToPrimitiveConverter : JsonConverter +{ + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var str = reader.GetString(); + if (str is null) + { + return default; + } + + var target = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert; + if (target == typeof(bool) && bool.TryParse(str, out var b)) + { + return (T?)(object)b; + } + + if (target == typeof(int) && int.TryParse(str, out var i)) + { + return (T?)(object)i; + } + + if (target == typeof(double) && double.TryParse(str, out var d)) + { + return (T?)(object)d; + } + + if (target == typeof(float) && float.TryParse(str, out var f)) + { + return (T?)(object)f; + } + + if (target == typeof(long) && long.TryParse(str, out var l)) + { + return (T?)(object)l; + } + + if (target == typeof(DateTime) && DateTime.TryParse(str, out var dt)) + { + return (T?)(object)dt; + } + + var converted = Convert.ChangeType(str, target, System.Globalization.CultureInfo.InvariantCulture); + return (T?)converted; + } + if (reader.TokenType is JsonTokenType.True or JsonTokenType.False) + { + var target = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert; + if (target == typeof(bool)) + { + return (T?)(object)reader.GetBoolean(); + } + } + if (reader.TokenType == JsonTokenType.Number) + { + var target = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert; + if (target == typeof(int)) + { + return (T?)(object)reader.GetInt32(); + } + + if (target == typeof(long)) + { + return (T?)(object)reader.GetInt64(); + } + + if (target == typeof(float)) + { + return (T?)(object)reader.GetSingle(); + } + + if (target == typeof(double)) + { + return (T?)(object)reader.GetDouble(); + } + + var dbl = reader.GetDouble(); + var converted = Convert.ChangeType(dbl, target, System.Globalization.CultureInfo.InvariantCulture); + return (T?)converted; + } + if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) + { + return JsonSerializer.Deserialize(ref reader, options); + } + if (reader.TokenType == JsonTokenType.Null) + { + return default; + } + + throw new JsonException($"Unsupported token type: {reader.TokenType}"); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, options); + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 61a855c..963e516 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,73 +1,73 @@ - - - - - net8.0;net9.0 - latest - enable - enable - true - $(NoWarn);CS1591 - false - - - - - true - true - portable - true - - - - - Bernhard Windisch - COCOAR e.U. - Cocoar.Configuration - Copyright © COCOAR $([System.DateTime]::Now.Year) - Apache-2.0 - false - https://github.com/cocoar-dev/cocoar.configuration - https://github.com/cocoar-dev/cocoar.configuration - git - README.md - Full release notes: https://github.com/cocoar-dev/cocoar.configuration/blob/develop/CHANGELOG.md - - package-icon.png - - - - - true - true - snupkg - - - - - true - latest-recommended - true - - - - - - - - - - - - - - - - - - - true - true - - - + + + + + net8.0;net9.0 + latest + enable + enable + true + $(NoWarn);CS1591 + false + + + + + true + true + portable + true + + + + + Bernhard Windisch + COCOAR e.U. + Cocoar.Configuration + Copyright © COCOAR $([System.DateTime]::Now.Year) + Apache-2.0 + false + https://github.com/cocoar-dev/cocoar.configuration + https://github.com/cocoar-dev/cocoar.configuration + git + README.md + Full release notes: https://github.com/cocoar-dev/cocoar.configuration/blob/develop/CHANGELOG.md + + package-icon.png + + + + + true + true + snupkg + + + + + true + latest-recommended + true + + + + + + + + + + + + + + + + + + + true + true + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 62f2af7..e244eec 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -1,46 +1,46 @@ - - - true - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - + + + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/Examples/AspNetCoreExample/AspNetCoreExample.csproj b/src/Examples/AspNetCoreExample/AspNetCoreExample.csproj index 4473d7d..402d68e 100644 --- a/src/Examples/AspNetCoreExample/AspNetCoreExample.csproj +++ b/src/Examples/AspNetCoreExample/AspNetCoreExample.csproj @@ -1,13 +1,13 @@ - - - net9.0 - enable - enable - true - Examples.AspNetCoreExample - - - - - + + + net9.0 + enable + enable + true + Examples.AspNetCoreExample + + + + + \ No newline at end of file diff --git a/src/Examples/AspNetCoreExample/Program.cs b/src/Examples/AspNetCoreExample/Program.cs index 1a719dd..836fe4b 100644 --- a/src/Examples/AspNetCoreExample/Program.cs +++ b/src/Examples/AspNetCoreExample/Program.cs @@ -1,73 +1,73 @@ -using Cocoar.Configuration.AspNetCore; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Providers; - -namespace Examples.AspNetCoreExample; - -public interface IAppSettings -{ - string ApplicationName { get; } - string Version { get; } - bool IsProduction { get; } -} - -public class AppSettings : IAppSettings -{ - public string ApplicationName { get; set; } = ""; - public string Version { get; set; } = "1.0.0"; - public bool IsProduction { get; set; } -} - -public class DatabaseSettings -{ - public string ConnectionString { get; set; } = ""; - public int CommandTimeout { get; set; } = 30; - public bool EnableRetryOnFailure { get; set; } = true; -} - -public static class Program -{ - public static void Main(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - builder.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromFile("config.json").Select("App"), - rule.For().FromEnvironment("APP_"), - rule.For().FromFile("config.json").Select("Database"), - rule.For().FromEnvironment("DB_") - ], setup => [ - setup.ConcreteType().ExposeAs(), - ])); - - var app = builder.Build(); - - app.MapGet("/config", (IAppSettings appSettings, DatabaseSettings? dbSettings) => new - { - Application = new { appSettings.ApplicationName, appSettings.Version, appSettings.IsProduction }, - Database = new { HasConnectionString = !string.IsNullOrEmpty(dbSettings?.ConnectionString), dbSettings?.CommandTimeout, dbSettings?.EnableRetryOnFailure } - }); - - app.MapGet("/health", (IAppSettings appSettings) => new - { - Status = "Healthy", - Application = appSettings.ApplicationName, - Version = appSettings.Version, - Environment = appSettings.IsProduction ? "Production" : "Development" - }); - - app.MapGet("/manager", (ConfigManager manager) => - { - var appSettings = manager.GetConfig(); - var dbSettings = manager.GetConfig(); - return new - { - RetrievedVia = "ConfigManager", - Application = new { appSettings.ApplicationName, appSettings.Version, appSettings.IsProduction }, - Database = new { HasConnectionString = !string.IsNullOrEmpty(dbSettings?.ConnectionString), dbSettings?.CommandTimeout, dbSettings?.EnableRetryOnFailure } - }; - }); - - app.Run(); - } -} +using Cocoar.Configuration.AspNetCore; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; + +namespace Examples.AspNetCoreExample; + +public interface IAppSettings +{ + string ApplicationName { get; } + string Version { get; } + bool IsProduction { get; } +} + +public class AppSettings : IAppSettings +{ + public string ApplicationName { get; set; } = ""; + public string Version { get; set; } = "1.0.0"; + public bool IsProduction { get; set; } +} + +public class DatabaseSettings +{ + public string ConnectionString { get; set; } = ""; + public int CommandTimeout { get; set; } = 30; + public bool EnableRetryOnFailure { get; set; } = true; +} + +public static class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + rule.For().FromFile("config.json").Select("App"), + rule.For().FromEnvironment("APP_"), + rule.For().FromFile("config.json").Select("Database"), + rule.For().FromEnvironment("DB_") + ], setup => [ + setup.ConcreteType().ExposeAs(), + ])); + + var app = builder.Build(); + + app.MapGet("/config", (IAppSettings appSettings, DatabaseSettings? dbSettings) => new + { + Application = new { appSettings.ApplicationName, appSettings.Version, appSettings.IsProduction }, + Database = new { HasConnectionString = !string.IsNullOrEmpty(dbSettings?.ConnectionString), dbSettings?.CommandTimeout, dbSettings?.EnableRetryOnFailure } + }); + + app.MapGet("/health", (IAppSettings appSettings) => new + { + Status = "Healthy", + Application = appSettings.ApplicationName, + Version = appSettings.Version, + Environment = appSettings.IsProduction ? "Production" : "Development" + }); + + app.MapGet("/manager", (ConfigManager manager) => + { + var appSettings = manager.GetConfig(); + var dbSettings = manager.GetConfig(); + return new + { + RetrievedVia = "ConfigManager", + Application = new { appSettings.ApplicationName, appSettings.Version, appSettings.IsProduction }, + Database = new { HasConnectionString = !string.IsNullOrEmpty(dbSettings?.ConnectionString), dbSettings?.CommandTimeout, dbSettings?.EnableRetryOnFailure } + }; + }); + + app.Run(); + } +} diff --git a/src/Examples/AspNetCoreExample/Properties/launchSettings.json b/src/Examples/AspNetCoreExample/Properties/launchSettings.json index 9f4eb06..c6b6fc1 100644 --- a/src/Examples/AspNetCoreExample/Properties/launchSettings.json +++ b/src/Examples/AspNetCoreExample/Properties/launchSettings.json @@ -1,12 +1,12 @@ -{ - "profiles": { - "AspNetCoreExample": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:59007;http://localhost:59008" - } - } +{ + "profiles": { + "AspNetCoreExample": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59007;http://localhost:59008" + } + } } \ No newline at end of file diff --git a/src/Examples/BasicUsage/BasicUsage.csproj b/src/Examples/BasicUsage/BasicUsage.csproj index f7a4ab1..fb246db 100644 --- a/src/Examples/BasicUsage/BasicUsage.csproj +++ b/src/Examples/BasicUsage/BasicUsage.csproj @@ -1,13 +1,13 @@ - - - net9.0 - enable - enable - true - Examples.BasicUsage - - - - - + + + net9.0 + enable + enable + true + Examples.BasicUsage + + + + + \ No newline at end of file diff --git a/src/Examples/BasicUsage/Program.cs b/src/Examples/BasicUsage/Program.cs index ca023b1..da5a396 100644 --- a/src/Examples/BasicUsage/Program.cs +++ b/src/Examples/BasicUsage/Program.cs @@ -1,50 +1,50 @@ -using Cocoar.Configuration.AspNetCore; -using Cocoar.Configuration.Providers; - -namespace Examples.BasicUsage; - -public interface IStartupSettings -{ - string ConnectionString { get; } - bool EnableLogging { get; } - int TimeoutSeconds { get; } -} - -public class StartUpConfiguration : IStartupSettings -{ - public string ConnectionString { get; set; } = ""; - public bool EnableLogging { get; set; } - public int TimeoutSeconds { get; set; } = 30; -} - -public class MartenStartupSettings -{ - public string DatabaseConnection { get; set; } = ""; - public bool EnableMigrations { get; set; } - public string Schema { get; set; } = "public"; -} - -public static class Program -{ - public static void Main(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - builder.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromFile("config.json").Select("StartUp"), - rule.For().FromFile("config.json").Select("Marten"), - rule.For().FromEnvironment(), - rule.For().FromEnvironment("MARTEN_") - ], setup => [ - setup.ConcreteType().ExposeAs() - ])); - - var app = builder.Build(); - - var startupConfig = app.Services.GetService(); - var martenConfig = app.Services.GetService(); - - Console.WriteLine($"Startup: {startupConfig?.ConnectionString} Logging: {startupConfig?.EnableLogging} Timeout: {startupConfig?.TimeoutSeconds}"); - Console.WriteLine($"Marten: {martenConfig?.DatabaseConnection} Migrations: {martenConfig?.EnableMigrations} Schema: {martenConfig?.Schema}"); - } -} +using Cocoar.Configuration.AspNetCore; +using Cocoar.Configuration.Providers; + +namespace Examples.BasicUsage; + +public interface IStartupSettings +{ + string ConnectionString { get; } + bool EnableLogging { get; } + int TimeoutSeconds { get; } +} + +public class StartUpConfiguration : IStartupSettings +{ + public string ConnectionString { get; set; } = ""; + public bool EnableLogging { get; set; } + public int TimeoutSeconds { get; set; } = 30; +} + +public class MartenStartupSettings +{ + public string DatabaseConnection { get; set; } = ""; + public bool EnableMigrations { get; set; } + public string Schema { get; set; } = "public"; +} + +public static class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + rule.For().FromFile("config.json").Select("StartUp"), + rule.For().FromFile("config.json").Select("Marten"), + rule.For().FromEnvironment(), + rule.For().FromEnvironment("MARTEN_") + ], setup => [ + setup.ConcreteType().ExposeAs() + ])); + + var app = builder.Build(); + + var startupConfig = app.Services.GetService(); + var martenConfig = app.Services.GetService(); + + Console.WriteLine($"Startup: {startupConfig?.ConnectionString} Logging: {startupConfig?.EnableLogging} Timeout: {startupConfig?.TimeoutSeconds}"); + Console.WriteLine($"Marten: {martenConfig?.DatabaseConnection} Migrations: {martenConfig?.EnableMigrations} Schema: {martenConfig?.Schema}"); + } +} diff --git a/src/Examples/BasicUsage/Properties/launchSettings.json b/src/Examples/BasicUsage/Properties/launchSettings.json index 4ec40cb..bd7b734 100644 --- a/src/Examples/BasicUsage/Properties/launchSettings.json +++ b/src/Examples/BasicUsage/Properties/launchSettings.json @@ -1,12 +1,12 @@ -{ - "profiles": { - "BasicUsage": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:59006;http://localhost:59009" - } - } +{ + "profiles": { + "BasicUsage": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59006;http://localhost:59009" + } + } } \ No newline at end of file diff --git a/src/Examples/CommandLineExample/CommandLineExample.csproj b/src/Examples/CommandLineExample/CommandLineExample.csproj index 4aa59ef..cbe1273 100644 --- a/src/Examples/CommandLineExample/CommandLineExample.csproj +++ b/src/Examples/CommandLineExample/CommandLineExample.csproj @@ -1,14 +1,14 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/src/Examples/CommandLineExample/Program.cs b/src/Examples/CommandLineExample/Program.cs index 0dbcf97..df52374 100644 --- a/src/Examples/CommandLineExample/Program.cs +++ b/src/Examples/CommandLineExample/Program.cs @@ -1,214 +1,214 @@ -using Cocoar.Configuration; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Providers; - -namespace CommandLineExample; - -public class Program -{ - public static void Main(string[] args) - { - Console.WriteLine("=== CommandLine Provider Test ===\n"); - Console.WriteLine($"Arguments: {string.Join(" ", args)}\n"); - - // Test 1: Default (--) prefix - TestDefault(args); - - // Test 2: Multiple prefixes - TestMultiplePrefixes(args); - - // Test 3: Semantic prefixes - TestSemanticPrefixes(args); - - // Test 4: Prefix filtering - TestPrefixFiltering(args); - - // Test 5: Nested configuration - TestNestedConfiguration(args); - } - - static void TestDefault(string[] args) - { - Console.WriteLine("--- Test 1: Default (--) prefix ---"); - try - { - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args }) - ])); - - var config = manager.GetConfig(); - Console.WriteLine($"Host: {config?.Host ?? "null"}"); - Console.WriteLine($"Port: {config?.Port ?? 0}"); - Console.WriteLine($"Verbose: {config?.Verbose ?? false}"); - } - catch (Exception ex) - { - Console.WriteLine($"ERROR: {ex.Message}"); - } - Console.WriteLine(); - } - - static void TestMultiplePrefixes(string[] args) - { - Console.WriteLine("--- Test 2: Multiple prefixes (--, -, /) ---"); - try - { - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromCommandLine(cm => new CommandLineRuleOptions - { - Args = args, - SwitchPrefixes = ["--", "-", "/"] - }) - ])); - - var config = manager.GetConfig(); - Console.WriteLine($"Host: {config?.Host ?? "null"}"); - Console.WriteLine($"Port: {config?.Port ?? 0}"); - Console.WriteLine($"Verbose: {config?.Verbose ?? false}"); - } - catch (Exception ex) - { - Console.WriteLine($"ERROR: {ex.Message}"); - } - Console.WriteLine(); - } - - static void TestSemanticPrefixes(string[] args) - { - Console.WriteLine("--- Test 3: Semantic prefixes (@, #, %) ---"); - try - { - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromCommandLine(cm => new CommandLineRuleOptions - { - Args = args, - SwitchPrefixes = ["@"] - }), - rule.For().FromCommandLine(cm => new CommandLineRuleOptions - { - Args = args, - SwitchPrefixes = ["#"] - }), - rule.For().FromCommandLine(cm => new CommandLineRuleOptions - { - Args = args, - SwitchPrefixes = ["%"] - }) - ])); - - var target = manager.GetConfig(); - var issue = manager.GetConfig(); - var env = manager.GetConfig(); - - Console.WriteLine($"Target Host: {target?.Host ?? "null"}"); - Console.WriteLine($"Issue ID: {issue?.Id ?? 0}"); - Console.WriteLine($"Environment: {env?.Name ?? "null"}"); - } - catch (Exception ex) - { - Console.WriteLine($"ERROR: {ex.Message}"); - } - Console.WriteLine(); - } - - static void TestPrefixFiltering(string[] args) - { - Console.WriteLine("--- Test 4: Prefix filtering (app_, db_) ---"); - try - { - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromCommandLine(cm => new CommandLineRuleOptions - { - Args = args, - Prefix = "app_" - }), - rule.For().FromCommandLine(cm => new CommandLineRuleOptions - { - Args = args, - Prefix = "db_" - }) - ])); - - var app = manager.GetConfig(); - var db = manager.GetConfig(); - - Console.WriteLine($"App Host: {app?.Host ?? "null"}"); - Console.WriteLine($"App Port: {app?.Port ?? 0}"); - Console.WriteLine($"DB ConnectionString: {db?.ConnectionString ?? "null"}"); - } - catch (Exception ex) - { - Console.WriteLine($"ERROR: {ex.Message}"); - } - Console.WriteLine(); - } - - static void TestNestedConfiguration(string[] args) - { - Console.WriteLine("--- Test 5: Nested configuration (: and __) ---"); - try - { - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args }) - ])); - - var config = manager.GetConfig(); - Console.WriteLine($"Database Host: {config?.Database?.Host ?? "null"}"); - Console.WriteLine($"Database Port: {config?.Database?.Port ?? 0}"); - Console.WriteLine($"Cache Host: {config?.Cache?.Host ?? "null"}"); - Console.WriteLine($"Cache Ttl: {config?.Cache?.Ttl ?? 0}"); - } - catch (Exception ex) - { - Console.WriteLine($"ERROR: {ex.Message}"); - } - Console.WriteLine(); - } -} - -// Config classes -public class AppConfig -{ - public string? Host { get; set; } - public int Port { get; set; } - public bool Verbose { get; set; } -} - -public class TargetConfig -{ - public string? Host { get; set; } -} - -public class IssueConfig -{ - public int Id { get; set; } -} - -public class EnvConfig -{ - public string? Name { get; set; } -} - -public class DatabaseConfig -{ - public string? ConnectionString { get; set; } -} - -public class ServerConfig -{ - public DatabaseSettings? Database { get; set; } - public CacheSettings? Cache { get; set; } -} - -public class DatabaseSettings -{ - public string? Host { get; set; } - public int Port { get; set; } -} - -public class CacheSettings -{ - public string? Host { get; set; } - public int Ttl { get; set; } -} - +using Cocoar.Configuration; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; + +namespace CommandLineExample; + +public class Program +{ + public static void Main(string[] args) + { + Console.WriteLine("=== CommandLine Provider Test ===\n"); + Console.WriteLine($"Arguments: {string.Join(" ", args)}\n"); + + // Test 1: Default (--) prefix + TestDefault(args); + + // Test 2: Multiple prefixes + TestMultiplePrefixes(args); + + // Test 3: Semantic prefixes + TestSemanticPrefixes(args); + + // Test 4: Prefix filtering + TestPrefixFiltering(args); + + // Test 5: Nested configuration + TestNestedConfiguration(args); + } + + static void TestDefault(string[] args) + { + Console.WriteLine("--- Test 1: Default (--) prefix ---"); + try + { + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args }) + ])); + + var config = manager.GetConfig(); + Console.WriteLine($"Host: {config?.Host ?? "null"}"); + Console.WriteLine($"Port: {config?.Port ?? 0}"); + Console.WriteLine($"Verbose: {config?.Verbose ?? false}"); + } + catch (Exception ex) + { + Console.WriteLine($"ERROR: {ex.Message}"); + } + Console.WriteLine(); + } + + static void TestMultiplePrefixes(string[] args) + { + Console.WriteLine("--- Test 2: Multiple prefixes (--, -, /) ---"); + try + { + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromCommandLine(cm => new CommandLineRuleOptions + { + Args = args, + SwitchPrefixes = ["--", "-", "/"] + }) + ])); + + var config = manager.GetConfig(); + Console.WriteLine($"Host: {config?.Host ?? "null"}"); + Console.WriteLine($"Port: {config?.Port ?? 0}"); + Console.WriteLine($"Verbose: {config?.Verbose ?? false}"); + } + catch (Exception ex) + { + Console.WriteLine($"ERROR: {ex.Message}"); + } + Console.WriteLine(); + } + + static void TestSemanticPrefixes(string[] args) + { + Console.WriteLine("--- Test 3: Semantic prefixes (@, #, %) ---"); + try + { + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromCommandLine(cm => new CommandLineRuleOptions + { + Args = args, + SwitchPrefixes = ["@"] + }), + rule.For().FromCommandLine(cm => new CommandLineRuleOptions + { + Args = args, + SwitchPrefixes = ["#"] + }), + rule.For().FromCommandLine(cm => new CommandLineRuleOptions + { + Args = args, + SwitchPrefixes = ["%"] + }) + ])); + + var target = manager.GetConfig(); + var issue = manager.GetConfig(); + var env = manager.GetConfig(); + + Console.WriteLine($"Target Host: {target?.Host ?? "null"}"); + Console.WriteLine($"Issue ID: {issue?.Id ?? 0}"); + Console.WriteLine($"Environment: {env?.Name ?? "null"}"); + } + catch (Exception ex) + { + Console.WriteLine($"ERROR: {ex.Message}"); + } + Console.WriteLine(); + } + + static void TestPrefixFiltering(string[] args) + { + Console.WriteLine("--- Test 4: Prefix filtering (app_, db_) ---"); + try + { + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromCommandLine(cm => new CommandLineRuleOptions + { + Args = args, + Prefix = "app_" + }), + rule.For().FromCommandLine(cm => new CommandLineRuleOptions + { + Args = args, + Prefix = "db_" + }) + ])); + + var app = manager.GetConfig(); + var db = manager.GetConfig(); + + Console.WriteLine($"App Host: {app?.Host ?? "null"}"); + Console.WriteLine($"App Port: {app?.Port ?? 0}"); + Console.WriteLine($"DB ConnectionString: {db?.ConnectionString ?? "null"}"); + } + catch (Exception ex) + { + Console.WriteLine($"ERROR: {ex.Message}"); + } + Console.WriteLine(); + } + + static void TestNestedConfiguration(string[] args) + { + Console.WriteLine("--- Test 5: Nested configuration (: and __) ---"); + try + { + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args }) + ])); + + var config = manager.GetConfig(); + Console.WriteLine($"Database Host: {config?.Database?.Host ?? "null"}"); + Console.WriteLine($"Database Port: {config?.Database?.Port ?? 0}"); + Console.WriteLine($"Cache Host: {config?.Cache?.Host ?? "null"}"); + Console.WriteLine($"Cache Ttl: {config?.Cache?.Ttl ?? 0}"); + } + catch (Exception ex) + { + Console.WriteLine($"ERROR: {ex.Message}"); + } + Console.WriteLine(); + } +} + +// Config classes +public class AppConfig +{ + public string? Host { get; set; } + public int Port { get; set; } + public bool Verbose { get; set; } +} + +public class TargetConfig +{ + public string? Host { get; set; } +} + +public class IssueConfig +{ + public int Id { get; set; } +} + +public class EnvConfig +{ + public string? Name { get; set; } +} + +public class DatabaseConfig +{ + public string? ConnectionString { get; set; } +} + +public class ServerConfig +{ + public DatabaseSettings? Database { get; set; } + public CacheSettings? Cache { get; set; } +} + +public class DatabaseSettings +{ + public string? Host { get; set; } + public int Port { get; set; } +} + +public class CacheSettings +{ + public string? Host { get; set; } + public int Ttl { get; set; } +} + diff --git a/src/Examples/CommandLineExample/test-commandline.ps1 b/src/Examples/CommandLineExample/test-commandline.ps1 index 869a4fa..f09f31a 100644 --- a/src/Examples/CommandLineExample/test-commandline.ps1 +++ b/src/Examples/CommandLineExample/test-commandline.ps1 @@ -1,24 +1,24 @@ -# CommandLine Provider Test Script - -Write-Host "=== Building CommandLineExample ===" -ForegroundColor Cyan -dotnet build - -Write-Host "`n=== Test 1: Default (--) prefix ===" -ForegroundColor Yellow -dotnet run -- --host=localhost --port=8080 --verbose - -Write-Host "`n=== Test 2: Multiple prefixes (mixed) ===" -ForegroundColor Yellow -dotnet run -- --host=server1 -port=9000 /verbose - -Write-Host "`n=== Test 3: Semantic prefixes ===" -ForegroundColor Yellow -dotnet run -- '@host=10.10.10.10' '#id=123' '%name=production' - -Write-Host "`n=== Test 4: Prefix filtering ===" -ForegroundColor Yellow -dotnet run -- --app_host=appserver --app_port=3000 --db_connectionstring="Server=dbserver" - -Write-Host "`n=== Test 5: Nested configuration (colon syntax) ===" -ForegroundColor Yellow -dotnet run -- --database:host=localhost --database:port=5432 --cache:host=redis --cache:ttl=300 - -Write-Host "`n=== Test 6: Nested configuration (double underscore syntax) ===" -ForegroundColor Yellow -dotnet run -- --database__host=localhost --database__port=5432 --cache__host=redis --cache__ttl=300 - -Write-Host "`n=== All tests complete! ===" -ForegroundColor Green +# CommandLine Provider Test Script + +Write-Host "=== Building CommandLineExample ===" -ForegroundColor Cyan +dotnet build + +Write-Host "`n=== Test 1: Default (--) prefix ===" -ForegroundColor Yellow +dotnet run -- --host=localhost --port=8080 --verbose + +Write-Host "`n=== Test 2: Multiple prefixes (mixed) ===" -ForegroundColor Yellow +dotnet run -- --host=server1 -port=9000 /verbose + +Write-Host "`n=== Test 3: Semantic prefixes ===" -ForegroundColor Yellow +dotnet run -- '@host=10.10.10.10' '#id=123' '%name=production' + +Write-Host "`n=== Test 4: Prefix filtering ===" -ForegroundColor Yellow +dotnet run -- --app_host=appserver --app_port=3000 --db_connectionstring="Server=dbserver" + +Write-Host "`n=== Test 5: Nested configuration (colon syntax) ===" -ForegroundColor Yellow +dotnet run -- --database:host=localhost --database:port=5432 --cache:host=redis --cache:ttl=300 + +Write-Host "`n=== Test 6: Nested configuration (double underscore syntax) ===" -ForegroundColor Yellow +dotnet run -- --database__host=localhost --database__port=5432 --cache__host=redis --cache__ttl=300 + +Write-Host "`n=== All tests complete! ===" -ForegroundColor Green diff --git a/src/Examples/ConditionalRulesExample/ConditionalRulesExample.csproj b/src/Examples/ConditionalRulesExample/ConditionalRulesExample.csproj index c47a7dc..5749ac1 100644 --- a/src/Examples/ConditionalRulesExample/ConditionalRulesExample.csproj +++ b/src/Examples/ConditionalRulesExample/ConditionalRulesExample.csproj @@ -1,15 +1,15 @@ - - - - Exe - net9.0 - enable - enable - $(NoWarn);NU5104;CS8618 - - - - - - - + + + + Exe + net9.0 + enable + enable + $(NoWarn);NU5104;CS8618 + + + + + + + diff --git a/src/Examples/ConditionalRulesExample/Program.cs b/src/Examples/ConditionalRulesExample/Program.cs index 9d34eb9..c102d85 100644 --- a/src/Examples/ConditionalRulesExample/Program.cs +++ b/src/Examples/ConditionalRulesExample/Program.cs @@ -1,60 +1,60 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Providers; -using ConditionalRulesExample; - -Console.WriteLine("=== Conditional Rules Example ===\n"); -Console.WriteLine("Demonstrates using When() with IConfigurationAccessor\n"); - -// Setup: Load tenant configuration, then conditionally load premium features -var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - // Load tenant info first - rule.For().FromStaticJson(""" - { - "TenantId": "acme-corp", - "Tier": "Premium" - } - """), - - // Conditionally load premium features only for Premium tier tenants - rule.For().FromStaticJson(""" - { - "AdvancedAnalytics": true, - "PrioritySupport": true - } - """) - .When(accessor => - { - var tenant = accessor.GetConfig()!; - return tenant.Tier == "Premium"; - }) -])); - -var tenant = manager.GetConfig()!; -var features = manager.GetConfig(); - -Console.WriteLine($"Tenant: {tenant.TenantId}"); -Console.WriteLine($"Tier: {tenant.Tier}"); -Console.WriteLine($"Premium Features: {(features != null ? "Enabled ✓" : "Not Available")}"); - -if (features != null) -{ - Console.WriteLine($" - Advanced Analytics: {features.AdvancedAnalytics}"); - Console.WriteLine($" - Priority Support: {features.PrioritySupport}"); -} - -Console.WriteLine("\n=== Example Complete ==="); - -namespace ConditionalRulesExample -{ // Configuration classes - public record TenantSettings - { - public string TenantId { get; set; } = string.Empty; - public string Tier { get; set; } = string.Empty; - } - - public record PremiumFeatures - { - public bool AdvancedAnalytics { get; set; } - public bool PrioritySupport { get; set; } - } -} +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; +using ConditionalRulesExample; + +Console.WriteLine("=== Conditional Rules Example ===\n"); +Console.WriteLine("Demonstrates using When() with IConfigurationAccessor\n"); + +// Setup: Load tenant configuration, then conditionally load premium features +var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + // Load tenant info first + rule.For().FromStaticJson(""" + { + "TenantId": "acme-corp", + "Tier": "Premium" + } + """), + + // Conditionally load premium features only for Premium tier tenants + rule.For().FromStaticJson(""" + { + "AdvancedAnalytics": true, + "PrioritySupport": true + } + """) + .When(accessor => + { + var tenant = accessor.GetConfig()!; + return tenant.Tier == "Premium"; + }) +])); + +var tenant = manager.GetConfig()!; +var features = manager.GetConfig(); + +Console.WriteLine($"Tenant: {tenant.TenantId}"); +Console.WriteLine($"Tier: {tenant.Tier}"); +Console.WriteLine($"Premium Features: {(features != null ? "Enabled ✓" : "Not Available")}"); + +if (features != null) +{ + Console.WriteLine($" - Advanced Analytics: {features.AdvancedAnalytics}"); + Console.WriteLine($" - Priority Support: {features.PrioritySupport}"); +} + +Console.WriteLine("\n=== Example Complete ==="); + +namespace ConditionalRulesExample +{ // Configuration classes + public record TenantSettings + { + public string TenantId { get; set; } = string.Empty; + public string Tier { get; set; } = string.Empty; + } + + public record PremiumFeatures + { + public bool AdvancedAnalytics { get; set; } + public bool PrioritySupport { get; set; } + } +} diff --git a/src/Examples/Directory.Build.props b/src/Examples/Directory.Build.props index c40f5f5..a0cbc60 100644 --- a/src/Examples/Directory.Build.props +++ b/src/Examples/Directory.Build.props @@ -1,13 +1,13 @@ - - - - - - - false - - - - $(NoWarn);CA1707;CA1816;CA1822;CA1852;CA1310;CA1305;CA1861;CA1836;CA1805;CA1001;CA1869;CA1845;CA1847;CA1850;CA1848;CA1510;CA1513;CA1860;CA1862;CA1854;CA1859;CA1826;CA2201;CS0618;CS8604;CS8625;CS1570;SYSLIB0057;SYSLIB0027 - + + + + + + + false + + + + $(NoWarn);CA1707;CA1816;CA1822;CA1852;CA1310;CA1305;CA1861;CA1836;CA1805;CA1001;CA1869;CA1845;CA1847;CA1850;CA1848;CA1510;CA1513;CA1860;CA1862;CA1854;CA1859;CA1826;CA2201;CS0618;CS8604;CS8625;CS1570;SYSLIB0057;SYSLIB0027 + \ No newline at end of file diff --git a/src/Examples/DynamicDependencies/DynamicDependencies.csproj b/src/Examples/DynamicDependencies/DynamicDependencies.csproj index feb0920..62b42d8 100644 --- a/src/Examples/DynamicDependencies/DynamicDependencies.csproj +++ b/src/Examples/DynamicDependencies/DynamicDependencies.csproj @@ -1,15 +1,15 @@ - - - net9.0 - enable - enable - true - Examples.DynamicDependencies - - - - - - - + + + net9.0 + enable + enable + true + Examples.DynamicDependencies + + + + + + + \ No newline at end of file diff --git a/src/Examples/DynamicDependencies/Program.cs b/src/Examples/DynamicDependencies/Program.cs index 2ef7578..d522e0b 100644 --- a/src/Examples/DynamicDependencies/Program.cs +++ b/src/Examples/DynamicDependencies/Program.cs @@ -1,78 +1,78 @@ -using Cocoar.Configuration.DI; -using Cocoar.Configuration.Providers; -using Microsoft.Extensions.DependencyInjection; - -namespace Examples.DynamicDependencies; - -public class ApiSettings -{ - public string BaseUrl { get; set; } = ""; - public string ApiKey { get; set; } = ""; -} - -public class FeatureFlags -{ - public bool EnableNewDashboard { get; set; } - public bool EnableBetaFeatures { get; set; } - public string Theme { get; set; } = "default"; -} - -public class RegionSettings -{ - public string Region { get; set; } = ""; - public string DataCenter { get; set; } = ""; -} - -public class RegionSpecificConfig -{ - public string DatabaseEndpoint { get; set; } = ""; - public string CdnUrl { get; set; } = ""; - public string[] AvailableLanguages { get; set; } = []; -} - -public static class Program -{ - public static void Main(string[] args) - { - var services = new ServiceCollection(); - - services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config.json")).Select("Api") - .Required(), - - rule.For().FromStatic(configManager => - { - var apiSettings = configManager.GetConfig()!; - if (apiSettings.BaseUrl.Contains("staging")) - { - return new FeatureFlags { EnableNewDashboard = true, EnableBetaFeatures = true, Theme = "staging" }; - } - return new FeatureFlags { EnableNewDashboard = false, EnableBetaFeatures = false, Theme = "production" }; - }), - - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config.json")).Select("Region") - .Required(), - - rule.For().FromStatic(configManager => - { - var regionSettings = configManager.GetConfig()!; - return regionSettings.Region switch - { - "us-west-2" => new RegionSpecificConfig { DatabaseEndpoint = "db-oregon.example.com", CdnUrl = "https://cdn-us-west.example.com", AvailableLanguages = new[] { "en", "es" } }, - "eu-central-1" => new RegionSpecificConfig { DatabaseEndpoint = "db-frankfurt.example.com", CdnUrl = "https://cdn-eu-central.example.com", AvailableLanguages = new[] { "en", "de", "fr" } }, - _ => new RegionSpecificConfig { DatabaseEndpoint = "db-global.example.com", CdnUrl = "https://cdn-global.example.com", AvailableLanguages = new[] { "en" } } - }; - }) - - ])); - - var serviceProvider = services.BuildServiceProvider(); - - var apiSettings = serviceProvider.GetRequiredService(); - var featureFlags = serviceProvider.GetService(); - var regionConfig = serviceProvider.GetService(); - - Console.WriteLine($"API: {apiSettings.BaseUrl} Flags theme: {featureFlags?.Theme} Region DB: {regionConfig?.DatabaseEndpoint}"); - } -} +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace Examples.DynamicDependencies; + +public class ApiSettings +{ + public string BaseUrl { get; set; } = ""; + public string ApiKey { get; set; } = ""; +} + +public class FeatureFlags +{ + public bool EnableNewDashboard { get; set; } + public bool EnableBetaFeatures { get; set; } + public string Theme { get; set; } = "default"; +} + +public class RegionSettings +{ + public string Region { get; set; } = ""; + public string DataCenter { get; set; } = ""; +} + +public class RegionSpecificConfig +{ + public string DatabaseEndpoint { get; set; } = ""; + public string CdnUrl { get; set; } = ""; + public string[] AvailableLanguages { get; set; } = []; +} + +public static class Program +{ + public static void Main(string[] args) + { + var services = new ServiceCollection(); + + services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config.json")).Select("Api") + .Required(), + + rule.For().FromStatic(configManager => + { + var apiSettings = configManager.GetConfig()!; + if (apiSettings.BaseUrl.Contains("staging")) + { + return new FeatureFlags { EnableNewDashboard = true, EnableBetaFeatures = true, Theme = "staging" }; + } + return new FeatureFlags { EnableNewDashboard = false, EnableBetaFeatures = false, Theme = "production" }; + }), + + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config.json")).Select("Region") + .Required(), + + rule.For().FromStatic(configManager => + { + var regionSettings = configManager.GetConfig()!; + return regionSettings.Region switch + { + "us-west-2" => new RegionSpecificConfig { DatabaseEndpoint = "db-oregon.example.com", CdnUrl = "https://cdn-us-west.example.com", AvailableLanguages = new[] { "en", "es" } }, + "eu-central-1" => new RegionSpecificConfig { DatabaseEndpoint = "db-frankfurt.example.com", CdnUrl = "https://cdn-eu-central.example.com", AvailableLanguages = new[] { "en", "de", "fr" } }, + _ => new RegionSpecificConfig { DatabaseEndpoint = "db-global.example.com", CdnUrl = "https://cdn-global.example.com", AvailableLanguages = new[] { "en" } } + }; + }) + + ])); + + var serviceProvider = services.BuildServiceProvider(); + + var apiSettings = serviceProvider.GetRequiredService(); + var featureFlags = serviceProvider.GetService(); + var regionConfig = serviceProvider.GetService(); + + Console.WriteLine($"API: {apiSettings.BaseUrl} Flags theme: {featureFlags?.Theme} Region DB: {regionConfig?.DatabaseEndpoint}"); + } +} diff --git a/src/Examples/ExposeExample/ExposeExample.csproj b/src/Examples/ExposeExample/ExposeExample.csproj index c4286b4..838b7d2 100644 --- a/src/Examples/ExposeExample/ExposeExample.csproj +++ b/src/Examples/ExposeExample/ExposeExample.csproj @@ -1,21 +1,21 @@ - - - - Exe - net9.0 - enable - true - - - - - - - - - - PreserveNewest - - - + + + + Exe + net9.0 + enable + true + + + + + + + + + + PreserveNewest + + + \ No newline at end of file diff --git a/src/Examples/ExposeExample/Program.cs b/src/Examples/ExposeExample/Program.cs index 111ccea..66738ab 100644 --- a/src/Examples/ExposeExample/Program.cs +++ b/src/Examples/ExposeExample/Program.cs @@ -1,151 +1,151 @@ -using System; -using Cocoar.Configuration; -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; - -namespace ExposeExample; - -// ========================= INTERFACES ========================= - -public interface IPaymentConfig -{ - string MerchantId { get; } - string ApiKey { get; } - decimal MaxTransactionAmount { get; } - bool EnableRefunds { get; } -} - -public interface IFeatureToggles -{ - bool EnableNewDashboard { get; } - bool EnableExperimentalFeatures { get; } - int MaxConcurrentUsers { get; } -} - -public interface IReadOnlyFeatureToggles -{ - bool EnableNewDashboard { get; } - bool EnableExperimentalFeatures { get; } -} - -// ========================= CONCRETE TYPES ========================= - -public class PaymentConfig : IPaymentConfig -{ - public string MerchantId { get; set; } = ""; - public string ApiKey { get; set; } = ""; - public decimal MaxTransactionAmount { get; set; } - public bool EnableRefunds { get; set; } = true; - - // Additional properties not in interface - public string InternalNotes { get; set; } = ""; -} - -public class FeatureToggleConfig : IFeatureToggles, IReadOnlyFeatureToggles -{ - public bool EnableNewDashboard { get; set; } - public bool EnableExperimentalFeatures { get; set; } - public int MaxConcurrentUsers { get; set; } = 50; - - // Additional properties not exposed through interfaces - public bool CacheEnabled { get; set; } = true; - public string ConfigVersion { get; set; } = "1.0"; -} - -public class DatabaseConfig -{ - public string ConnectionString { get; set; } = ""; - public int CommandTimeout { get; set; } = 30; - public bool EnableLogging { get; set; } = true; -} - -public static class Program -{ - public static void Main(string[] args) - { - Console.WriteLine("=== Cocoar.Configuration Exposure Example ==="); - Console.WriteLine("(Demonstrating interface exposure without DI)"); - Console.WriteLine(); - - try - { - var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/payment.json")), - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/features.json")), - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/database.json")) - ], setup => [ - setup.ConcreteType().ExposeAs(), - setup.ConcreteType().ExposeAs().ExposeAs() - ])); - - Console.WriteLine("📋 ConfigManager initialized successfully!"); - Console.WriteLine(); - - Console.WriteLine("🔧 Testing concrete type access:"); - - var paymentConcrete = manager.GetConfig(); - Console.WriteLine($" PaymentConfig.MerchantId: {paymentConcrete?.MerchantId}"); - Console.WriteLine($" PaymentConfig.InternalNotes: {paymentConcrete?.InternalNotes}"); - - var featureConcrete = manager.GetConfig(); - Console.WriteLine($" FeatureToggleConfig.CacheEnabled: {featureConcrete?.CacheEnabled}"); - Console.WriteLine(); - - Console.WriteLine("🎭 Testing interface access through exposures:"); - - var paymentInterface = manager.GetConfig(); - Console.WriteLine($" IPaymentConfig.MerchantId: {paymentInterface?.MerchantId}"); - Console.WriteLine($" IPaymentConfig.EnableRefunds: {paymentInterface?.EnableRefunds}"); - - var featureInterface = manager.GetConfig(); - Console.WriteLine($" IFeatureToggles.EnableNewDashboard: {featureInterface?.EnableNewDashboard}"); - Console.WriteLine($" IFeatureToggles.MaxConcurrentUsers: {featureInterface?.MaxConcurrentUsers}"); - - var readOnlyInterface = manager.GetConfig(); - Console.WriteLine($" IReadOnlyFeatureToggles.EnableExperimentalFeatures: {readOnlyInterface?.EnableExperimentalFeatures}"); - Console.WriteLine(); - - // 6. Test type not in bindings (should return null for interface) - Console.WriteLine("❌ Testing interface without exposure (should be null):"); - - var dbInterface = manager.GetConfig(); - Console.WriteLine($" IPaymentConfig (exposed): {(dbInterface != null ? "✓ Found" : "✗ Not found")}"); - - // Hypothetical interface not defined in bindings would return null - Console.WriteLine(" (Hypothetical non-exposed interface would return null)"); - Console.WriteLine(); - - // 7. Demonstrate fallback behavior - Console.WriteLine("🔄 Exposure resolution process:"); - Console.WriteLine(" 1. GetConfig() called"); - Console.WriteLine(" 2. Direct lookup for IPaymentConfig: ✗ Not found"); - Console.WriteLine(" 3. Check exposure registry: ✓ Found mapping IPaymentConfig → PaymentConfig"); - Console.WriteLine(" 4. Lookup PaymentConfig: ✓ Found configuration"); - Console.WriteLine(" 5. Deserialize to PaymentConfig, cast to IPaymentConfig: ✓ Success"); - Console.WriteLine(); - - // 8. Show API patterns - Console.WriteLine("📖 Key Exposure Patterns:"); - Console.WriteLine(" ✓ Setup.ConcreteType().ExposeAs().Build()"); - Console.WriteLine(" ✓ ConfigManager.Create(c => c.UseConfiguration(rules, setup))"); - Console.WriteLine(" ✓ manager.GetConfig() // resolves via exposure"); - Console.WriteLine(" ✓ manager.GetConfig() // direct access"); - Console.WriteLine(" ✓ Multiple interfaces per concrete type"); - Console.WriteLine(" ✓ Runtime validation of interface implementation"); - Console.WriteLine(); - - Console.WriteLine("🎯 Exposures enable clean interface-based access"); - Console.WriteLine(" without coupling the core library to DI frameworks!"); - - } - catch (Exception ex) - { - Console.WriteLine($"❌ Error: {ex.Message}"); - if (ex.InnerException != null) - Console.WriteLine($" Inner: {ex.InnerException.Message}"); - } - } -} - +using System; +using Cocoar.Configuration; +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; + +namespace ExposeExample; + +// ========================= INTERFACES ========================= + +public interface IPaymentConfig +{ + string MerchantId { get; } + string ApiKey { get; } + decimal MaxTransactionAmount { get; } + bool EnableRefunds { get; } +} + +public interface IFeatureToggles +{ + bool EnableNewDashboard { get; } + bool EnableExperimentalFeatures { get; } + int MaxConcurrentUsers { get; } +} + +public interface IReadOnlyFeatureToggles +{ + bool EnableNewDashboard { get; } + bool EnableExperimentalFeatures { get; } +} + +// ========================= CONCRETE TYPES ========================= + +public class PaymentConfig : IPaymentConfig +{ + public string MerchantId { get; set; } = ""; + public string ApiKey { get; set; } = ""; + public decimal MaxTransactionAmount { get; set; } + public bool EnableRefunds { get; set; } = true; + + // Additional properties not in interface + public string InternalNotes { get; set; } = ""; +} + +public class FeatureToggleConfig : IFeatureToggles, IReadOnlyFeatureToggles +{ + public bool EnableNewDashboard { get; set; } + public bool EnableExperimentalFeatures { get; set; } + public int MaxConcurrentUsers { get; set; } = 50; + + // Additional properties not exposed through interfaces + public bool CacheEnabled { get; set; } = true; + public string ConfigVersion { get; set; } = "1.0"; +} + +public class DatabaseConfig +{ + public string ConnectionString { get; set; } = ""; + public int CommandTimeout { get; set; } = 30; + public bool EnableLogging { get; set; } = true; +} + +public static class Program +{ + public static void Main(string[] args) + { + Console.WriteLine("=== Cocoar.Configuration Exposure Example ==="); + Console.WriteLine("(Demonstrating interface exposure without DI)"); + Console.WriteLine(); + + try + { + var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/payment.json")), + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/features.json")), + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/database.json")) + ], setup => [ + setup.ConcreteType().ExposeAs(), + setup.ConcreteType().ExposeAs().ExposeAs() + ])); + + Console.WriteLine("📋 ConfigManager initialized successfully!"); + Console.WriteLine(); + + Console.WriteLine("🔧 Testing concrete type access:"); + + var paymentConcrete = manager.GetConfig(); + Console.WriteLine($" PaymentConfig.MerchantId: {paymentConcrete?.MerchantId}"); + Console.WriteLine($" PaymentConfig.InternalNotes: {paymentConcrete?.InternalNotes}"); + + var featureConcrete = manager.GetConfig(); + Console.WriteLine($" FeatureToggleConfig.CacheEnabled: {featureConcrete?.CacheEnabled}"); + Console.WriteLine(); + + Console.WriteLine("🎭 Testing interface access through exposures:"); + + var paymentInterface = manager.GetConfig(); + Console.WriteLine($" IPaymentConfig.MerchantId: {paymentInterface?.MerchantId}"); + Console.WriteLine($" IPaymentConfig.EnableRefunds: {paymentInterface?.EnableRefunds}"); + + var featureInterface = manager.GetConfig(); + Console.WriteLine($" IFeatureToggles.EnableNewDashboard: {featureInterface?.EnableNewDashboard}"); + Console.WriteLine($" IFeatureToggles.MaxConcurrentUsers: {featureInterface?.MaxConcurrentUsers}"); + + var readOnlyInterface = manager.GetConfig(); + Console.WriteLine($" IReadOnlyFeatureToggles.EnableExperimentalFeatures: {readOnlyInterface?.EnableExperimentalFeatures}"); + Console.WriteLine(); + + // 6. Test type not in bindings (should return null for interface) + Console.WriteLine("❌ Testing interface without exposure (should be null):"); + + var dbInterface = manager.GetConfig(); + Console.WriteLine($" IPaymentConfig (exposed): {(dbInterface != null ? "✓ Found" : "✗ Not found")}"); + + // Hypothetical interface not defined in bindings would return null + Console.WriteLine(" (Hypothetical non-exposed interface would return null)"); + Console.WriteLine(); + + // 7. Demonstrate fallback behavior + Console.WriteLine("🔄 Exposure resolution process:"); + Console.WriteLine(" 1. GetConfig() called"); + Console.WriteLine(" 2. Direct lookup for IPaymentConfig: ✗ Not found"); + Console.WriteLine(" 3. Check exposure registry: ✓ Found mapping IPaymentConfig → PaymentConfig"); + Console.WriteLine(" 4. Lookup PaymentConfig: ✓ Found configuration"); + Console.WriteLine(" 5. Deserialize to PaymentConfig, cast to IPaymentConfig: ✓ Success"); + Console.WriteLine(); + + // 8. Show API patterns + Console.WriteLine("📖 Key Exposure Patterns:"); + Console.WriteLine(" ✓ Setup.ConcreteType().ExposeAs().Build()"); + Console.WriteLine(" ✓ ConfigManager.Create(c => c.UseConfiguration(rules, setup))"); + Console.WriteLine(" ✓ manager.GetConfig() // resolves via exposure"); + Console.WriteLine(" ✓ manager.GetConfig() // direct access"); + Console.WriteLine(" ✓ Multiple interfaces per concrete type"); + Console.WriteLine(" ✓ Runtime validation of interface implementation"); + Console.WriteLine(); + + Console.WriteLine("🎯 Exposures enable clean interface-based access"); + Console.WriteLine(" without coupling the core library to DI frameworks!"); + + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error: {ex.Message}"); + if (ex.InnerException != null) + Console.WriteLine($" Inner: {ex.InnerException.Message}"); + } + } +} + diff --git a/src/Examples/ExposeExample/README.md b/src/Examples/ExposeExample/README.md index fb8e065..732aa67 100644 --- a/src/Examples/ExposeExample/README.md +++ b/src/Examples/ExposeExample/README.md @@ -1,15 +1,15 @@ -# Exposure Example - -Focused demonstration of exposing a concrete configuration type as one or more interfaces without using a DI container. - -See the main README and [docs/migration-v1-to-v2.md](../../docs/migration-v1-to-v2.md) for details about the Configure API and interface exposure. - -What this example shows: -- Rules producing a concrete config type -- Exposure that maps the concrete type to an interface -- Access via both concrete and interface (`ConfigManager.GetConfig()`) - -Run: -```bash -dotnet run -``` +# Exposure Example + +Focused demonstration of exposing a concrete configuration type as one or more interfaces without using a DI container. + +See the main README and [docs/migration-v1-to-v2.md](../../docs/migration-v1-to-v2.md) for details about the Configure API and interface exposure. + +What this example shows: +- Rules producing a concrete config type +- Exposure that maps the concrete type to an interface +- Access via both concrete and interface (`ConfigManager.GetConfig()`) + +Run: +```bash +dotnet run +``` diff --git a/src/Examples/ExposeExample/config/database.json b/src/Examples/ExposeExample/config/database.json index 0217a85..9e28d9c 100644 --- a/src/Examples/ExposeExample/config/database.json +++ b/src/Examples/ExposeExample/config/database.json @@ -1,5 +1,5 @@ -{ - "ConnectionString": "Server=prod-db.company.com;Database=MainApp;Trusted_Connection=true;", - "CommandTimeout": 60, - "EnableLogging": false +{ + "ConnectionString": "Server=prod-db.company.com;Database=MainApp;Trusted_Connection=true;", + "CommandTimeout": 60, + "EnableLogging": false } \ No newline at end of file diff --git a/src/Examples/ExposeExample/config/features.json b/src/Examples/ExposeExample/config/features.json index 3c3a726..8fc5110 100644 --- a/src/Examples/ExposeExample/config/features.json +++ b/src/Examples/ExposeExample/config/features.json @@ -1,7 +1,7 @@ -{ - "EnableNewDashboard": true, - "EnableExperimentalFeatures": false, - "MaxConcurrentUsers": 100, - "CacheEnabled": true, - "ConfigVersion": "2.1" +{ + "EnableNewDashboard": true, + "EnableExperimentalFeatures": false, + "MaxConcurrentUsers": 100, + "CacheEnabled": true, + "ConfigVersion": "2.1" } \ No newline at end of file diff --git a/src/Examples/ExposeExample/config/payment.json b/src/Examples/ExposeExample/config/payment.json index 111b286..2bb1dc6 100644 --- a/src/Examples/ExposeExample/config/payment.json +++ b/src/Examples/ExposeExample/config/payment.json @@ -1,7 +1,7 @@ -{ - "MerchantId": "MERCH_12345", - "ApiKey": "pk_live_abcdef123456789", - "MaxTransactionAmount": 10000.00, - "EnableRefunds": true, - "InternalNotes": "Production payment configuration - handle with care" +{ + "MerchantId": "MERCH_12345", + "ApiKey": "pk_live_abcdef123456789", + "MaxTransactionAmount": 10000.00, + "EnableRefunds": true, + "InternalNotes": "Production payment configuration - handle with care" } \ No newline at end of file diff --git a/src/Examples/FileLayering/FileLayering.csproj b/src/Examples/FileLayering/FileLayering.csproj index 961fb60..2c53eb5 100644 --- a/src/Examples/FileLayering/FileLayering.csproj +++ b/src/Examples/FileLayering/FileLayering.csproj @@ -1,15 +1,15 @@ - - - net9.0 - enable - enable - true - Examples.FileLayering - - - - - - - + + + net9.0 + enable + enable + true + Examples.FileLayering + + + + + + + \ No newline at end of file diff --git a/src/Examples/FileLayering/Program.cs b/src/Examples/FileLayering/Program.cs index 5b1f8b3..720573f 100644 --- a/src/Examples/FileLayering/Program.cs +++ b/src/Examples/FileLayering/Program.cs @@ -1,34 +1,34 @@ -using Cocoar.Configuration.DI; -using Cocoar.Configuration.Providers; -using Microsoft.Extensions.DependencyInjection; - -namespace Examples.FileLayering; - -public class AppConfig -{ - public string ApplicationName { get; set; } = ""; - public string Version { get; set; } = ""; - public bool EnableLogging { get; set; } - public string LogLevel { get; set; } = "Information"; - public int MaxConnections { get; set; } = 100; - public string[] AllowedOrigins { get; set; } = Array.Empty(); -} - -public static class Program -{ - public static void Main(string[] args) - { - var services = new ServiceCollection(); - - services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromFile("base.json").Select("App"), - rule.For().FromFile("production.json").Select("App"), - rule.For().FromFile("local.json").Select("App") - ])); - - var serviceProvider = services.BuildServiceProvider(); - var config = serviceProvider.GetService(); - - Console.WriteLine($"App: {config?.ApplicationName} Version: {config?.Version} LogLevel: {config?.LogLevel}"); - } -} +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace Examples.FileLayering; + +public class AppConfig +{ + public string ApplicationName { get; set; } = ""; + public string Version { get; set; } = ""; + public bool EnableLogging { get; set; } + public string LogLevel { get; set; } = "Information"; + public int MaxConnections { get; set; } = 100; + public string[] AllowedOrigins { get; set; } = Array.Empty(); +} + +public static class Program +{ + public static void Main(string[] args) + { + var services = new ServiceCollection(); + + services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + rule.For().FromFile("base.json").Select("App"), + rule.For().FromFile("production.json").Select("App"), + rule.For().FromFile("local.json").Select("App") + ])); + + var serviceProvider = services.BuildServiceProvider(); + var config = serviceProvider.GetService(); + + Console.WriteLine($"App: {config?.ApplicationName} Version: {config?.Version} LogLevel: {config?.LogLevel}"); + } +} diff --git a/src/Examples/GenericProviderAPI/GenericProviderAPI.csproj b/src/Examples/GenericProviderAPI/GenericProviderAPI.csproj index 917e218..7f36e0c 100644 --- a/src/Examples/GenericProviderAPI/GenericProviderAPI.csproj +++ b/src/Examples/GenericProviderAPI/GenericProviderAPI.csproj @@ -1,15 +1,15 @@ - - - net9.0 - enable - enable - true - Examples.GenericProviderAPI - - - - - - - + + + net9.0 + enable + enable + true + Examples.GenericProviderAPI + + + + + + + \ No newline at end of file diff --git a/src/Examples/GenericProviderAPI/Program.cs b/src/Examples/GenericProviderAPI/Program.cs index eb70006..5423dc9 100644 --- a/src/Examples/GenericProviderAPI/Program.cs +++ b/src/Examples/GenericProviderAPI/Program.cs @@ -1,32 +1,32 @@ -using Cocoar.Configuration.DI; -using Cocoar.Configuration.Providers; -using Microsoft.Extensions.DependencyInjection; - -namespace Examples.GenericProviderAPI; - -public class AppSettings -{ - public string ApplicationName { get; set; } = ""; - public bool EnableFeatureA { get; set; } - public int MaxRetries { get; set; } = 3; -} - -public static class Program -{ - public static void Main(string[] args) - { - var services = new ServiceCollection(); - - services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("./appsettings.json")) - .Select("App") - .Required() - ])); - - var serviceProvider = services.BuildServiceProvider(); - - var config = serviceProvider.GetRequiredService(); - - Console.WriteLine($"App: {config.ApplicationName} FeatureA: {config.EnableFeatureA} Retries: {config.MaxRetries}"); - } -} +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace Examples.GenericProviderAPI; + +public class AppSettings +{ + public string ApplicationName { get; set; } = ""; + public bool EnableFeatureA { get; set; } + public int MaxRetries { get; set; } = 3; +} + +public static class Program +{ + public static void Main(string[] args) + { + var services = new ServiceCollection(); + + services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("./appsettings.json")) + .Select("App") + .Required() + ])); + + var serviceProvider = services.BuildServiceProvider(); + + var config = serviceProvider.GetRequiredService(); + + Console.WriteLine($"App: {config.ApplicationName} FeatureA: {config.EnableFeatureA} Retries: {config.MaxRetries}"); + } +} diff --git a/src/Examples/HttpPollingExample/HttpPollingExample.csproj b/src/Examples/HttpPollingExample/HttpPollingExample.csproj index 554af90..3d22c15 100644 --- a/src/Examples/HttpPollingExample/HttpPollingExample.csproj +++ b/src/Examples/HttpPollingExample/HttpPollingExample.csproj @@ -1,16 +1,16 @@ - - - net9.0 - enable - enable - true - Examples.HttpPollingExample - - - - - - - - + + + net9.0 + enable + enable + true + Examples.HttpPollingExample + + + + + + + + \ No newline at end of file diff --git a/src/Examples/HttpPollingExample/Program.cs b/src/Examples/HttpPollingExample/Program.cs index 44b549c..334997f 100644 --- a/src/Examples/HttpPollingExample/Program.cs +++ b/src/Examples/HttpPollingExample/Program.cs @@ -1,50 +1,50 @@ -using Cocoar.Configuration.DI; -using Cocoar.Configuration.Providers; -using Microsoft.Extensions.DependencyInjection; - -namespace Examples.HttpPollingExample; - -public class RemoteFeatureFlags -{ - public bool EnableNewDashboard { get; set; } - public bool EnableBetaFeatures { get; set; } - public string[] AllowedRegions { get; set; } = Array.Empty(); -} - -public class ApiConfiguration -{ - public string BaseUrl { get; set; } = ""; - public string ApiKey { get; set; } = ""; - public int PollIntervalSeconds { get; set; } = 30; -} - -public static class Program -{ - private static readonly string[] DefaultAllowedRegions = ["us-east-1", "eu-west-1"]; - - public static void Main(string[] args) - { - var services = new ServiceCollection(); - - services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config.json")).Select("Api") - .Required(), - - rule.For().FromStatic(_ => new RemoteFeatureFlags - { - EnableNewDashboard = true, - EnableBetaFeatures = false, - AllowedRegions = DefaultAllowedRegions - }) - - ])); - - var serviceProvider = services.BuildServiceProvider(); - - var apiConfig = serviceProvider.GetRequiredService(); - var featureFlags = serviceProvider.GetService(); - - Console.WriteLine($"API Base: {apiConfig.BaseUrl} FeatureFlags Beta: {featureFlags?.EnableBetaFeatures}"); - } -} +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace Examples.HttpPollingExample; + +public class RemoteFeatureFlags +{ + public bool EnableNewDashboard { get; set; } + public bool EnableBetaFeatures { get; set; } + public string[] AllowedRegions { get; set; } = Array.Empty(); +} + +public class ApiConfiguration +{ + public string BaseUrl { get; set; } = ""; + public string ApiKey { get; set; } = ""; + public int PollIntervalSeconds { get; set; } = 30; +} + +public static class Program +{ + private static readonly string[] DefaultAllowedRegions = ["us-east-1", "eu-west-1"]; + + public static void Main(string[] args) + { + var services = new ServiceCollection(); + + services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config.json")).Select("Api") + .Required(), + + rule.For().FromStatic(_ => new RemoteFeatureFlags + { + EnableNewDashboard = true, + EnableBetaFeatures = false, + AllowedRegions = DefaultAllowedRegions + }) + + ])); + + var serviceProvider = services.BuildServiceProvider(); + + var apiConfig = serviceProvider.GetRequiredService(); + var featureFlags = serviceProvider.GetService(); + + Console.WriteLine($"API Base: {apiConfig.BaseUrl} FeatureFlags Beta: {featureFlags?.EnableBetaFeatures}"); + } +} diff --git a/src/Examples/MicrosoftAdapterExample/MicrosoftAdapterExample.csproj b/src/Examples/MicrosoftAdapterExample/MicrosoftAdapterExample.csproj index 54e3fed..4f4d0fa 100644 --- a/src/Examples/MicrosoftAdapterExample/MicrosoftAdapterExample.csproj +++ b/src/Examples/MicrosoftAdapterExample/MicrosoftAdapterExample.csproj @@ -1,16 +1,16 @@ - - - net9.0 - enable - enable - true - Examples.MicrosoftAdapterExample - - - - - - - - + + + net9.0 + enable + enable + true + Examples.MicrosoftAdapterExample + + + + + + + + \ No newline at end of file diff --git a/src/Examples/SecretsBasicExample/Program.cs b/src/Examples/SecretsBasicExample/Program.cs index 7c2559f..8b2f613 100644 --- a/src/Examples/SecretsBasicExample/Program.cs +++ b/src/Examples/SecretsBasicExample/Program.cs @@ -1,136 +1,136 @@ -using Cocoar.Configuration; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Secrets; -using Cocoar.Configuration.X509Encryption; -using System.Text.Json; -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace SecretsBasicExample; - -/// -/// Demonstrates basic Secrets usage with self-signed certificate. -/// -/// Key concepts: -/// - Secret requires certificate-based encryption/decryption -/// - Certificate must be generated explicitly before use -/// - Values in JSON must be pre-encrypted using the CLI tool -/// - Use SecretLease with 'using' to ensure proper cleanup -/// -class Program -{ - static void Main() - { - Console.WriteLine("=== Secrets Basic Example: Self-Signed Certificate ===\n"); - Console.WriteLine("⚠️ NOTE: This example demonstrates the API."); - Console.WriteLine(" In production, use the CLI tool to pre-encrypt secrets.\n"); - - // Generate self-signed certificate explicitly for this demo - var certPath = Path.Combine(Path.GetTempPath(), "cocoar-secrets-demo.pfx"); - - // Explicit certificate generation (password-less) - X509CertificateGenerator.GenerateAndSave( - certPath, - null, // Password-less certificate - "CN=Dev Secrets", - validYears: 1, - keySize: 2048, - overwrite: true); - - // Create ConfigManager with certificate-based secrets - var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("appsettings.json")) - ]) - .UseSecretsSetup(secrets => secrets - .UseCertificateFromFile(certPath) - .WithKeyId("dev-secrets"))); - - // Retrieve configuration - var config = manager.GetConfig(); - - Console.WriteLine("✅ Configuration loaded with certificate-based secrets\n"); - Console.WriteLine("📋 Configuration structure:"); - Console.WriteLine($" Database.ConnectionString: {config?.Database.ConnectionString}"); // Shows "***" - Console.WriteLine($" Database.ApiKey: {config?.Database.ApiKey}"); // Shows "***" - Console.WriteLine($" ExternalService.ApiKey: {config?.ExternalService.ApiKey}"); // Shows "***" - Console.WriteLine(); - - // Demonstrate secure access to secrets - Console.WriteLine("🔐 Accessing secrets securely:\n"); - Console.WriteLine("⚠️ NOTE: These are plain text values from JSON (not encrypted yet)\n"); - - // CORRECT: Use 'using' to ensure memory is zeroized - using (var dbPasswordLease = config?.Database.ConnectionString.Open()) - { - var value = dbPasswordLease?.Value; - if (value != null) - { - Console.WriteLine($" Database connection string (first 30 chars): {value.Substring(0, Math.Min(30, value.Length))}..."); - } - } - // ✅ Memory automatically zeroized after 'using' block - - using (var apiKeyLease = config?.Database.ApiKey.Open()) - { - var value = apiKeyLease?.Value; - if (value != null) - { - Console.WriteLine($" Database API key (first 15 chars): {value.Substring(0, Math.Min(15, value.Length))}..."); - } - } - - using (var externalApiKeyLease = config?.ExternalService.ApiKey.Open()) - { - var value = externalApiKeyLease?.Value; - if (value != null) - { - Console.WriteLine($" External service API key (first 20 chars): {value.Substring(0, Math.Min(20, value.Length))}..."); - } - } - - Console.WriteLine(); - Console.WriteLine("✨ Key points:"); - Console.WriteLine(" • Certificate-based encryption/decryption"); - Console.WriteLine(" • Certificate explicitly generated before use"); - Console.WriteLine(" • Certificate stored at: " + certPath); - Console.WriteLine(" • Certificate subject: CN=Dev Secrets"); - Console.WriteLine(); - Console.WriteLine("⚠️ Production checklist:"); - Console.WriteLine(" • Generate certificates using: cocoar-secrets generate-cert"); - Console.WriteLine(" • Encrypt secrets using: cocoar-secrets encrypt"); - Console.WriteLine(" • Use proper PKI certificates (not self-signed)"); - Console.WriteLine(" • Pre-encrypted secret envelopes in JSON"); - - // Cleanup demo cert - if (File.Exists(certPath)) - { - File.Delete(certPath); - } - Console.WriteLine(" • Secrets shown as '***' when converted to string"); - Console.WriteLine(" • Memory zeroized after each 'using' block"); - Console.WriteLine(); - Console.WriteLine("🎯 Use case: Development & testing with explicit certificate generation"); - } -} - -public class AppConfig -{ - public DatabaseConfig Database { get; set; } = new(); - public ExternalServiceConfig ExternalService { get; set; } = new(); -} - -public class DatabaseConfig -{ - // Secret automatically encrypts plain-text values from JSON - public Secret ConnectionString { get; set; } = null!; - public Secret ApiKey { get; set; } = null!; -} - -public class ExternalServiceConfig -{ - public string Url { get; set; } = ""; - public Secret ApiKey { get; set; } = null!; -} - +using Cocoar.Configuration; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.X509Encryption; +using System.Text.Json; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace SecretsBasicExample; + +/// +/// Demonstrates basic Secrets usage with self-signed certificate. +/// +/// Key concepts: +/// - Secret requires certificate-based encryption/decryption +/// - Certificate must be generated explicitly before use +/// - Values in JSON must be pre-encrypted using the CLI tool +/// - Use SecretLease with 'using' to ensure proper cleanup +/// +class Program +{ + static void Main() + { + Console.WriteLine("=== Secrets Basic Example: Self-Signed Certificate ===\n"); + Console.WriteLine("⚠️ NOTE: This example demonstrates the API."); + Console.WriteLine(" In production, use the CLI tool to pre-encrypt secrets.\n"); + + // Generate self-signed certificate explicitly for this demo + var certPath = Path.Combine(Path.GetTempPath(), "cocoar-secrets-demo.pfx"); + + // Explicit certificate generation (password-less) + X509CertificateGenerator.GenerateAndSave( + certPath, + null, // Password-less certificate + "CN=Dev Secrets", + validYears: 1, + keySize: 2048, + overwrite: true); + + // Create ConfigManager with certificate-based secrets + var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("appsettings.json")) + ]) + .UseSecretsSetup(secrets => secrets + .UseCertificateFromFile(certPath) + .WithKeyId("dev-secrets"))); + + // Retrieve configuration + var config = manager.GetConfig(); + + Console.WriteLine("✅ Configuration loaded with certificate-based secrets\n"); + Console.WriteLine("📋 Configuration structure:"); + Console.WriteLine($" Database.ConnectionString: {config?.Database.ConnectionString}"); // Shows "***" + Console.WriteLine($" Database.ApiKey: {config?.Database.ApiKey}"); // Shows "***" + Console.WriteLine($" ExternalService.ApiKey: {config?.ExternalService.ApiKey}"); // Shows "***" + Console.WriteLine(); + + // Demonstrate secure access to secrets + Console.WriteLine("🔐 Accessing secrets securely:\n"); + Console.WriteLine("⚠️ NOTE: These are plain text values from JSON (not encrypted yet)\n"); + + // CORRECT: Use 'using' to ensure memory is zeroized + using (var dbPasswordLease = config?.Database.ConnectionString.Open()) + { + var value = dbPasswordLease?.Value; + if (value != null) + { + Console.WriteLine($" Database connection string (first 30 chars): {value.Substring(0, Math.Min(30, value.Length))}..."); + } + } + // ✅ Memory automatically zeroized after 'using' block + + using (var apiKeyLease = config?.Database.ApiKey.Open()) + { + var value = apiKeyLease?.Value; + if (value != null) + { + Console.WriteLine($" Database API key (first 15 chars): {value.Substring(0, Math.Min(15, value.Length))}..."); + } + } + + using (var externalApiKeyLease = config?.ExternalService.ApiKey.Open()) + { + var value = externalApiKeyLease?.Value; + if (value != null) + { + Console.WriteLine($" External service API key (first 20 chars): {value.Substring(0, Math.Min(20, value.Length))}..."); + } + } + + Console.WriteLine(); + Console.WriteLine("✨ Key points:"); + Console.WriteLine(" • Certificate-based encryption/decryption"); + Console.WriteLine(" • Certificate explicitly generated before use"); + Console.WriteLine(" • Certificate stored at: " + certPath); + Console.WriteLine(" • Certificate subject: CN=Dev Secrets"); + Console.WriteLine(); + Console.WriteLine("⚠️ Production checklist:"); + Console.WriteLine(" • Generate certificates using: cocoar-secrets generate-cert"); + Console.WriteLine(" • Encrypt secrets using: cocoar-secrets encrypt"); + Console.WriteLine(" • Use proper PKI certificates (not self-signed)"); + Console.WriteLine(" • Pre-encrypted secret envelopes in JSON"); + + // Cleanup demo cert + if (File.Exists(certPath)) + { + File.Delete(certPath); + } + Console.WriteLine(" • Secrets shown as '***' when converted to string"); + Console.WriteLine(" • Memory zeroized after each 'using' block"); + Console.WriteLine(); + Console.WriteLine("🎯 Use case: Development & testing with explicit certificate generation"); + } +} + +public class AppConfig +{ + public DatabaseConfig Database { get; set; } = new(); + public ExternalServiceConfig ExternalService { get; set; } = new(); +} + +public class DatabaseConfig +{ + // Secret automatically encrypts plain-text values from JSON + public Secret ConnectionString { get; set; } = null!; + public Secret ApiKey { get; set; } = null!; +} + +public class ExternalServiceConfig +{ + public string Url { get; set; } = ""; + public Secret ApiKey { get; set; } = null!; +} + diff --git a/src/Examples/SecretsBasicExample/README.md b/src/Examples/SecretsBasicExample/README.md index 49f9497..5f4da5f 100644 --- a/src/Examples/SecretsBasicExample/README.md +++ b/src/Examples/SecretsBasicExample/README.md @@ -1,91 +1,91 @@ -# Secrets Basic Example - -**Pre-Encrypted Secrets** - Demonstrates secure secret handling with encrypted envelopes. - -## What This Example Demonstrates - -- ✅ Loading pre-encrypted secrets from configuration files -- ✅ Password-less certificate-based decryption setup -- ✅ Proper `SecretLease` usage with `using` statements -- ✅ Memory zeroization for security -- ✅ `Secret` for any JSON-serializable type - -## Quick Start - -```bash -dotnet run --project Examples\SecretsBasicExample\SecretsBasicExample.csproj -``` - -## Key Code Snippet - -```csharp -var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromFile("appsettings.json") - ]) - .UseSecretsSetup(secrets => secrets - .UseCertificateFromFile("secrets.pfx") // Password-less certificate - .WithKeyId("dev-secrets"))); - -var config = manager.GetConfig(); - -// Access secrets securely -using (var lease = config.Database.ApiKey.Open()) -{ - var key = lease.Value; // Use the secret - // ... use it with APIs, database connections, etc. -} -// Memory automatically zeroized after 'using' block -``` - -## Configuration File Example - -Your `appsettings.json` must contain pre-encrypted envelopes: - -```json -{ - "Database": { - "ApiKey": { - "_cocoar_secret": "v1", - "kid": "dev-secrets", - "alg": "RSA-OAEP-AES256-GCM", - "type": "utf8", - "createdAt": "2024-11-01T12:34:56Z", - "iv": "AbCdEf...", - "ct": "XyZ123...", - "tag": "DeF456...", - "wk": "GhI789..." - } - } -} -``` - -## What Happens Under the Hood - -1. Configuration system loads JSON with `_cocoar_secret` marker -2. `SecretJsonConverter` detects envelope and creates `Secret` instance -3. Secret remains encrypted in memory until `.Open()` is called -4. Decryption happens on-demand using registered certificate -5. Secrets shown as `***` when printed -6. Memory zeroized when `SecretLease` is disposed - -## Security Architecture - -**Pre-encrypted envelopes only** - Secrets must be encrypted by external systems: -- CI/CD pipelines (Azure DevOps, GitHub Actions, Jenkins) -- Security teams using encryption tools -- Cloud vaults (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) - -**Plaintext detection** - If you accidentally provide plaintext secrets instead of envelopes, `Secret.Open()` will throw `InvalidOperationException` with a clear error message. - -**Password-less certificates** - Certificates are protected by file permissions (`chmod 600` on Linux/macOS, NTFS permissions on Windows) and full-disk encryption (BitLocker/LUKS/FileVault). - -## Use Case - -**All environments** - Development, staging, and production all use pre-encrypted envelopes for consistent security. - -## See Also - -- [SecretsCertificateExample](../SecretsCertificateExample/) - Advanced certificate management -- [Secrets CLI](../../Cocoar.Configuration.Secrets.Cli/README.md) - Command-line encryption/decryption tools - +# Secrets Basic Example + +**Pre-Encrypted Secrets** - Demonstrates secure secret handling with encrypted envelopes. + +## What This Example Demonstrates + +- ✅ Loading pre-encrypted secrets from configuration files +- ✅ Password-less certificate-based decryption setup +- ✅ Proper `SecretLease` usage with `using` statements +- ✅ Memory zeroization for security +- ✅ `Secret` for any JSON-serializable type + +## Quick Start + +```bash +dotnet run --project Examples\SecretsBasicExample\SecretsBasicExample.csproj +``` + +## Key Code Snippet + +```csharp +var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromFile("appsettings.json") + ]) + .UseSecretsSetup(secrets => secrets + .UseCertificateFromFile("secrets.pfx") // Password-less certificate + .WithKeyId("dev-secrets"))); + +var config = manager.GetConfig(); + +// Access secrets securely +using (var lease = config.Database.ApiKey.Open()) +{ + var key = lease.Value; // Use the secret + // ... use it with APIs, database connections, etc. +} +// Memory automatically zeroized after 'using' block +``` + +## Configuration File Example + +Your `appsettings.json` must contain pre-encrypted envelopes: + +```json +{ + "Database": { + "ApiKey": { + "_cocoar_secret": "v1", + "kid": "dev-secrets", + "alg": "RSA-OAEP-AES256-GCM", + "type": "utf8", + "createdAt": "2024-11-01T12:34:56Z", + "iv": "AbCdEf...", + "ct": "XyZ123...", + "tag": "DeF456...", + "wk": "GhI789..." + } + } +} +``` + +## What Happens Under the Hood + +1. Configuration system loads JSON with `_cocoar_secret` marker +2. `SecretJsonConverter` detects envelope and creates `Secret` instance +3. Secret remains encrypted in memory until `.Open()` is called +4. Decryption happens on-demand using registered certificate +5. Secrets shown as `***` when printed +6. Memory zeroized when `SecretLease` is disposed + +## Security Architecture + +**Pre-encrypted envelopes only** - Secrets must be encrypted by external systems: +- CI/CD pipelines (Azure DevOps, GitHub Actions, Jenkins) +- Security teams using encryption tools +- Cloud vaults (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) + +**Plaintext detection** - If you accidentally provide plaintext secrets instead of envelopes, `Secret.Open()` will throw `InvalidOperationException` with a clear error message. + +**Password-less certificates** - Certificates are protected by file permissions (`chmod 600` on Linux/macOS, NTFS permissions on Windows) and full-disk encryption (BitLocker/LUKS/FileVault). + +## Use Case + +**All environments** - Development, staging, and production all use pre-encrypted envelopes for consistent security. + +## See Also + +- [SecretsCertificateExample](../SecretsCertificateExample/) - Advanced certificate management +- [Secrets CLI](../../Cocoar.Configuration.Secrets.Cli/README.md) - Command-line encryption/decryption tools + diff --git a/src/Examples/SecretsBasicExample/SecretsBasicExample.csproj b/src/Examples/SecretsBasicExample/SecretsBasicExample.csproj index 6d6a582..5d18dce 100644 --- a/src/Examples/SecretsBasicExample/SecretsBasicExample.csproj +++ b/src/Examples/SecretsBasicExample/SecretsBasicExample.csproj @@ -1,20 +1,20 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - PreserveNewest - - - - + + + + Exe + net9.0 + enable + enable + + + + + + + + + PreserveNewest + + + + diff --git a/src/Examples/SecretsBasicExample/appsettings.json b/src/Examples/SecretsBasicExample/appsettings.json index ec816e5..fc14a03 100644 --- a/src/Examples/SecretsBasicExample/appsettings.json +++ b/src/Examples/SecretsBasicExample/appsettings.json @@ -1,10 +1,10 @@ -{ - "Database": { - "ConnectionString": "Server=localhost;Database=MyApp;User Id=sa;Password=MySecretP@ssw0rd!;", - "ApiKey": "sk_test_51234567890abcdef" - }, - "ExternalService": { - "Url": "https://api.example.com", - "ApiKey": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - } -} +{ + "Database": { + "ConnectionString": "Server=localhost;Database=MyApp;User Id=sa;Password=MySecretP@ssw0rd!;", + "ApiKey": "sk_test_51234567890abcdef" + }, + "ExternalService": { + "Url": "https://api.example.com", + "ApiKey": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } +} diff --git a/src/Examples/SecretsCertificateExample/Program.cs b/src/Examples/SecretsCertificateExample/Program.cs index 4313663..4f6bad7 100644 --- a/src/Examples/SecretsCertificateExample/Program.cs +++ b/src/Examples/SecretsCertificateExample/Program.cs @@ -1,161 +1,161 @@ -using Cocoar.Configuration; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Secrets; -using Cocoar.Configuration.X509Encryption; -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace SecretsCertificateExample; - -/// -/// Demonstrates production-ready Secrets usage with certificate-based decryption. -/// -/// Key concepts: -/// - UseCertificateFromFile() for decrypting pre-encrypted secrets -/// - Certificates must be generated explicitly before use -/// - Pre-encrypted envelopes from CI/CD pipelines -/// - Certificate rotation with UseCertificatesFromFolder() -/// -class Program -{ - static void Main() - { - Console.WriteLine("=== Secrets Certificate Example: Pre-Encrypted Envelopes ===\n"); - - // Simulate different scenarios - RunDevelopmentScenario(); - Console.WriteLine("\n" + new string('=', 60) + "\n"); - RunProductionScenario(); - } - - static void RunDevelopmentScenario() - { - Console.WriteLine("🔧 DEVELOPMENT SCENARIO"); - Console.WriteLine(" Pre-encrypted secrets with explicit certificate\n"); - - // Generate a password-less self-signed certificate for development explicitly - var devCertPath = Path.Combine(Path.GetTempPath(), "cocoar-dev-demo.pfx"); - - X509CertificateGenerator.GenerateAndSave( - devCertPath, - null, // Password-less certificate - "CN=Dev Secrets", - validYears: 1, - keySize: 2048, - overwrite: true); - - var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("appsettings.encrypted.json")) - ]) - .UseSecretsSetup(secrets => secrets - .UseCertificateFromFile(devCertPath) - .WithKeyId("dev-secrets"))); - - var config = manager.GetConfig(); - - Console.WriteLine("✅ Configuration loaded (Development mode)"); - Console.WriteLine($" Database: {config?.Database.Host}:{config?.Database.Port}/{config?.Database.Name}"); - Console.WriteLine($" Password: {config?.Database.Password}"); // Shows "***" - Console.WriteLine($" Beta features: {config?.Features.EnableBetaFeatures}"); - - using (var passwordLease = config?.Database.Password.Open()) - { - Console.WriteLine($" Actual password: {passwordLease?.Value}"); - } - - Console.WriteLine("\n 💡 Certificate explicitly generated: CN=Dev Secrets"); - Console.WriteLine(" 💡 Kid: 'dev-secrets'"); - Console.WriteLine(" 💡 Secrets must be pre-encrypted with this certificate's public key"); - - // Cleanup demo cert - if (File.Exists(devCertPath)) - { - File.Delete(devCertPath); - } - } - - static void RunProductionScenario() - { - Console.WriteLine("🏭 PRODUCTION SCENARIO"); - Console.WriteLine(" Pre-encrypted secrets from CI/CD pipeline\n"); - - // Generate a password-less self-signed certificate for demonstration - // In real production, you'd use: .UseCertificateFromFile("certs/prod.pfx") - var prodCertPath = Path.Combine(Path.GetTempPath(), "cocoar-prod-demo.pfx"); - - X509CertificateGenerator.GenerateAndSave( - prodCertPath, - null, // Password-less certificate - "CN=Production Secrets", - validYears: 1, - keySize: 2048, - overwrite: true); - - var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("appsettings.encrypted.json")) - ]) - .UseSecretsSetup(secrets => secrets - .UseCertificateFromFile(prodCertPath) - .WithKeyId("prod-secrets"))); - - var config = manager.GetConfig(); - - Console.WriteLine("✅ Configuration loaded (Production mode)"); - Console.WriteLine($" Database: {config?.Database.Host}:{config?.Database.Port}/{config?.Database.Name}"); - Console.WriteLine($" Password: {config?.Database.Password}"); // Shows "***" - Console.WriteLine($" API Endpoint: {config?.ExternalApi.Endpoint}"); - Console.WriteLine($" API Key: {config?.ExternalApi.ApiKey}"); // Shows "***" - Console.WriteLine($" Beta features: {config?.Features.EnableBetaFeatures}"); - - Console.WriteLine("\n 🔐 Decryption certificate:"); - Console.WriteLine(" • Certificate: CN=Production Secrets"); - Console.WriteLine(" • Kid: 'prod-secrets'"); - Console.WriteLine(" • Decrypts pre-encrypted secrets from CI/CD"); - - Console.WriteLine("\n 💡 In real production:"); - Console.WriteLine(" • Generate cert: cocoar-secrets generate-cert -o certs/prod.pfx -p $PASSWORD"); - Console.WriteLine(" • Encrypt secrets: cocoar-secrets encrypt -f appsettings.json -c certs/prod.pfx"); - Console.WriteLine(" • Use .UseCertificatesFromFolder() for rotation support"); - Console.WriteLine(" • Store cert password in environment variable"); - Console.WriteLine(" • CI/CD pre-encrypts secrets before deployment"); - Console.WriteLine(" • Application only decrypts, never encrypts"); - - // Cleanup demo cert - if (File.Exists(prodCertPath)) - { - File.Delete(prodCertPath); - } - } -} - -public class AppConfig -{ - public DatabaseConfig Database { get; set; } = new(); - public ExternalServiceConfig ExternalApi { get; set; } = new(); - public FeaturesConfig Features { get; set; } = new(); -} - -public class DatabaseConfig -{ - public string Host { get; set; } = ""; - public int Port { get; set; } - public string Database { get; set; } = ""; - public string Name { get; set; } = ""; - public Secret Password { get; set; } = null!; -} - -public class ExternalServiceConfig -{ - public string Url { get; set; } = ""; - public string Endpoint { get; set; } = ""; - public Secret ApiKey { get; set; } = null!; -} - -public class FeaturesConfig -{ - public bool EnableBetaFeatures { get; set; } -} - +using Cocoar.Configuration; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.X509Encryption; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace SecretsCertificateExample; + +/// +/// Demonstrates production-ready Secrets usage with certificate-based decryption. +/// +/// Key concepts: +/// - UseCertificateFromFile() for decrypting pre-encrypted secrets +/// - Certificates must be generated explicitly before use +/// - Pre-encrypted envelopes from CI/CD pipelines +/// - Certificate rotation with UseCertificatesFromFolder() +/// +class Program +{ + static void Main() + { + Console.WriteLine("=== Secrets Certificate Example: Pre-Encrypted Envelopes ===\n"); + + // Simulate different scenarios + RunDevelopmentScenario(); + Console.WriteLine("\n" + new string('=', 60) + "\n"); + RunProductionScenario(); + } + + static void RunDevelopmentScenario() + { + Console.WriteLine("🔧 DEVELOPMENT SCENARIO"); + Console.WriteLine(" Pre-encrypted secrets with explicit certificate\n"); + + // Generate a password-less self-signed certificate for development explicitly + var devCertPath = Path.Combine(Path.GetTempPath(), "cocoar-dev-demo.pfx"); + + X509CertificateGenerator.GenerateAndSave( + devCertPath, + null, // Password-less certificate + "CN=Dev Secrets", + validYears: 1, + keySize: 2048, + overwrite: true); + + var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("appsettings.encrypted.json")) + ]) + .UseSecretsSetup(secrets => secrets + .UseCertificateFromFile(devCertPath) + .WithKeyId("dev-secrets"))); + + var config = manager.GetConfig(); + + Console.WriteLine("✅ Configuration loaded (Development mode)"); + Console.WriteLine($" Database: {config?.Database.Host}:{config?.Database.Port}/{config?.Database.Name}"); + Console.WriteLine($" Password: {config?.Database.Password}"); // Shows "***" + Console.WriteLine($" Beta features: {config?.Features.EnableBetaFeatures}"); + + using (var passwordLease = config?.Database.Password.Open()) + { + Console.WriteLine($" Actual password: {passwordLease?.Value}"); + } + + Console.WriteLine("\n 💡 Certificate explicitly generated: CN=Dev Secrets"); + Console.WriteLine(" 💡 Kid: 'dev-secrets'"); + Console.WriteLine(" 💡 Secrets must be pre-encrypted with this certificate's public key"); + + // Cleanup demo cert + if (File.Exists(devCertPath)) + { + File.Delete(devCertPath); + } + } + + static void RunProductionScenario() + { + Console.WriteLine("🏭 PRODUCTION SCENARIO"); + Console.WriteLine(" Pre-encrypted secrets from CI/CD pipeline\n"); + + // Generate a password-less self-signed certificate for demonstration + // In real production, you'd use: .UseCertificateFromFile("certs/prod.pfx") + var prodCertPath = Path.Combine(Path.GetTempPath(), "cocoar-prod-demo.pfx"); + + X509CertificateGenerator.GenerateAndSave( + prodCertPath, + null, // Password-less certificate + "CN=Production Secrets", + validYears: 1, + keySize: 2048, + overwrite: true); + + var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("appsettings.encrypted.json")) + ]) + .UseSecretsSetup(secrets => secrets + .UseCertificateFromFile(prodCertPath) + .WithKeyId("prod-secrets"))); + + var config = manager.GetConfig(); + + Console.WriteLine("✅ Configuration loaded (Production mode)"); + Console.WriteLine($" Database: {config?.Database.Host}:{config?.Database.Port}/{config?.Database.Name}"); + Console.WriteLine($" Password: {config?.Database.Password}"); // Shows "***" + Console.WriteLine($" API Endpoint: {config?.ExternalApi.Endpoint}"); + Console.WriteLine($" API Key: {config?.ExternalApi.ApiKey}"); // Shows "***" + Console.WriteLine($" Beta features: {config?.Features.EnableBetaFeatures}"); + + Console.WriteLine("\n 🔐 Decryption certificate:"); + Console.WriteLine(" • Certificate: CN=Production Secrets"); + Console.WriteLine(" • Kid: 'prod-secrets'"); + Console.WriteLine(" • Decrypts pre-encrypted secrets from CI/CD"); + + Console.WriteLine("\n 💡 In real production:"); + Console.WriteLine(" • Generate cert: cocoar-secrets generate-cert -o certs/prod.pfx -p $PASSWORD"); + Console.WriteLine(" • Encrypt secrets: cocoar-secrets encrypt -f appsettings.json -c certs/prod.pfx"); + Console.WriteLine(" • Use .UseCertificatesFromFolder() for rotation support"); + Console.WriteLine(" • Store cert password in environment variable"); + Console.WriteLine(" • CI/CD pre-encrypts secrets before deployment"); + Console.WriteLine(" • Application only decrypts, never encrypts"); + + // Cleanup demo cert + if (File.Exists(prodCertPath)) + { + File.Delete(prodCertPath); + } + } +} + +public class AppConfig +{ + public DatabaseConfig Database { get; set; } = new(); + public ExternalServiceConfig ExternalApi { get; set; } = new(); + public FeaturesConfig Features { get; set; } = new(); +} + +public class DatabaseConfig +{ + public string Host { get; set; } = ""; + public int Port { get; set; } + public string Database { get; set; } = ""; + public string Name { get; set; } = ""; + public Secret Password { get; set; } = null!; +} + +public class ExternalServiceConfig +{ + public string Url { get; set; } = ""; + public string Endpoint { get; set; } = ""; + public Secret ApiKey { get; set; } = null!; +} + +public class FeaturesConfig +{ + public bool EnableBetaFeatures { get; set; } +} + diff --git a/src/Examples/SecretsCertificateExample/README.md b/src/Examples/SecretsCertificateExample/README.md index 5884b8d..240f4ad 100644 --- a/src/Examples/SecretsCertificateExample/README.md +++ b/src/Examples/SecretsCertificateExample/README.md @@ -1,146 +1,146 @@ -# Secrets Certificate Example - -**Production-ready secrets management** with certificate-based decryption and rotation. - -## What This Example Demonstrates - -- ✅ Multiple certificate configurations for different environments -- ✅ Certificate rotation with `UseCertificatesFromFolder` -- ✅ Key identifier (Kid) management and backward compatibility -- ✅ Certificate caching strategies for security/performance trade-offs - -## Quick Start - -```bash -dotnet run --project Examples\SecretsCertificateExample\SecretsCertificateExample.csproj -``` - -## Key Code Snippets - -### Development Setup - -First, generate a certificate using the CLI: -```bash -cocoar-secrets generate-cert -o certs/dev.pfx -``` - -Then configure the manager: -```csharp -var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromFile("appsettings.dev.json") - ]) - .UseSecretsSetup(secrets => secrets - .UseCertificateFromFile("certs/dev.pfx") - .WithKeyId("dev-secrets"))); -``` - -### Production Setup with Rotation - -```csharp -var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromFile("appsettings.prod.json") - ]) - // Folder-based with automatic rotation - // Uses kid-based folders: C:\certs\prod\production-secrets\*.pfx - .UseSecretsSetup(secrets => secrets - .UseCertificatesFromFolder(@"C:\certs\prod", - cacheDurationSeconds: 30))); -``` - -### Multi-Tier Security - -```csharp -var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromFile("appsettings.json") - ]) - // Critical secrets - no cache, load fresh every time - .UseSecretsSetup(secrets => secrets - .UseCertificatesFromFolder(@"C:\certs\pci", - cacheDurationSeconds: 0))); // Maximum security -``` - -## Certificate Management - -### Certificate Roles - -1. **Decryption Certificates** (Registered via `UseCertificateFromFile` / `UseCertificatesFromFolder`) - - Decrypt pre-encrypted secrets from external sources - - Identified by `kid` (key identifier) - - Multiple certificates supported for rotation and multi-environment - -### Benefits of Folder-Based Certificates - -- **Zero-downtime rotation:** Add new certificate, old secrets still work -- **Reduced memory footprint:** Certificates cached with TTL -- **Automatic discovery:** No restart needed when certificates added -- **Intelligent caching:** Two-level cache (envelope hash + cert cache) - -### Security vs Performance Trade-offs - -| Cache Duration | Security Level | Use Case | Performance | -|----------------|---------------|----------|-------------| -| **0s** | Maximum | PCI-DSS, HIPAA | File I/O every decrypt | -| **5-30s** | High | API keys, credentials | 100-1000x faster | -| **60-300s** | Medium | Service credentials | Minimal I/O | -| **3600s+** | Low | Feature flags | Maximum performance | - -## Configuration File Example - -Your `appsettings.json` must contain pre-encrypted envelopes: - -```json -{ - "Database": { - "ConnectionString": { - "_cocoar_secret": "v1", - "kid": "production-secrets", - "alg": "RSA-OAEP-AES256-GCM", - "type": "utf8", - "createdAt": "2024-11-01T12:34:56Z", - "iv": "...", - "ct": "...", - "tag": "...", - "wk": "..." - } - }, - "ApiKeys": { - "Stripe": { - "_cocoar_secret": "v1", - "kid": "api-keys", - "alg": "RSA-OAEP-AES256-GCM", - "type": "utf8", - "createdAt": "2024-11-01T12:34:56Z", - "iv": "...", - "ct": "...", - "tag": "...", - "wk": "..." - } - } -} -``` - -## Best Practices - -1. **Pre-encrypt all production secrets** - Use CI/CD pipelines or security tools -2. **Use folder-based certificates** - Enable rotation without code changes -3. **Match cache duration to sensitivity** - Critical data = 0s cache, feature flags = 1 hour -4. **Use descriptive Kids** - `production-api-keys` > `cert1` -5. **Plan for rotation** - Use `WithAdditionalKeyId` for backward compatibility -6. **Test rotation** - Verify old secrets decrypt with new certificates -7. **Monitor and audit** - Log decryption operations for compliance - -## Use Case - -**Production environments** - Enterprise-grade secret management with: -- Certificate rotation -- Multi-tier security -- Compliance requirements (PCI-DSS, HIPAA) -- High availability - -## See Also - -- [Intelligent Certificate Caching](../../Cocoar.Configuration.Secrets/intelligent-certificate-caching.md) - Deep dive into caching architecture -- [Secrets CLI](../../Cocoar.Configuration.Secrets.Cli/README.md) - Command-line encryption/decryption tools +# Secrets Certificate Example + +**Production-ready secrets management** with certificate-based decryption and rotation. + +## What This Example Demonstrates + +- ✅ Multiple certificate configurations for different environments +- ✅ Certificate rotation with `UseCertificatesFromFolder` +- ✅ Key identifier (Kid) management and backward compatibility +- ✅ Certificate caching strategies for security/performance trade-offs + +## Quick Start + +```bash +dotnet run --project Examples\SecretsCertificateExample\SecretsCertificateExample.csproj +``` + +## Key Code Snippets + +### Development Setup + +First, generate a certificate using the CLI: +```bash +cocoar-secrets generate-cert -o certs/dev.pfx +``` + +Then configure the manager: +```csharp +var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromFile("appsettings.dev.json") + ]) + .UseSecretsSetup(secrets => secrets + .UseCertificateFromFile("certs/dev.pfx") + .WithKeyId("dev-secrets"))); +``` + +### Production Setup with Rotation + +```csharp +var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromFile("appsettings.prod.json") + ]) + // Folder-based with automatic rotation + // Uses kid-based folders: C:\certs\prod\production-secrets\*.pfx + .UseSecretsSetup(secrets => secrets + .UseCertificatesFromFolder(@"C:\certs\prod", + cacheDurationSeconds: 30))); +``` + +### Multi-Tier Security + +```csharp +var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromFile("appsettings.json") + ]) + // Critical secrets - no cache, load fresh every time + .UseSecretsSetup(secrets => secrets + .UseCertificatesFromFolder(@"C:\certs\pci", + cacheDurationSeconds: 0))); // Maximum security +``` + +## Certificate Management + +### Certificate Roles + +1. **Decryption Certificates** (Registered via `UseCertificateFromFile` / `UseCertificatesFromFolder`) + - Decrypt pre-encrypted secrets from external sources + - Identified by `kid` (key identifier) + - Multiple certificates supported for rotation and multi-environment + +### Benefits of Folder-Based Certificates + +- **Zero-downtime rotation:** Add new certificate, old secrets still work +- **Reduced memory footprint:** Certificates cached with TTL +- **Automatic discovery:** No restart needed when certificates added +- **Intelligent caching:** Two-level cache (envelope hash + cert cache) + +### Security vs Performance Trade-offs + +| Cache Duration | Security Level | Use Case | Performance | +|----------------|---------------|----------|-------------| +| **0s** | Maximum | PCI-DSS, HIPAA | File I/O every decrypt | +| **5-30s** | High | API keys, credentials | 100-1000x faster | +| **60-300s** | Medium | Service credentials | Minimal I/O | +| **3600s+** | Low | Feature flags | Maximum performance | + +## Configuration File Example + +Your `appsettings.json` must contain pre-encrypted envelopes: + +```json +{ + "Database": { + "ConnectionString": { + "_cocoar_secret": "v1", + "kid": "production-secrets", + "alg": "RSA-OAEP-AES256-GCM", + "type": "utf8", + "createdAt": "2024-11-01T12:34:56Z", + "iv": "...", + "ct": "...", + "tag": "...", + "wk": "..." + } + }, + "ApiKeys": { + "Stripe": { + "_cocoar_secret": "v1", + "kid": "api-keys", + "alg": "RSA-OAEP-AES256-GCM", + "type": "utf8", + "createdAt": "2024-11-01T12:34:56Z", + "iv": "...", + "ct": "...", + "tag": "...", + "wk": "..." + } + } +} +``` + +## Best Practices + +1. **Pre-encrypt all production secrets** - Use CI/CD pipelines or security tools +2. **Use folder-based certificates** - Enable rotation without code changes +3. **Match cache duration to sensitivity** - Critical data = 0s cache, feature flags = 1 hour +4. **Use descriptive Kids** - `production-api-keys` > `cert1` +5. **Plan for rotation** - Use `WithAdditionalKeyId` for backward compatibility +6. **Test rotation** - Verify old secrets decrypt with new certificates +7. **Monitor and audit** - Log decryption operations for compliance + +## Use Case + +**Production environments** - Enterprise-grade secret management with: +- Certificate rotation +- Multi-tier security +- Compliance requirements (PCI-DSS, HIPAA) +- High availability + +## See Also + +- [Intelligent Certificate Caching](../../Cocoar.Configuration.Secrets/intelligent-certificate-caching.md) - Deep dive into caching architecture +- [Secrets CLI](../../Cocoar.Configuration.Secrets.Cli/README.md) - Command-line encryption/decryption tools diff --git a/src/Examples/SecretsCertificateExample/SecretsCertificateExample.csproj b/src/Examples/SecretsCertificateExample/SecretsCertificateExample.csproj index 32b499a..44c0c33 100644 --- a/src/Examples/SecretsCertificateExample/SecretsCertificateExample.csproj +++ b/src/Examples/SecretsCertificateExample/SecretsCertificateExample.csproj @@ -1,23 +1,23 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - PreserveNewest - - - PreserveNewest - - - - + + + + Exe + net9.0 + enable + enable + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/src/Examples/SecretsCertificateExample/appsettings.encrypted.json b/src/Examples/SecretsCertificateExample/appsettings.encrypted.json index 39418a8..ebd1429 100644 --- a/src/Examples/SecretsCertificateExample/appsettings.encrypted.json +++ b/src/Examples/SecretsCertificateExample/appsettings.encrypted.json @@ -1,35 +1,35 @@ -{ - "Database": { - "Host": "prod-db.example.com", - "Port": 5432, - "Name": "production_db", - "Password": { - "__cocoar_secret__": "v1", - "kid": "prod-secrets", - "alg": "RSA-OAEP-AES256-GCM", - "type": "utf8", - "comment": "This would be a real encrypted envelope from CI/CD - example only", - "iv": "fake_base64_iv", - "ct": "fake_base64_ciphertext", - "tag": "fake_base64_tag", - "wk": "fake_base64_wrapped_key" - } - }, - "ExternalApi": { - "Endpoint": "https://api.production.example.com", - "ApiKey": { - "__cocoar_secret__": "v1", - "kid": "prod-secrets", - "alg": "RSA-OAEP-AES256-GCM", - "type": "utf8", - "comment": "Pre-encrypted by CI/CD pipeline", - "iv": "fake_base64_iv_2", - "ct": "fake_base64_ciphertext_2", - "tag": "fake_base64_tag_2", - "wk": "fake_base64_wrapped_key_2" - } - }, - "Features": { - "EnableBetaFeatures": true - } -} +{ + "Database": { + "Host": "prod-db.example.com", + "Port": 5432, + "Name": "production_db", + "Password": { + "__cocoar_secret__": "v1", + "kid": "prod-secrets", + "alg": "RSA-OAEP-AES256-GCM", + "type": "utf8", + "comment": "This would be a real encrypted envelope from CI/CD - example only", + "iv": "fake_base64_iv", + "ct": "fake_base64_ciphertext", + "tag": "fake_base64_tag", + "wk": "fake_base64_wrapped_key" + } + }, + "ExternalApi": { + "Endpoint": "https://api.production.example.com", + "ApiKey": { + "__cocoar_secret__": "v1", + "kid": "prod-secrets", + "alg": "RSA-OAEP-AES256-GCM", + "type": "utf8", + "comment": "Pre-encrypted by CI/CD pipeline", + "iv": "fake_base64_iv_2", + "ct": "fake_base64_ciphertext_2", + "tag": "fake_base64_tag_2", + "wk": "fake_base64_wrapped_key_2" + } + }, + "Features": { + "EnableBetaFeatures": true + } +} diff --git a/src/Examples/SecretsCertificateExample/appsettings.json b/src/Examples/SecretsCertificateExample/appsettings.json index a24ae95..6a3c26a 100644 --- a/src/Examples/SecretsCertificateExample/appsettings.json +++ b/src/Examples/SecretsCertificateExample/appsettings.json @@ -1,11 +1,11 @@ -{ - "Database": { - "Host": "localhost", - "Port": 5432, - "Name": "production_db", - "Password": "PlainTextDevPassword123!" - }, - "Features": { - "EnableBetaFeatures": false - } -} +{ + "Database": { + "Host": "localhost", + "Port": 5432, + "Name": "production_db", + "Password": "PlainTextDevPassword123!" + }, + "Features": { + "EnableBetaFeatures": false + } +} diff --git a/src/Examples/SimplifiedCoreExample/Program.cs b/src/Examples/SimplifiedCoreExample/Program.cs index 5969924..f791c65 100644 --- a/src/Examples/SimplifiedCoreExample/Program.cs +++ b/src/Examples/SimplifiedCoreExample/Program.cs @@ -1,135 +1,135 @@ -using Cocoar.Configuration; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Rules; - -namespace SimplifiedCoreExample; - -// Configuration POCOs (no attributes, no interface exposure here - pure data) -public class AppConfig -{ - public string ApplicationName { get; set; } = ""; - public string Version { get; set; } = ""; - public string Environment { get; set; } = ""; - public string LogLevel { get; set; } = "Information"; -} - -public class DatabaseConfig -{ - public string ConnectionString { get; set; } = ""; - public int CommandTimeout { get; set; } = 30; - public int MaxRetries { get; set; } = 3; -} - -public class FeatureConfig -{ - public bool EnableNewDashboard { get; set; } - public bool EnableExperimentalFeatures { get; set; } - public int MaxConcurrentUsers { get; set; } = 50; - public bool CacheEnabled { get; set; } = true; -} - -public static class Program -{ - public static void Main(string[] args) - { - Console.WriteLine("=== Cocoar.Configuration Simplified Core Example ==="); - Console.WriteLine("(No DI, no interface exposure - direct concrete type retrieval only)"); - Console.WriteLine(); - - // 1. Build rules using the function-based API - var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/app.json")), - - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/database.json")), - - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/features.json")) - ])); - - Console.WriteLine("📋 Configuration Manager initialized"); - Console.WriteLine(); - - // 3. Retrieve configurations by concrete type only - try - { - var appConfig = manager.GetConfig(); - var dbConfig = manager.GetConfig(); - var featureConfig = manager.GetConfig(); - - Console.WriteLine("✅ Successfully loaded all configurations:"); - Console.WriteLine(); - - Console.WriteLine("🏗️ App Configuration:"); - Console.WriteLine(" Name: {0}", appConfig?.ApplicationName); - Console.WriteLine(" Version: {0}", appConfig?.Version); - Console.WriteLine(" Environment: {0}", appConfig?.Environment); - Console.WriteLine(" LogLevel: {0}", appConfig?.LogLevel); - Console.WriteLine(); - - Console.WriteLine("🗄️ Database Configuration:"); - Console.WriteLine(" ConnectionString: {0}", dbConfig?.ConnectionString != null ? MaskConnectionString(dbConfig.ConnectionString) : "N/A"); - Console.WriteLine(" CommandTimeout: {0}s", dbConfig?.CommandTimeout); - Console.WriteLine(" MaxRetries: {0}", dbConfig?.MaxRetries); - Console.WriteLine(); - - Console.WriteLine("🎛️ Feature Configuration:"); - Console.WriteLine(" EnableNewDashboard: {0}", featureConfig?.EnableNewDashboard); - Console.WriteLine(" EnableExperimentalFeatures: {0}", featureConfig?.EnableExperimentalFeatures); - Console.WriteLine(" MaxConcurrentUsers: {0}", featureConfig?.MaxConcurrentUsers); - Console.WriteLine(" CacheEnabled: {0}", featureConfig?.CacheEnabled); - Console.WriteLine(); - - // 4. Demonstrate rule layering by creating a scenario with overrides - Console.WriteLine("🔄 Demonstrating rule layering (later rules override earlier ones):"); - - var layeredManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - // Base configuration - rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/app.json")), - - // Override via static JSON (simulating environment-specific override) - rule.For().FromStaticJson("""{"Environment": "Production", "LogLevel": "Warning"}""") - ])); - - var overriddenApp = layeredManager.GetConfig(); - - Console.WriteLine(" Original Environment: Development → Overridden: {0}", overriddenApp?.Environment); - Console.WriteLine(" Original LogLevel: Information → Overridden: {0}", overriddenApp?.LogLevel); - Console.WriteLine(" ApplicationName (unchanged): {0}", overriddenApp?.ApplicationName); - Console.WriteLine(); - - // 5. Show configuration access patterns - Console.WriteLine("📖 Key API Patterns in Simplified Core:"); - Console.WriteLine(" ✓ rule.For().FromFile(...)"); - Console.WriteLine(" ✓ rule.Static(...).For()"); - Console.WriteLine(" ✓ ConfigManager.Create(c => c.UseConfiguration(rule => [...]))"); - Console.WriteLine(" ✓ manager.GetConfig()"); - Console.WriteLine(" ✗ No .As() (removed from core)"); - Console.WriteLine(" ✗ No service lifetimes (moved to DI package)"); - Console.WriteLine(" ✗ No AddCocoarConfiguration() (moved to DI package)"); - Console.WriteLine(); - - Console.WriteLine("🎯 This example demonstrates the core library after DI separation."); - Console.WriteLine(" For DI integration, use Cocoar.Configuration.DI package."); - Console.WriteLine(" For interface exposure, future TypeExposureRegistry will be added."); - } - catch (Exception ex) - { - Console.WriteLine("❌ Error loading configuration: {0}", ex.Message); - Console.WriteLine(" Stack trace: {0}", ex.StackTrace); - } - - Console.WriteLine(); - Console.WriteLine("Press any key to exit..."); - Console.ReadKey(); - } - - private static string MaskConnectionString(string connectionString) - { - // Simple masking for demo purposes - if (connectionString.Length > 20) - return connectionString.Substring(0, 20) + "***"; - return connectionString; - } -} - +using Cocoar.Configuration; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Rules; + +namespace SimplifiedCoreExample; + +// Configuration POCOs (no attributes, no interface exposure here - pure data) +public class AppConfig +{ + public string ApplicationName { get; set; } = ""; + public string Version { get; set; } = ""; + public string Environment { get; set; } = ""; + public string LogLevel { get; set; } = "Information"; +} + +public class DatabaseConfig +{ + public string ConnectionString { get; set; } = ""; + public int CommandTimeout { get; set; } = 30; + public int MaxRetries { get; set; } = 3; +} + +public class FeatureConfig +{ + public bool EnableNewDashboard { get; set; } + public bool EnableExperimentalFeatures { get; set; } + public int MaxConcurrentUsers { get; set; } = 50; + public bool CacheEnabled { get; set; } = true; +} + +public static class Program +{ + public static void Main(string[] args) + { + Console.WriteLine("=== Cocoar.Configuration Simplified Core Example ==="); + Console.WriteLine("(No DI, no interface exposure - direct concrete type retrieval only)"); + Console.WriteLine(); + + // 1. Build rules using the function-based API + var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/app.json")), + + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/database.json")), + + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/features.json")) + ])); + + Console.WriteLine("📋 Configuration Manager initialized"); + Console.WriteLine(); + + // 3. Retrieve configurations by concrete type only + try + { + var appConfig = manager.GetConfig(); + var dbConfig = manager.GetConfig(); + var featureConfig = manager.GetConfig(); + + Console.WriteLine("✅ Successfully loaded all configurations:"); + Console.WriteLine(); + + Console.WriteLine("🏗️ App Configuration:"); + Console.WriteLine(" Name: {0}", appConfig?.ApplicationName); + Console.WriteLine(" Version: {0}", appConfig?.Version); + Console.WriteLine(" Environment: {0}", appConfig?.Environment); + Console.WriteLine(" LogLevel: {0}", appConfig?.LogLevel); + Console.WriteLine(); + + Console.WriteLine("🗄️ Database Configuration:"); + Console.WriteLine(" ConnectionString: {0}", dbConfig?.ConnectionString != null ? MaskConnectionString(dbConfig.ConnectionString) : "N/A"); + Console.WriteLine(" CommandTimeout: {0}s", dbConfig?.CommandTimeout); + Console.WriteLine(" MaxRetries: {0}", dbConfig?.MaxRetries); + Console.WriteLine(); + + Console.WriteLine("🎛️ Feature Configuration:"); + Console.WriteLine(" EnableNewDashboard: {0}", featureConfig?.EnableNewDashboard); + Console.WriteLine(" EnableExperimentalFeatures: {0}", featureConfig?.EnableExperimentalFeatures); + Console.WriteLine(" MaxConcurrentUsers: {0}", featureConfig?.MaxConcurrentUsers); + Console.WriteLine(" CacheEnabled: {0}", featureConfig?.CacheEnabled); + Console.WriteLine(); + + // 4. Demonstrate rule layering by creating a scenario with overrides + Console.WriteLine("🔄 Demonstrating rule layering (later rules override earlier ones):"); + + var layeredManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + // Base configuration + rule.For().FromFile(_ => FileSourceRuleOptions.FromFilePath("config/app.json")), + + // Override via static JSON (simulating environment-specific override) + rule.For().FromStaticJson("""{"Environment": "Production", "LogLevel": "Warning"}""") + ])); + + var overriddenApp = layeredManager.GetConfig(); + + Console.WriteLine(" Original Environment: Development → Overridden: {0}", overriddenApp?.Environment); + Console.WriteLine(" Original LogLevel: Information → Overridden: {0}", overriddenApp?.LogLevel); + Console.WriteLine(" ApplicationName (unchanged): {0}", overriddenApp?.ApplicationName); + Console.WriteLine(); + + // 5. Show configuration access patterns + Console.WriteLine("📖 Key API Patterns in Simplified Core:"); + Console.WriteLine(" ✓ rule.For().FromFile(...)"); + Console.WriteLine(" ✓ rule.Static(...).For()"); + Console.WriteLine(" ✓ ConfigManager.Create(c => c.UseConfiguration(rule => [...]))"); + Console.WriteLine(" ✓ manager.GetConfig()"); + Console.WriteLine(" ✗ No .As() (removed from core)"); + Console.WriteLine(" ✗ No service lifetimes (moved to DI package)"); + Console.WriteLine(" ✗ No AddCocoarConfiguration() (moved to DI package)"); + Console.WriteLine(); + + Console.WriteLine("🎯 This example demonstrates the core library after DI separation."); + Console.WriteLine(" For DI integration, use Cocoar.Configuration.DI package."); + Console.WriteLine(" For interface exposure, future TypeExposureRegistry will be added."); + } + catch (Exception ex) + { + Console.WriteLine("❌ Error loading configuration: {0}", ex.Message); + Console.WriteLine(" Stack trace: {0}", ex.StackTrace); + } + + Console.WriteLine(); + Console.WriteLine("Press any key to exit..."); + Console.ReadKey(); + } + + private static string MaskConnectionString(string connectionString) + { + // Simple masking for demo purposes + if (connectionString.Length > 20) + return connectionString.Substring(0, 20) + "***"; + return connectionString; + } +} + diff --git a/src/Examples/SimplifiedCoreExample/README.md b/src/Examples/SimplifiedCoreExample/README.md index 804c41a..a11d6ba 100644 --- a/src/Examples/SimplifiedCoreExample/README.md +++ b/src/Examples/SimplifiedCoreExample/README.md @@ -1,72 +1,72 @@ -# Simplified Core Example - -This example demonstrates the **simplified core API** after removing DI constructs from `Cocoar.Configuration`. - -## What This Shows - -- **Pure concrete type configuration**: Only `For()` - no interfaces, no lifetimes -- **Manual ConfigManager usage**: No DI integration - direct instantiation and retrieval -- **Rule layering**: Later rules override earlier ones (last-write-wins semantics) -- **Multiple file sources**: Separate JSON files for different configuration areas - -## Key API Changes - -### ✅ Simplified Rule Building -```csharp -// OLD (with DI/interface concerns mixed in) -Rule.From.File("app.json").For().As(ServiceLifetime.Singleton) - -// NEW (core responsibility only) -Rule.From.File("app.json").For() -``` - -### ✅ Direct Manager Usage -```csharp -// Manual configuration manager (no DI) -var manager = ConfigManager.Create(c => c.UseConfiguration(rules)); -var config = manager.GetConfig(); -``` - -### ❌ Removed Features (moved to DI package) -- `.As()` - interface exposure -- `ServiceLifetime` parameters - DI lifetimes -- `AddCocoarConfiguration()` - DI integration -- Service keys - keyed DI registrations - -## Running the Example - -```bash -cd src/Examples/SimplifiedCoreExample -dotnet run -``` - -## File Structure - -``` -config/ -├── app.json # Application metadata -├── database.json # Database connection settings -└── features.json # Feature toggles -``` - -## Expected Output - -The example will: -1. Load three separate configuration objects from JSON files -2. Display their contents in a formatted way -3. Demonstrate rule layering with static JSON override -4. Show the key API patterns and what's changed - -## Migration Notes - -This represents the **core library after DI separation**. For projects needing: -- **DI integration**: Use `Cocoar.Configuration.DI` package -- **Interface exposure**: Future `TypeExposureRegistry` (see proposal docs) -- **ASP.NET Core**: Use `Cocoar.Configuration.AspNetCore` package - -## Purpose - -- Test the simplified core API without breaking existing examples -- Validate that basic configuration loading still works without DI -- Document the new mental model: rules are purely about data acquisition -- Provide a reference for core-only usage scenarios +# Simplified Core Example + +This example demonstrates the **simplified core API** after removing DI constructs from `Cocoar.Configuration`. + +## What This Shows + +- **Pure concrete type configuration**: Only `For()` - no interfaces, no lifetimes +- **Manual ConfigManager usage**: No DI integration - direct instantiation and retrieval +- **Rule layering**: Later rules override earlier ones (last-write-wins semantics) +- **Multiple file sources**: Separate JSON files for different configuration areas + +## Key API Changes + +### ✅ Simplified Rule Building +```csharp +// OLD (with DI/interface concerns mixed in) +Rule.From.File("app.json").For().As(ServiceLifetime.Singleton) + +// NEW (core responsibility only) +Rule.From.File("app.json").For() +``` + +### ✅ Direct Manager Usage +```csharp +// Manual configuration manager (no DI) +var manager = ConfigManager.Create(c => c.UseConfiguration(rules)); +var config = manager.GetConfig(); +``` + +### ❌ Removed Features (moved to DI package) +- `.As()` - interface exposure +- `ServiceLifetime` parameters - DI lifetimes +- `AddCocoarConfiguration()` - DI integration +- Service keys - keyed DI registrations + +## Running the Example + +```bash +cd src/Examples/SimplifiedCoreExample +dotnet run +``` + +## File Structure + +``` +config/ +├── app.json # Application metadata +├── database.json # Database connection settings +└── features.json # Feature toggles +``` + +## Expected Output + +The example will: +1. Load three separate configuration objects from JSON files +2. Display their contents in a formatted way +3. Demonstrate rule layering with static JSON override +4. Show the key API patterns and what's changed + +## Migration Notes + +This represents the **core library after DI separation**. For projects needing: +- **DI integration**: Use `Cocoar.Configuration.DI` package +- **Interface exposure**: Future `TypeExposureRegistry` (see proposal docs) +- **ASP.NET Core**: Use `Cocoar.Configuration.AspNetCore` package + +## Purpose + +- Test the simplified core API without breaking existing examples +- Validate that basic configuration loading still works without DI +- Document the new mental model: rules are purely about data acquisition +- Provide a reference for core-only usage scenarios diff --git a/src/Examples/SimplifiedCoreExample/SimplifiedCoreExample.csproj b/src/Examples/SimplifiedCoreExample/SimplifiedCoreExample.csproj index 23f0903..3e35d95 100644 --- a/src/Examples/SimplifiedCoreExample/SimplifiedCoreExample.csproj +++ b/src/Examples/SimplifiedCoreExample/SimplifiedCoreExample.csproj @@ -1,26 +1,26 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - Always - - - Always - - - Always - - - + + + + Exe + net9.0 + enable + enable + + + + + + + + + Always + + + Always + + + Always + + + \ No newline at end of file diff --git a/src/Examples/SimplifiedCoreExample/config/app.json b/src/Examples/SimplifiedCoreExample/config/app.json index 1ebafcb..270da7b 100644 --- a/src/Examples/SimplifiedCoreExample/config/app.json +++ b/src/Examples/SimplifiedCoreExample/config/app.json @@ -1,6 +1,6 @@ -{ - "ApplicationName": "SimplifiedCoreExample", - "Version": "1.0.0", - "Environment": "Development", - "LogLevel": "Information" +{ + "ApplicationName": "SimplifiedCoreExample", + "Version": "1.0.0", + "Environment": "Development", + "LogLevel": "Information" } \ No newline at end of file diff --git a/src/Examples/SimplifiedCoreExample/config/database.json b/src/Examples/SimplifiedCoreExample/config/database.json index 31b72ef..67cb44f 100644 --- a/src/Examples/SimplifiedCoreExample/config/database.json +++ b/src/Examples/SimplifiedCoreExample/config/database.json @@ -1,5 +1,5 @@ -{ - "ConnectionString": "Server=localhost;Database=ExampleDb;Integrated Security=true;", - "CommandTimeout": 30, - "MaxRetries": 3 +{ + "ConnectionString": "Server=localhost;Database=ExampleDb;Integrated Security=true;", + "CommandTimeout": 30, + "MaxRetries": 3 } \ No newline at end of file diff --git a/src/Examples/SimplifiedCoreExample/config/features.json b/src/Examples/SimplifiedCoreExample/config/features.json index 9a51715..5863178 100644 --- a/src/Examples/SimplifiedCoreExample/config/features.json +++ b/src/Examples/SimplifiedCoreExample/config/features.json @@ -1,6 +1,6 @@ -{ - "EnableNewDashboard": true, - "EnableExperimentalFeatures": false, - "MaxConcurrentUsers": 100, - "CacheEnabled": true +{ + "EnableNewDashboard": true, + "EnableExperimentalFeatures": false, + "MaxConcurrentUsers": 100, + "CacheEnabled": true } \ No newline at end of file diff --git a/src/Examples/StaticProviderExample/Program.cs b/src/Examples/StaticProviderExample/Program.cs index 9574b2f..7e40f07 100644 --- a/src/Examples/StaticProviderExample/Program.cs +++ b/src/Examples/StaticProviderExample/Program.cs @@ -1,92 +1,92 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Providers; - -namespace Examples.StaticProviderExample; - -public sealed class CoreDefaults -{ - public string Feature { get; set; } = "A"; - public bool Enabled { get; set; } = true; - public int Priority { get; set; } = 1; -} - -public sealed class DatabaseSettings -{ - public string ConnectionString { get; set; } = string.Empty; - public int TimeoutSeconds { get; set; } = 30; - public bool EnableRetries { get; set; } = true; -} - -public sealed class Wrapper -{ - public CoreDefaults? Inner { get; set; } -} - -public static class Program -{ - public static void Main(string[] args) - { - Console.WriteLine("=== StaticJsonProvider Demo: JSON Strings + Factory Functions ===\n"); - - var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - // 1. JSON string approach - great for configuration templates, testing, defaults - rule.For().FromStaticJson(""" - { - "Feature": "JsonBasedFeature", - "Enabled": true, - "Priority": 5 - } - """), - - // 2. Direct JSON string for database configuration - rule.For().FromStaticJson(""" - { - "ConnectionString": "Server=localhost;Database=MyApp;Integrated Security=true", - "TimeoutSeconds": 45, - "EnableRetries": true - } - """), - - // 3. Factory approach - dynamic composition using previously resolved configs - rule.For().FromStatic(cm => { - var coreConfig = cm.GetConfig()!; - return new Wrapper { - Inner = new CoreDefaults { - Feature = $"Enhanced_{coreConfig.Feature}", - Enabled = coreConfig.Enabled, - Priority = coreConfig.Priority + 10 - } - }; - }) - ])); - - // Retrieve and display configurations - var coreDefaults = manager.GetConfig()!; - var dbSettings = manager.GetConfig()!; - var wrapper = manager.GetConfig()!; - - Console.WriteLine("📋 Core Configuration (from JSON string):"); - Console.WriteLine($" Feature: {coreDefaults.Feature}"); - Console.WriteLine($" Enabled: {coreDefaults.Enabled}"); - Console.WriteLine($" Priority: {coreDefaults.Priority}"); - Console.WriteLine(); - - Console.WriteLine("🗄️ Database Configuration (from JSON string):"); - Console.WriteLine($" Connection: {dbSettings.ConnectionString}"); - Console.WriteLine($" Timeout: {dbSettings.TimeoutSeconds}s"); - Console.WriteLine($" Retries: {dbSettings.EnableRetries}"); - Console.WriteLine(); - - Console.WriteLine("🔧 Wrapper Configuration (from factory using dependency):"); - Console.WriteLine($" Enhanced Feature: {wrapper.Inner?.Feature}"); - Console.WriteLine($" Enabled: {wrapper.Inner?.Enabled}"); - Console.WriteLine($" Enhanced Priority: {wrapper.Inner?.Priority}"); - Console.WriteLine(); - - Console.WriteLine("✅ Demonstrates:"); - Console.WriteLine(" • JSON string support for StaticJsonProvider"); - Console.WriteLine(" • Factory functions for dynamic composition"); - Console.WriteLine(" • Layered configuration with dependency injection"); - Console.WriteLine(" • Each rule gets isolated provider instances (no sharing)"); - } -} +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; + +namespace Examples.StaticProviderExample; + +public sealed class CoreDefaults +{ + public string Feature { get; set; } = "A"; + public bool Enabled { get; set; } = true; + public int Priority { get; set; } = 1; +} + +public sealed class DatabaseSettings +{ + public string ConnectionString { get; set; } = string.Empty; + public int TimeoutSeconds { get; set; } = 30; + public bool EnableRetries { get; set; } = true; +} + +public sealed class Wrapper +{ + public CoreDefaults? Inner { get; set; } +} + +public static class Program +{ + public static void Main(string[] args) + { + Console.WriteLine("=== StaticJsonProvider Demo: JSON Strings + Factory Functions ===\n"); + + var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + // 1. JSON string approach - great for configuration templates, testing, defaults + rule.For().FromStaticJson(""" + { + "Feature": "JsonBasedFeature", + "Enabled": true, + "Priority": 5 + } + """), + + // 2. Direct JSON string for database configuration + rule.For().FromStaticJson(""" + { + "ConnectionString": "Server=localhost;Database=MyApp;Integrated Security=true", + "TimeoutSeconds": 45, + "EnableRetries": true + } + """), + + // 3. Factory approach - dynamic composition using previously resolved configs + rule.For().FromStatic(cm => { + var coreConfig = cm.GetConfig()!; + return new Wrapper { + Inner = new CoreDefaults { + Feature = $"Enhanced_{coreConfig.Feature}", + Enabled = coreConfig.Enabled, + Priority = coreConfig.Priority + 10 + } + }; + }) + ])); + + // Retrieve and display configurations + var coreDefaults = manager.GetConfig()!; + var dbSettings = manager.GetConfig()!; + var wrapper = manager.GetConfig()!; + + Console.WriteLine("📋 Core Configuration (from JSON string):"); + Console.WriteLine($" Feature: {coreDefaults.Feature}"); + Console.WriteLine($" Enabled: {coreDefaults.Enabled}"); + Console.WriteLine($" Priority: {coreDefaults.Priority}"); + Console.WriteLine(); + + Console.WriteLine("🗄️ Database Configuration (from JSON string):"); + Console.WriteLine($" Connection: {dbSettings.ConnectionString}"); + Console.WriteLine($" Timeout: {dbSettings.TimeoutSeconds}s"); + Console.WriteLine($" Retries: {dbSettings.EnableRetries}"); + Console.WriteLine(); + + Console.WriteLine("🔧 Wrapper Configuration (from factory using dependency):"); + Console.WriteLine($" Enhanced Feature: {wrapper.Inner?.Feature}"); + Console.WriteLine($" Enabled: {wrapper.Inner?.Enabled}"); + Console.WriteLine($" Enhanced Priority: {wrapper.Inner?.Priority}"); + Console.WriteLine(); + + Console.WriteLine("✅ Demonstrates:"); + Console.WriteLine(" • JSON string support for StaticJsonProvider"); + Console.WriteLine(" • Factory functions for dynamic composition"); + Console.WriteLine(" • Layered configuration with dependency injection"); + Console.WriteLine(" • Each rule gets isolated provider instances (no sharing)"); + } +} diff --git a/src/Examples/StaticProviderExample/StaticProviderExample.csproj b/src/Examples/StaticProviderExample/StaticProviderExample.csproj index 3e49e88..537f257 100644 --- a/src/Examples/StaticProviderExample/StaticProviderExample.csproj +++ b/src/Examples/StaticProviderExample/StaticProviderExample.csproj @@ -1,16 +1,16 @@ - - - Exe - net9.0 - enable - enable - true - Examples.StaticProviderExample - - - - - - - + + + Exe + net9.0 + enable + enable + true + Examples.StaticProviderExample + + + + + + + \ No newline at end of file diff --git a/src/Examples/TestingOverridesExample/Program.cs b/src/Examples/TestingOverridesExample/Program.cs index 679d80a..8ed8db3 100644 --- a/src/Examples/TestingOverridesExample/Program.cs +++ b/src/Examples/TestingOverridesExample/Program.cs @@ -1,40 +1,40 @@ -using Cocoar.Configuration.AspNetCore; -using Cocoar.Configuration.Providers; - -namespace Examples.TestingOverridesExample; - -public class DbConfig -{ - public string ConnectionString { get; set; } = ""; - public int MaxConnections { get; set; } = 10; -} - -public class ApiSettings -{ - public string BaseUrl { get; set; } = ""; - public string ApiKey { get; set; } = ""; -} - -public partial class Program -{ - public static void Main(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - // Regular configuration - these rules would normally run - builder.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromFile("config.json").Select("Database"), - rule.For().FromFile("config.json").Select("Api") - ])); - - var app = builder.Build(); - - app.MapGet("/config", (DbConfig db, ApiSettings api) => new - { - Database = new { db.ConnectionString, db.MaxConnections }, - Api = new { api.BaseUrl, api.ApiKey } - }); - - app.Run(); - } -} +using Cocoar.Configuration.AspNetCore; +using Cocoar.Configuration.Providers; + +namespace Examples.TestingOverridesExample; + +public class DbConfig +{ + public string ConnectionString { get; set; } = ""; + public int MaxConnections { get; set; } = 10; +} + +public class ApiSettings +{ + public string BaseUrl { get; set; } = ""; + public string ApiKey { get; set; } = ""; +} + +public partial class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + // Regular configuration - these rules would normally run + builder.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + rule.For().FromFile("config.json").Select("Database"), + rule.For().FromFile("config.json").Select("Api") + ])); + + var app = builder.Build(); + + app.MapGet("/config", (DbConfig db, ApiSettings api) => new + { + Database = new { db.ConnectionString, db.MaxConnections }, + Api = new { api.BaseUrl, api.ApiKey } + }); + + app.Run(); + } +} diff --git a/src/Examples/TestingOverridesExample/Properties/launchSettings.json b/src/Examples/TestingOverridesExample/Properties/launchSettings.json index 2569cb3..6073068 100644 --- a/src/Examples/TestingOverridesExample/Properties/launchSettings.json +++ b/src/Examples/TestingOverridesExample/Properties/launchSettings.json @@ -1,12 +1,12 @@ -{ - "profiles": { - "TestingOverridesExample": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:51543;http://localhost:51544" - } - } +{ + "profiles": { + "TestingOverridesExample": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:51543;http://localhost:51544" + } + } } \ No newline at end of file diff --git a/src/Examples/TestingOverridesExample/README.md b/src/Examples/TestingOverridesExample/README.md index 1a7c0d9..8a3123a 100644 --- a/src/Examples/TestingOverridesExample/README.md +++ b/src/Examples/TestingOverridesExample/README.md @@ -1,267 +1,267 @@ -# Testing Overrides Example - -This example demonstrates how to override Cocoar configuration in integration tests using `CocoarTestConfiguration`. - -## Problem Solved - -When writing integration tests with `WebApplicationFactory`, you often need to: -- Replace production configuration with test-specific values -- Skip providers that would fail in test environments (HTTP endpoints, missing files) -- Partially override some configs while keeping others from the original sources - -Standard ASP.NET Core allows this: -```csharp -new WebApplicationFactory() - .WithWebHostBuilder(builder => { - builder.UseSetting("ConnectionStrings:Postgres", testConnectionString); - }); -``` - -But with Cocoar.Configuration, you want the same capability using the rule-based API. - -## Solution: CocoarTestConfiguration - -Set test configuration **before** creating `ConfigManager` (or `WebApplicationFactory`) using `AsyncLocal` context. - -**Works universally:** -- ✅ Direct `ConfigManager.Create(...)` instantiation -- ✅ `ConfigManager.CreateAsync(...)` async factory -- ✅ `services.AddCocoarConfiguration(...)` in DI -- ✅ `builder.AddCocoarConfiguration(...)` in ASP.NET Core -- ✅ `WebApplicationFactory` in integration tests - -### Option 1: Replace All Rules (Skip Original Providers) - -```csharp -[Fact] -public async Task TestWithReplacedConfig() -{ - // Set test configuration BEFORE creating ConfigManager - using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig { - ConnectionString = testDb - }) - ]); - - await using var factory = new WebApplicationFactory(); - // Original rules (FromFile, FromHttp, etc.) are SKIPPED - // Only test rules run -} -``` - -**Use when:** -- Original providers would fail (HTTP endpoint unavailable, files missing) -- You want complete test isolation -- Performance: faster tests (no I/O from original providers) - -### Option 2: Append Test Rules (Partial Override) - -```csharp -[Fact] -public async Task TestWithPartialOverride() -{ - // Append test rules to end (last-write-wins) - using var _ = CocoarTestConfiguration.AppendConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig { - ConnectionString = testDb - }) - ]); - - await using var factory = new WebApplicationFactory(); - // Original rules run first, then test rules override specific values -} -``` - -**Use when:** -- You only need to override some configuration values -- Other configs can come from original sources (files, environment) -- Partial testing scenarios - -### Option 3: Replace Secrets Setup (Independent of Rule Mode) - -```csharp -[Fact] -public async Task TestWithPlaintextSecrets() -{ - // Override secrets setup only — original rules still run - using var _ = CocoarTestConfiguration - .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); - - await using var factory = new WebApplicationFactory(); - // Secrets can now be provided as plaintext in test fixtures -} -``` - -**Use when:** -- You need to enable test-specific secrets behavior (e.g., plaintext) -- Original rules should still execute -- Requires `Cocoar.Configuration.Secrets` package - -### Option 4: Mix and Match (Per-Concern Independence) - -Each concern is independent — combine freely: - -```csharp -[Fact] -public async Task TestWithRulesAndSecrets() -{ - // Replace rules AND override secrets setup independently - using var _ = CocoarTestConfiguration - .ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig { - ConnectionString = testDb - }) - ]) - .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); - - await using var factory = new WebApplicationFactory(); -} -``` - -```csharp -[Fact] -public async Task TestAppendWithSecrets() -{ - // Append rules AND override secrets setup - using var _ = CocoarTestConfiguration - .AppendConfiguration(rule => [ - rule.For().FromStatic(_ => new FeatureFlags { NewFeature = true }) - ]) - .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); - - await using var factory = new WebApplicationFactory(); -} -``` - -## Running the Example - -```bash -cd src/Examples/TestingOverridesExample -dotnet test -``` - -## Key Points - -1. **Universal Application** - Works with any ConfigManager instantiation method (direct, DI, AspNetCore) -2. **AsyncLocal Context** - Test configuration flows through async/await automatically -3. **No Application Changes** - `Program.cs` doesn't need test-aware code -4. **Per-Test Isolation** - Each test can set different configuration -5. **Per-Concern Independence** - Rules mode and secrets setup override independently -6. **Clean Up** - `using var _` disposes the scope; or call `CocoarTestConfiguration.Clear()` - -## Direct ConfigManager Usage - -Test overrides also work when creating ConfigManager directly (without DI): - -```csharp -[Fact] -public void DirectConfigManagerTest() -{ - using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => testConfig) - ]); - - // Works with direct instantiation - var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromFile("config.json") // SKIPPED in test - ])); - - var config = configManager.GetConfig()!; - // config comes from test rules, not file -} -``` - -Also works with `ConfigManager.CreateAsync()`: - -```csharp -[Fact] -public async Task DirectConfigManagerAsyncTest() -{ - using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => testConfig) - ]); - - var configManager = await ConfigManager.CreateAsync(c => c.UseConfiguration(rule => [ - rule.For().FromFile("config.json") // SKIPPED in test - ])); - - var config = configManager.GetConfig()!; -} -``` - -## Fixture-Based Pattern (Centralized Config) - -For test classes sharing the same configuration, use fixtures with `Apply()`: - -```csharp -// Fixture holds the shared config context -public class IntegrationTestFixture -{ - public TestConfigurationContext TestContext { get; } = - TestConfigurationContext.Replace( - rule => [ - rule.For().FromStatic(_ => new DbConfig { Connection = "test-db" }), - rule.For().FromStatic(_ => new ApiSettings { BaseUrl = "https://test.api" }) - ]); -} - -// Test class applies it in constructor -public class MyTests : IClassFixture, IDisposable -{ - public MyTests(IntegrationTestFixture fixture) - { - // Bridge the async context gap - CocoarTestConfiguration.Apply(fixture.TestContext); - } - - public void Dispose() => CocoarTestConfiguration.Clear(); - - [Fact] - public async Task Test1() - { - await using var factory = new WebApplicationFactory(); - // Uses fixture's config - } - - [Fact] - public async Task Test2() - { - // Same config, no repetition - } -} -``` - -**Why Apply()?** AsyncLocal flows within the same async context, but xUnit runs fixture setup and test methods in separate contexts. `Apply()` bridges this gap. - -## Fixture-Based Pattern with Secrets - -Use `TestOverrideBuilder` directly for fixture-based patterns that need secrets: - -```csharp -public class IntegrationTestFixture -{ - public TestConfigurationContext TestContext { get; } = - new TestOverrideBuilder() - .ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig { Connection = "test-db" }) - ]) - .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()) - .Build(); -} - -public class MyTests : IClassFixture, IDisposable -{ - public MyTests(IntegrationTestFixture fixture) - { - CocoarTestConfiguration.Apply(fixture.TestContext); - } - - public void Dispose() => CocoarTestConfiguration.Clear(); -} -``` - -## Related - -- [BasicUsage Example](../BasicUsage) - Simple configuration setup -- [Cocoar.Configuration.Testing](../../Cocoar.Configuration/Testing) - Testing API reference -- [Testing Overrides Quick Reference](../../../docs/testing-overrides-quickref.md) - Full patterns +# Testing Overrides Example + +This example demonstrates how to override Cocoar configuration in integration tests using `CocoarTestConfiguration`. + +## Problem Solved + +When writing integration tests with `WebApplicationFactory`, you often need to: +- Replace production configuration with test-specific values +- Skip providers that would fail in test environments (HTTP endpoints, missing files) +- Partially override some configs while keeping others from the original sources + +Standard ASP.NET Core allows this: +```csharp +new WebApplicationFactory() + .WithWebHostBuilder(builder => { + builder.UseSetting("ConnectionStrings:Postgres", testConnectionString); + }); +``` + +But with Cocoar.Configuration, you want the same capability using the rule-based API. + +## Solution: CocoarTestConfiguration + +Set test configuration **before** creating `ConfigManager` (or `WebApplicationFactory`) using `AsyncLocal` context. + +**Works universally:** +- ✅ Direct `ConfigManager.Create(...)` instantiation +- ✅ `ConfigManager.CreateAsync(...)` async factory +- ✅ `services.AddCocoarConfiguration(...)` in DI +- ✅ `builder.AddCocoarConfiguration(...)` in ASP.NET Core +- ✅ `WebApplicationFactory` in integration tests + +### Option 1: Replace All Rules (Skip Original Providers) + +```csharp +[Fact] +public async Task TestWithReplacedConfig() +{ + // Set test configuration BEFORE creating ConfigManager + using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig { + ConnectionString = testDb + }) + ]); + + await using var factory = new WebApplicationFactory(); + // Original rules (FromFile, FromHttp, etc.) are SKIPPED + // Only test rules run +} +``` + +**Use when:** +- Original providers would fail (HTTP endpoint unavailable, files missing) +- You want complete test isolation +- Performance: faster tests (no I/O from original providers) + +### Option 2: Append Test Rules (Partial Override) + +```csharp +[Fact] +public async Task TestWithPartialOverride() +{ + // Append test rules to end (last-write-wins) + using var _ = CocoarTestConfiguration.AppendConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig { + ConnectionString = testDb + }) + ]); + + await using var factory = new WebApplicationFactory(); + // Original rules run first, then test rules override specific values +} +``` + +**Use when:** +- You only need to override some configuration values +- Other configs can come from original sources (files, environment) +- Partial testing scenarios + +### Option 3: Replace Secrets Setup (Independent of Rule Mode) + +```csharp +[Fact] +public async Task TestWithPlaintextSecrets() +{ + // Override secrets setup only — original rules still run + using var _ = CocoarTestConfiguration + .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); + + await using var factory = new WebApplicationFactory(); + // Secrets can now be provided as plaintext in test fixtures +} +``` + +**Use when:** +- You need to enable test-specific secrets behavior (e.g., plaintext) +- Original rules should still execute +- Requires `Cocoar.Configuration.Secrets` package + +### Option 4: Mix and Match (Per-Concern Independence) + +Each concern is independent — combine freely: + +```csharp +[Fact] +public async Task TestWithRulesAndSecrets() +{ + // Replace rules AND override secrets setup independently + using var _ = CocoarTestConfiguration + .ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig { + ConnectionString = testDb + }) + ]) + .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); + + await using var factory = new WebApplicationFactory(); +} +``` + +```csharp +[Fact] +public async Task TestAppendWithSecrets() +{ + // Append rules AND override secrets setup + using var _ = CocoarTestConfiguration + .AppendConfiguration(rule => [ + rule.For().FromStatic(_ => new FeatureFlags { NewFeature = true }) + ]) + .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); + + await using var factory = new WebApplicationFactory(); +} +``` + +## Running the Example + +```bash +cd src/Examples/TestingOverridesExample +dotnet test +``` + +## Key Points + +1. **Universal Application** - Works with any ConfigManager instantiation method (direct, DI, AspNetCore) +2. **AsyncLocal Context** - Test configuration flows through async/await automatically +3. **No Application Changes** - `Program.cs` doesn't need test-aware code +4. **Per-Test Isolation** - Each test can set different configuration +5. **Per-Concern Independence** - Rules mode and secrets setup override independently +6. **Clean Up** - `using var _` disposes the scope; or call `CocoarTestConfiguration.Clear()` + +## Direct ConfigManager Usage + +Test overrides also work when creating ConfigManager directly (without DI): + +```csharp +[Fact] +public void DirectConfigManagerTest() +{ + using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => testConfig) + ]); + + // Works with direct instantiation + var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromFile("config.json") // SKIPPED in test + ])); + + var config = configManager.GetConfig()!; + // config comes from test rules, not file +} +``` + +Also works with `ConfigManager.CreateAsync()`: + +```csharp +[Fact] +public async Task DirectConfigManagerAsyncTest() +{ + using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => testConfig) + ]); + + var configManager = await ConfigManager.CreateAsync(c => c.UseConfiguration(rule => [ + rule.For().FromFile("config.json") // SKIPPED in test + ])); + + var config = configManager.GetConfig()!; +} +``` + +## Fixture-Based Pattern (Centralized Config) + +For test classes sharing the same configuration, use fixtures with `Apply()`: + +```csharp +// Fixture holds the shared config context +public class IntegrationTestFixture +{ + public TestConfigurationContext TestContext { get; } = + TestConfigurationContext.Replace( + rule => [ + rule.For().FromStatic(_ => new DbConfig { Connection = "test-db" }), + rule.For().FromStatic(_ => new ApiSettings { BaseUrl = "https://test.api" }) + ]); +} + +// Test class applies it in constructor +public class MyTests : IClassFixture, IDisposable +{ + public MyTests(IntegrationTestFixture fixture) + { + // Bridge the async context gap + CocoarTestConfiguration.Apply(fixture.TestContext); + } + + public void Dispose() => CocoarTestConfiguration.Clear(); + + [Fact] + public async Task Test1() + { + await using var factory = new WebApplicationFactory(); + // Uses fixture's config + } + + [Fact] + public async Task Test2() + { + // Same config, no repetition + } +} +``` + +**Why Apply()?** AsyncLocal flows within the same async context, but xUnit runs fixture setup and test methods in separate contexts. `Apply()` bridges this gap. + +## Fixture-Based Pattern with Secrets + +Use `TestOverrideBuilder` directly for fixture-based patterns that need secrets: + +```csharp +public class IntegrationTestFixture +{ + public TestConfigurationContext TestContext { get; } = + new TestOverrideBuilder() + .ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig { Connection = "test-db" }) + ]) + .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()) + .Build(); +} + +public class MyTests : IClassFixture, IDisposable +{ + public MyTests(IntegrationTestFixture fixture) + { + CocoarTestConfiguration.Apply(fixture.TestContext); + } + + public void Dispose() => CocoarTestConfiguration.Clear(); +} +``` + +## Related + +- [BasicUsage Example](../BasicUsage) - Simple configuration setup +- [Cocoar.Configuration.Testing](../../Cocoar.Configuration/Testing) - Testing API reference +- [Testing Overrides Quick Reference](../../../docs/testing-overrides-quickref.md) - Full patterns diff --git a/src/Examples/TestingOverridesExample/TestingOverridesExample.csproj b/src/Examples/TestingOverridesExample/TestingOverridesExample.csproj index 252f1a5..c657303 100644 --- a/src/Examples/TestingOverridesExample/TestingOverridesExample.csproj +++ b/src/Examples/TestingOverridesExample/TestingOverridesExample.csproj @@ -1,36 +1,36 @@ - - - - Exe - net9.0 - enable - enable - false - Examples.TestingOverridesExample.Program - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + Exe + net9.0 + enable + enable + false + Examples.TestingOverridesExample.Program + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/Examples/TestingOverridesExample/Tests/DirectConfigManagerTests.cs b/src/Examples/TestingOverridesExample/Tests/DirectConfigManagerTests.cs index ce88ac3..28f7eba 100644 --- a/src/Examples/TestingOverridesExample/Tests/DirectConfigManagerTests.cs +++ b/src/Examples/TestingOverridesExample/Tests/DirectConfigManagerTests.cs @@ -1,85 +1,85 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Testing; -using Cocoar.Configuration.Providers; -using Xunit; - -namespace Examples.TestingOverridesExample.Tests; - -public class DirectConfigManagerTests : IDisposable -{ - [Fact] - public void DirectConfigManager_AppliesTestOverrides_ReplaceMode() - { - // Arrange - Set test configuration BEFORE creating ConfigManager - CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig - { - ConnectionString = "Server=test-direct;Database=DirectTest;", - MaxConnections = 42 - }) - ]); - - // Act - Create ConfigManager directly (no DI, no AspNetCore) - var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromFile("config.json").Select("Database") // This will be SKIPPED - ])); - - var dbConfig = configManager.GetConfig()!; - - // Assert - Test rules were used, not config.json - Assert.Equal("Server=test-direct;Database=DirectTest;", dbConfig.ConnectionString); - Assert.Equal(42, dbConfig.MaxConnections); - } - - [Fact] - public void DirectConfigManager_AppliesTestOverrides_AppendMode() - { - // Arrange - CocoarTestConfiguration.AppendConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig - { - MaxConnections = 999 // Override only MaxConnections - }) - ]); - - // Act - var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig - { - ConnectionString = "Server=base;", - MaxConnections = 10 - }) - ])); - - var dbConfig = configManager.GetConfig()!; - - // Assert - Base rule + test override merged (last-write-wins) - Assert.Equal(999, dbConfig.MaxConnections); // From test override - } - - [Fact] - public void DirectConfigManager_WorksNormally_WhenNoTestOverride() - { - // No CocoarTestConfiguration set - - // Act - var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig - { - ConnectionString = "Server=normal;", - MaxConnections = 50 - }) - ])); - - var dbConfig = configManager.GetConfig()!; - - // Assert - Normal behavior - Assert.Equal("Server=normal;", dbConfig.ConnectionString); - Assert.Equal(50, dbConfig.MaxConnections); - } - - public void Dispose() - { - CocoarTestConfiguration.Clear(); - } -} +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Testing; +using Cocoar.Configuration.Providers; +using Xunit; + +namespace Examples.TestingOverridesExample.Tests; + +public class DirectConfigManagerTests : IDisposable +{ + [Fact] + public void DirectConfigManager_AppliesTestOverrides_ReplaceMode() + { + // Arrange - Set test configuration BEFORE creating ConfigManager + CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig + { + ConnectionString = "Server=test-direct;Database=DirectTest;", + MaxConnections = 42 + }) + ]); + + // Act - Create ConfigManager directly (no DI, no AspNetCore) + var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromFile("config.json").Select("Database") // This will be SKIPPED + ])); + + var dbConfig = configManager.GetConfig()!; + + // Assert - Test rules were used, not config.json + Assert.Equal("Server=test-direct;Database=DirectTest;", dbConfig.ConnectionString); + Assert.Equal(42, dbConfig.MaxConnections); + } + + [Fact] + public void DirectConfigManager_AppliesTestOverrides_AppendMode() + { + // Arrange + CocoarTestConfiguration.AppendConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig + { + MaxConnections = 999 // Override only MaxConnections + }) + ]); + + // Act + var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig + { + ConnectionString = "Server=base;", + MaxConnections = 10 + }) + ])); + + var dbConfig = configManager.GetConfig()!; + + // Assert - Base rule + test override merged (last-write-wins) + Assert.Equal(999, dbConfig.MaxConnections); // From test override + } + + [Fact] + public void DirectConfigManager_WorksNormally_WhenNoTestOverride() + { + // No CocoarTestConfiguration set + + // Act + var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig + { + ConnectionString = "Server=normal;", + MaxConnections = 50 + }) + ])); + + var dbConfig = configManager.GetConfig()!; + + // Assert - Normal behavior + Assert.Equal("Server=normal;", dbConfig.ConnectionString); + Assert.Equal(50, dbConfig.MaxConnections); + } + + public void Dispose() + { + CocoarTestConfiguration.Clear(); + } +} diff --git a/src/Examples/TestingOverridesExample/Tests/FixtureBasedTests.cs b/src/Examples/TestingOverridesExample/Tests/FixtureBasedTests.cs index 0d75bbd..49c4928 100644 --- a/src/Examples/TestingOverridesExample/Tests/FixtureBasedTests.cs +++ b/src/Examples/TestingOverridesExample/Tests/FixtureBasedTests.cs @@ -1,234 +1,234 @@ -using Cocoar.Configuration.Testing; -using Cocoar.Configuration.Providers; -using Microsoft.AspNetCore.Mvc.Testing; -using Xunit; - -namespace Examples.TestingOverridesExample.Tests; - -/// -/// Shared test fixture that holds configuration context. -/// The context is created once and reused across all test classes using this fixture. -/// -public class SharedIntegrationTestFixture -{ - /// - /// Pre-built test configuration context built via (fixture pattern). - /// - public TestConfigurationContext TestContext { get; } = - TestConfigurationContext.Replace(rule => [ - rule.For().FromStatic(_ => new DbConfig - { - ConnectionString = "Server=fixture-test-db;Database=FixtureTestDb;", - MaxConnections = 10 - }), - rule.For().FromStatic(_ => new ApiSettings - { - BaseUrl = "https://api.fixture-test.example.com", - ApiKey = "fixture-test-api-key" - }) - ]); -} - -/// -/// Demonstrates the fixture-based pattern for sharing test configuration. -/// The constructor applies the fixture's context to bridge the AsyncLocal gap. -/// -public class FixtureBasedIntegrationTests : IClassFixture, IDisposable -{ - private readonly SharedIntegrationTestFixture _fixture; - - public FixtureBasedIntegrationTests(SharedIntegrationTestFixture fixture) - { - _fixture = fixture; - // Bridge the async context gap - applies fixture's context to test's async context - CocoarTestConfiguration.Apply(_fixture.TestContext); - } - - public void Dispose() - { - CocoarTestConfiguration.Clear(); - } - - [Fact] - public async Task Test1_UsesFixtureConfiguration() - { - // Arrange & Act - Create WebApplicationFactory - await using var factory = new WebApplicationFactory(); - using var client = factory.CreateClient(); - - // Assert - var response = await client.GetAsync("/config"); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("fixture-test-db", content); - Assert.Contains("api.fixture-test.example.com", content); - Assert.Contains("fixture-test-api-key", content); - } - - [Fact] - public async Task Test2_AlsoUsesFixtureConfiguration() - { - // Same configuration as Test1, no repetition needed - - await using var factory = new WebApplicationFactory(); - using var client = factory.CreateClient(); - - var response = await client.GetAsync("/config"); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("fixture-test-db", content); - } - - [Fact] - public void Test3_VerifiesConfigurationIsActive() - { - // The configuration should be active in test methods - Assert.True(CocoarTestConfiguration.IsActive); - Assert.NotNull(CocoarTestConfiguration.Current); - Assert.Equal(TestConfigurationMode.Replace, CocoarTestConfiguration.Current!.ConfigurationMode); - } -} - -/// -/// Demonstrates using TestOverrideBuilder (disposable scope) for automatic cleanup. -/// -public class ScopeBasedTests -{ - [Fact] - public void Scope_ClearsConfigurationOnDispose() - { - // Arrange - Assert.False(CocoarTestConfiguration.IsActive); - - // Act - Create scope - using (var scope = CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig { ConnectionString = "test" }) - ])) - { - Assert.True(CocoarTestConfiguration.IsActive); - } - - // Assert - Automatically cleared - Assert.False(CocoarTestConfiguration.IsActive); - } - - [Fact] - public void Scope_ClearsConfigurationEvenOnException() - { - // Arrange - Assert.False(CocoarTestConfiguration.IsActive); - - try - { - using var scope = CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig { ConnectionString = "test" }) - ]); - - Assert.True(CocoarTestConfiguration.IsActive); - throw new InvalidOperationException("Simulated test failure"); - } - catch (InvalidOperationException) - { - // Expected - } - - // Assert - Still cleared even though exception was thrown - Assert.False(CocoarTestConfiguration.IsActive); - } - - [Fact] - public void AppendConfiguration_ReturnsScope() - { - Assert.False(CocoarTestConfiguration.IsActive); - - using (var scope = CocoarTestConfiguration.AppendConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig { ConnectionString = "test" }) - ])) - { - Assert.True(CocoarTestConfiguration.IsActive); - Assert.Equal(TestConfigurationMode.Append, CocoarTestConfiguration.Current!.ConfigurationMode); - } - - Assert.False(CocoarTestConfiguration.IsActive); - } -} - -/// -/// Tests for TestConfigurationContext factory methods and TestOverrideBuilder. -/// -public class TestConfigurationContextFactoryTests -{ - [Fact] - public void Replace_CreatesContextInReplaceMode() - { - // Act - var context = TestConfigurationContext.Replace(rule => [ - rule.For().FromStatic(_ => new DbConfig { ConnectionString = "test" }) - ]); - - // Assert - Assert.Equal(TestConfigurationMode.Replace, context.ConfigurationMode); - Assert.NotNull(context.Rules); - } - - [Fact] - public void Append_CreatesContextInAppendMode() - { - // Act - var context = TestConfigurationContext.Append(rule => [ - rule.For().FromStatic(_ => new DbConfig { ConnectionString = "test" }) - ]); - - // Assert - Assert.Equal(TestConfigurationMode.Append, context.ConfigurationMode); - Assert.NotNull(context.Rules); - } - - [Fact] - public void Apply_SetsContextFromExistingInstance() - { - // Arrange - var context = TestConfigurationContext.Replace(rule => [ - rule.For().FromStatic(_ => new DbConfig { ConnectionString = "applied-test" }) - ]); - - Assert.False(CocoarTestConfiguration.IsActive); - - // Act - using var scope = CocoarTestConfiguration.Apply(context); - - // Assert - Assert.True(CocoarTestConfiguration.IsActive); - Assert.Same(context, CocoarTestConfiguration.Current); - } - - [Fact] - public void Apply_ThrowsOnNullContext() - { - Assert.Throws(() => CocoarTestConfiguration.Apply(null!)); - } - - [Fact] - public void Replace_ThrowsOnNullRules() - { - Assert.Throws(() => - TestConfigurationContext.Replace(null!)); - } - - [Fact] - public void Append_ThrowsOnNullRules() - { - Assert.Throws(() => - TestConfigurationContext.Append(null!)); - } - - [Fact] - public void TestOverrideBuilder_ReplaceConfiguration_ThrowsOnNullRules() - { - // The public constructor is for the fixture pattern; null rules should throw - Assert.Throws(() => - new TestOverrideBuilder().ReplaceConfiguration(null!)); - } -} +using Cocoar.Configuration.Testing; +using Cocoar.Configuration.Providers; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Examples.TestingOverridesExample.Tests; + +/// +/// Shared test fixture that holds configuration context. +/// The context is created once and reused across all test classes using this fixture. +/// +public class SharedIntegrationTestFixture +{ + /// + /// Pre-built test configuration context built via (fixture pattern). + /// + public TestConfigurationContext TestContext { get; } = + TestConfigurationContext.Replace(rule => [ + rule.For().FromStatic(_ => new DbConfig + { + ConnectionString = "Server=fixture-test-db;Database=FixtureTestDb;", + MaxConnections = 10 + }), + rule.For().FromStatic(_ => new ApiSettings + { + BaseUrl = "https://api.fixture-test.example.com", + ApiKey = "fixture-test-api-key" + }) + ]); +} + +/// +/// Demonstrates the fixture-based pattern for sharing test configuration. +/// The constructor applies the fixture's context to bridge the AsyncLocal gap. +/// +public class FixtureBasedIntegrationTests : IClassFixture, IDisposable +{ + private readonly SharedIntegrationTestFixture _fixture; + + public FixtureBasedIntegrationTests(SharedIntegrationTestFixture fixture) + { + _fixture = fixture; + // Bridge the async context gap - applies fixture's context to test's async context + CocoarTestConfiguration.Apply(_fixture.TestContext); + } + + public void Dispose() + { + CocoarTestConfiguration.Clear(); + } + + [Fact] + public async Task Test1_UsesFixtureConfiguration() + { + // Arrange & Act - Create WebApplicationFactory + await using var factory = new WebApplicationFactory(); + using var client = factory.CreateClient(); + + // Assert + var response = await client.GetAsync("/config"); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("fixture-test-db", content); + Assert.Contains("api.fixture-test.example.com", content); + Assert.Contains("fixture-test-api-key", content); + } + + [Fact] + public async Task Test2_AlsoUsesFixtureConfiguration() + { + // Same configuration as Test1, no repetition needed + + await using var factory = new WebApplicationFactory(); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/config"); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("fixture-test-db", content); + } + + [Fact] + public void Test3_VerifiesConfigurationIsActive() + { + // The configuration should be active in test methods + Assert.True(CocoarTestConfiguration.IsActive); + Assert.NotNull(CocoarTestConfiguration.Current); + Assert.Equal(TestConfigurationMode.Replace, CocoarTestConfiguration.Current!.ConfigurationMode); + } +} + +/// +/// Demonstrates using TestOverrideBuilder (disposable scope) for automatic cleanup. +/// +public class ScopeBasedTests +{ + [Fact] + public void Scope_ClearsConfigurationOnDispose() + { + // Arrange + Assert.False(CocoarTestConfiguration.IsActive); + + // Act - Create scope + using (var scope = CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig { ConnectionString = "test" }) + ])) + { + Assert.True(CocoarTestConfiguration.IsActive); + } + + // Assert - Automatically cleared + Assert.False(CocoarTestConfiguration.IsActive); + } + + [Fact] + public void Scope_ClearsConfigurationEvenOnException() + { + // Arrange + Assert.False(CocoarTestConfiguration.IsActive); + + try + { + using var scope = CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig { ConnectionString = "test" }) + ]); + + Assert.True(CocoarTestConfiguration.IsActive); + throw new InvalidOperationException("Simulated test failure"); + } + catch (InvalidOperationException) + { + // Expected + } + + // Assert - Still cleared even though exception was thrown + Assert.False(CocoarTestConfiguration.IsActive); + } + + [Fact] + public void AppendConfiguration_ReturnsScope() + { + Assert.False(CocoarTestConfiguration.IsActive); + + using (var scope = CocoarTestConfiguration.AppendConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig { ConnectionString = "test" }) + ])) + { + Assert.True(CocoarTestConfiguration.IsActive); + Assert.Equal(TestConfigurationMode.Append, CocoarTestConfiguration.Current!.ConfigurationMode); + } + + Assert.False(CocoarTestConfiguration.IsActive); + } +} + +/// +/// Tests for TestConfigurationContext factory methods and TestOverrideBuilder. +/// +public class TestConfigurationContextFactoryTests +{ + [Fact] + public void Replace_CreatesContextInReplaceMode() + { + // Act + var context = TestConfigurationContext.Replace(rule => [ + rule.For().FromStatic(_ => new DbConfig { ConnectionString = "test" }) + ]); + + // Assert + Assert.Equal(TestConfigurationMode.Replace, context.ConfigurationMode); + Assert.NotNull(context.Rules); + } + + [Fact] + public void Append_CreatesContextInAppendMode() + { + // Act + var context = TestConfigurationContext.Append(rule => [ + rule.For().FromStatic(_ => new DbConfig { ConnectionString = "test" }) + ]); + + // Assert + Assert.Equal(TestConfigurationMode.Append, context.ConfigurationMode); + Assert.NotNull(context.Rules); + } + + [Fact] + public void Apply_SetsContextFromExistingInstance() + { + // Arrange + var context = TestConfigurationContext.Replace(rule => [ + rule.For().FromStatic(_ => new DbConfig { ConnectionString = "applied-test" }) + ]); + + Assert.False(CocoarTestConfiguration.IsActive); + + // Act + using var scope = CocoarTestConfiguration.Apply(context); + + // Assert + Assert.True(CocoarTestConfiguration.IsActive); + Assert.Same(context, CocoarTestConfiguration.Current); + } + + [Fact] + public void Apply_ThrowsOnNullContext() + { + Assert.Throws(() => CocoarTestConfiguration.Apply(null!)); + } + + [Fact] + public void Replace_ThrowsOnNullRules() + { + Assert.Throws(() => + TestConfigurationContext.Replace(null!)); + } + + [Fact] + public void Append_ThrowsOnNullRules() + { + Assert.Throws(() => + TestConfigurationContext.Append(null!)); + } + + [Fact] + public void TestOverrideBuilder_ReplaceConfiguration_ThrowsOnNullRules() + { + // The public constructor is for the fixture pattern; null rules should throw + Assert.Throws(() => + new TestOverrideBuilder().ReplaceConfiguration(null!)); + } +} diff --git a/src/Examples/TestingOverridesExample/Tests/IntegrationTests.cs b/src/Examples/TestingOverridesExample/Tests/IntegrationTests.cs index 49c681e..eb3560d 100644 --- a/src/Examples/TestingOverridesExample/Tests/IntegrationTests.cs +++ b/src/Examples/TestingOverridesExample/Tests/IntegrationTests.cs @@ -1,112 +1,112 @@ -using Cocoar.Configuration.Testing; -using Cocoar.Configuration.Providers; -using Microsoft.AspNetCore.Mvc.Testing; -using Xunit; - -namespace Examples.TestingOverridesExample.Tests; - -public class IntegrationTestsWithReplace : IDisposable -{ - [Fact] - public async Task ReplaceConfiguration_OverridesAllConfiguration() - { - // Arrange - Set test configuration BEFORE creating WebApplicationFactory - CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig - { - ConnectionString = "Server=test-db;Database=TestDb;", - MaxConnections = 5 - }), - rule.For().FromStatic(_ => new ApiSettings - { - BaseUrl = "https://api.test.example.com", - ApiKey = "test-api-key" - }) - ]); - - // Act - Create WebApplicationFactory (config.json will be SKIPPED) - await using var factory = new WebApplicationFactory(); - using var client = factory.CreateClient(); - - // Assert - var response = await client.GetAsync("/config"); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("test-db", content); - Assert.Contains("api.test.example.com", content); - Assert.Contains("test-api-key", content); - - // Verify production values are NOT present - Assert.DoesNotContain("production-db", content); - Assert.DoesNotContain("prod-api-key", content); - } - - public void Dispose() - { - CocoarTestConfiguration.Clear(); - } -} - -public class IntegrationTestsWithAppend : IDisposable -{ - [Fact] - public async Task AppendConfiguration_OverridesSpecificValues() - { - // Arrange - Append test rules (config.json runs first, then test rules override) - CocoarTestConfiguration.AppendConfiguration(rule => [ - rule.For().FromStatic(_ => new DbConfig - { - ConnectionString = "Server=test-db;Database=TestDb;", - // MaxConnections not specified - will use value from config.json - }) - ]); - - // Act - await using var factory = new WebApplicationFactory(); - using var client = factory.CreateClient(); - - // Assert - var response = await client.GetAsync("/config"); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(); - - // DbConfig.ConnectionString overridden by test - Assert.Contains("test-db", content); - - // ApiSettings comes from config.json (not overridden in test) - Assert.Contains("api.production.example.com", content); - Assert.Contains("prod-api-key", content); - } - - public void Dispose() - { - CocoarTestConfiguration.Clear(); - } -} - -public class IntegrationTestsRealWorldScenario -{ - [Fact] - public async Task ReplaceMode_PreventFailureFromMissingHttpEndpoint() - { - // Scenario: App normally polls HTTP endpoint that's unavailable in tests - // Replace mode prevents HTTP provider from running at all - - CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new ApiSettings - { - BaseUrl = "https://test.local", - ApiKey = "test-key" - }) - ]); - - await using var factory = new WebApplicationFactory(); - - // Success - HTTP provider never attempted to connect - Assert.True(CocoarTestConfiguration.IsActive); - - CocoarTestConfiguration.Clear(); - } -} +using Cocoar.Configuration.Testing; +using Cocoar.Configuration.Providers; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Examples.TestingOverridesExample.Tests; + +public class IntegrationTestsWithReplace : IDisposable +{ + [Fact] + public async Task ReplaceConfiguration_OverridesAllConfiguration() + { + // Arrange - Set test configuration BEFORE creating WebApplicationFactory + CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig + { + ConnectionString = "Server=test-db;Database=TestDb;", + MaxConnections = 5 + }), + rule.For().FromStatic(_ => new ApiSettings + { + BaseUrl = "https://api.test.example.com", + ApiKey = "test-api-key" + }) + ]); + + // Act - Create WebApplicationFactory (config.json will be SKIPPED) + await using var factory = new WebApplicationFactory(); + using var client = factory.CreateClient(); + + // Assert + var response = await client.GetAsync("/config"); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("test-db", content); + Assert.Contains("api.test.example.com", content); + Assert.Contains("test-api-key", content); + + // Verify production values are NOT present + Assert.DoesNotContain("production-db", content); + Assert.DoesNotContain("prod-api-key", content); + } + + public void Dispose() + { + CocoarTestConfiguration.Clear(); + } +} + +public class IntegrationTestsWithAppend : IDisposable +{ + [Fact] + public async Task AppendConfiguration_OverridesSpecificValues() + { + // Arrange - Append test rules (config.json runs first, then test rules override) + CocoarTestConfiguration.AppendConfiguration(rule => [ + rule.For().FromStatic(_ => new DbConfig + { + ConnectionString = "Server=test-db;Database=TestDb;", + // MaxConnections not specified - will use value from config.json + }) + ]); + + // Act + await using var factory = new WebApplicationFactory(); + using var client = factory.CreateClient(); + + // Assert + var response = await client.GetAsync("/config"); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + + // DbConfig.ConnectionString overridden by test + Assert.Contains("test-db", content); + + // ApiSettings comes from config.json (not overridden in test) + Assert.Contains("api.production.example.com", content); + Assert.Contains("prod-api-key", content); + } + + public void Dispose() + { + CocoarTestConfiguration.Clear(); + } +} + +public class IntegrationTestsRealWorldScenario +{ + [Fact] + public async Task ReplaceMode_PreventFailureFromMissingHttpEndpoint() + { + // Scenario: App normally polls HTTP endpoint that's unavailable in tests + // Replace mode prevents HTTP provider from running at all + + CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new ApiSettings + { + BaseUrl = "https://test.local", + ApiKey = "test-key" + }) + ]); + + await using var factory = new WebApplicationFactory(); + + // Success - HTTP provider never attempted to connect + Assert.True(CocoarTestConfiguration.IsActive); + + CocoarTestConfiguration.Clear(); + } +} diff --git a/src/Examples/TestingOverridesExample/config.json b/src/Examples/TestingOverridesExample/config.json index 05bcc28..e899cea 100644 --- a/src/Examples/TestingOverridesExample/config.json +++ b/src/Examples/TestingOverridesExample/config.json @@ -1,10 +1,10 @@ -{ - "Database": { - "ConnectionString": "Server=production-db;Database=MyApp;", - "MaxConnections": 100 - }, - "Api": { - "BaseUrl": "https://api.production.example.com", - "ApiKey": "prod-api-key-12345" - } -} +{ + "Database": { + "ConnectionString": "Server=production-db;Database=MyApp;", + "MaxConnections": 100 + }, + "Api": { + "BaseUrl": "https://api.production.example.com", + "ApiKey": "prod-api-key-12345" + } +} diff --git a/src/Examples/TupleReactiveExample/Program.cs b/src/Examples/TupleReactiveExample/Program.cs index 9130302..da066fe 100644 --- a/src/Examples/TupleReactiveExample/Program.cs +++ b/src/Examples/TupleReactiveExample/Program.cs @@ -1,116 +1,116 @@ -using Cocoar.Configuration.DI; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Reactive; -using Examples.TupleReactiveExample; - -// Example: Demonstrates tuple-based reactive configuration snapshots with arbitrary arity -// and interface exposure eligibility guard. -// -// Run: -// dotnet run -// Then browse: -// http://localhost:5088/snapshot -// http://localhost:5088/raw -// http://localhost:5088/update -// -// The /update endpoint simulates a change to one config type; the tuple stream emits -// at most once per recompute pass with aligned values. - -// Cache array to avoid repeated allocations -var defaultFlags = new[] { "Alpha", "Beta" }; - -var builder = WebApplication.CreateBuilder(args); - -// Subjects to simulate runtime changes (Observable provider emits these) -var appSubject = new System.Reactive.Subjects.BehaviorSubject(new AppSettings { Message = "Hello World", Counter = 0 }); -var flagsSubject = new System.Reactive.Subjects.BehaviorSubject(new FeatureFlags { Flags = defaultFlags }); - -// Define configuration rules using observable providers for dynamic types and static JSON for logging -builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromObservable(appSubject), - rule.For().FromObservable(flagsSubject), - rule.For().FromStaticJson("{ \"Level\": \"Info\" }") -], setup => [ - setup.ConcreteType().ExposeAs() -])); - -var app = builder.Build(); - -// Grab single reactive configs (auto-registered) -var appReactive = app.Services.GetRequiredService>(); -var flagsReactive = app.Services.GetRequiredService>(); -var logReactive = app.Services.GetRequiredService>(); - -// Grab tuple reactive config (atomic, aligned) -var composite = app.Services.GetRequiredService>(); - -// Subscribe (fire-and-forget) for demonstration -_ = composite.Subscribe(t => -{ - var (a, f, l) = t; - Console.WriteLine($"Tuple emission -> Counter={a.Counter} Flags={string.Join(',', f.Flags)} Level={l.Level}"); -}); - -app.MapGet("/snapshot", (IReactiveConfig<(AppSettings App, FeatureFlags Flags, LoggingConfig Log)> tuple) => -{ - var currentValue = tuple.CurrentValue; - return Results.Ok(new - { - currentValue.App.Message, - currentValue.App.Counter, - currentValue.Flags.Flags, - currentValue.Log.Level - }); -}); - -app.MapGet("/raw", (AppSettings a, FeatureFlags f, LoggingConfig l) => new -{ - a.Message, - a.Counter, - f.Flags, - l.Level -}); - -// Simulate an update by layering a later (higher precedence) dynamic rule. -// In real scenarios you'd have file / http / environment providers triggering changes. -app.MapPost("/update", () => -{ - // Push new values through subjects; tuple reactive config will emit once with aligned snapshot. - var current = appReactive.CurrentValue; - appSubject.OnNext(new AppSettings { Message = current.Message, Counter = current.Counter + 1 }); - - var currentFlags = flagsReactive.CurrentValue; - if (!Enumerable.Contains(currentFlags.Flags, "Gamma")) - { - flagsSubject.OnNext(new FeatureFlags { Flags = currentFlags.Flags.Concat(["Gamma"]).ToArray() }); - } - return Results.Accepted(); -}); - -// Demonstrate guard (uncomment to see exception at runtime during resolution) -// var bad = app.Services.GetRequiredService>(); - -app.Run(); - -namespace Examples.TupleReactiveExample -{ // Types - public sealed class AppSettings : IAppSettings - { - public string Message { get; set; } = ""; - public int Counter { get; set; } - } - public interface IAppSettings - { - string Message { get; } - int Counter { get; } - } - public sealed class FeatureFlags - { - public string[] Flags { get; set; } = Array.Empty(); - } - public sealed class LoggingConfig - { - public string Level { get; set; } = "Info"; - } -// public interface IUnexposed { } // Example of an unexposed interface that would fail eligibility -} +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Reactive; +using Examples.TupleReactiveExample; + +// Example: Demonstrates tuple-based reactive configuration snapshots with arbitrary arity +// and interface exposure eligibility guard. +// +// Run: +// dotnet run +// Then browse: +// http://localhost:5088/snapshot +// http://localhost:5088/raw +// http://localhost:5088/update +// +// The /update endpoint simulates a change to one config type; the tuple stream emits +// at most once per recompute pass with aligned values. + +// Cache array to avoid repeated allocations +var defaultFlags = new[] { "Alpha", "Beta" }; + +var builder = WebApplication.CreateBuilder(args); + +// Subjects to simulate runtime changes (Observable provider emits these) +var appSubject = new System.Reactive.Subjects.BehaviorSubject(new AppSettings { Message = "Hello World", Counter = 0 }); +var flagsSubject = new System.Reactive.Subjects.BehaviorSubject(new FeatureFlags { Flags = defaultFlags }); + +// Define configuration rules using observable providers for dynamic types and static JSON for logging +builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ + rule.For().FromObservable(appSubject), + rule.For().FromObservable(flagsSubject), + rule.For().FromStaticJson("{ \"Level\": \"Info\" }") +], setup => [ + setup.ConcreteType().ExposeAs() +])); + +var app = builder.Build(); + +// Grab single reactive configs (auto-registered) +var appReactive = app.Services.GetRequiredService>(); +var flagsReactive = app.Services.GetRequiredService>(); +var logReactive = app.Services.GetRequiredService>(); + +// Grab tuple reactive config (atomic, aligned) +var composite = app.Services.GetRequiredService>(); + +// Subscribe (fire-and-forget) for demonstration +_ = composite.Subscribe(t => +{ + var (a, f, l) = t; + Console.WriteLine($"Tuple emission -> Counter={a.Counter} Flags={string.Join(',', f.Flags)} Level={l.Level}"); +}); + +app.MapGet("/snapshot", (IReactiveConfig<(AppSettings App, FeatureFlags Flags, LoggingConfig Log)> tuple) => +{ + var currentValue = tuple.CurrentValue; + return Results.Ok(new + { + currentValue.App.Message, + currentValue.App.Counter, + currentValue.Flags.Flags, + currentValue.Log.Level + }); +}); + +app.MapGet("/raw", (AppSettings a, FeatureFlags f, LoggingConfig l) => new +{ + a.Message, + a.Counter, + f.Flags, + l.Level +}); + +// Simulate an update by layering a later (higher precedence) dynamic rule. +// In real scenarios you'd have file / http / environment providers triggering changes. +app.MapPost("/update", () => +{ + // Push new values through subjects; tuple reactive config will emit once with aligned snapshot. + var current = appReactive.CurrentValue; + appSubject.OnNext(new AppSettings { Message = current.Message, Counter = current.Counter + 1 }); + + var currentFlags = flagsReactive.CurrentValue; + if (!Enumerable.Contains(currentFlags.Flags, "Gamma")) + { + flagsSubject.OnNext(new FeatureFlags { Flags = currentFlags.Flags.Concat(["Gamma"]).ToArray() }); + } + return Results.Accepted(); +}); + +// Demonstrate guard (uncomment to see exception at runtime during resolution) +// var bad = app.Services.GetRequiredService>(); + +app.Run(); + +namespace Examples.TupleReactiveExample +{ // Types + public sealed class AppSettings : IAppSettings + { + public string Message { get; set; } = ""; + public int Counter { get; set; } + } + public interface IAppSettings + { + string Message { get; } + int Counter { get; } + } + public sealed class FeatureFlags + { + public string[] Flags { get; set; } = Array.Empty(); + } + public sealed class LoggingConfig + { + public string Level { get; set; } = "Info"; + } +// public interface IUnexposed { } // Example of an unexposed interface that would fail eligibility +} diff --git a/src/Examples/TupleReactiveExample/Properties/launchSettings.json b/src/Examples/TupleReactiveExample/Properties/launchSettings.json index af29b4d..aff7f5b 100644 --- a/src/Examples/TupleReactiveExample/Properties/launchSettings.json +++ b/src/Examples/TupleReactiveExample/Properties/launchSettings.json @@ -1,12 +1,12 @@ -{ - "profiles": { - "TupleReactiveExample": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:62455;http://localhost:62456" - } - } +{ + "profiles": { + "TupleReactiveExample": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:62455;http://localhost:62456" + } + } } \ No newline at end of file diff --git a/src/Examples/TupleReactiveExample/TupleReactiveExample.csproj b/src/Examples/TupleReactiveExample/TupleReactiveExample.csproj index ee01374..e8c05b6 100644 --- a/src/Examples/TupleReactiveExample/TupleReactiveExample.csproj +++ b/src/Examples/TupleReactiveExample/TupleReactiveExample.csproj @@ -1,16 +1,16 @@ - - - net9.0 - enable - enable - true - Examples.TupleReactiveExample - - - - - - - - + + + net9.0 + enable + enable + true + Examples.TupleReactiveExample + + + + + + + + \ No newline at end of file diff --git a/src/tests/Cocoar.Configuration.Analyzers.Tests/Cocoar.Configuration.Analyzers.Tests.csproj b/src/tests/Cocoar.Configuration.Analyzers.Tests/Cocoar.Configuration.Analyzers.Tests.csproj index a97542e..6d08b4d 100644 --- a/src/tests/Cocoar.Configuration.Analyzers.Tests/Cocoar.Configuration.Analyzers.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Analyzers.Tests/Cocoar.Configuration.Analyzers.Tests.csproj @@ -1,29 +1,29 @@ - - - - net9.0 - latest - enable - false - true - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - + + + + net9.0 + latest + enable + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj b/src/tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj index 6522c48..8574d71 100644 --- a/src/tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj +++ b/src/tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj @@ -1,31 +1,31 @@ - - - - net9.0 - enable - enable - true - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - + + + + net9.0 + enable + enable + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Cocoar.Configuration.Core.Tests.csproj b/src/tests/Cocoar.Configuration.Core.Tests/Cocoar.Configuration.Core.Tests.csproj index a50fbac..505bf1b 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Cocoar.Configuration.Core.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Core.Tests/Cocoar.Configuration.Core.Tests.csproj @@ -1,30 +1,30 @@ - - - - net9.0 - enable - enable - true - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - + + + + net9.0 + enable + enable + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + \ No newline at end of file diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Core/ConfigManagerBuilderTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Core/ConfigManagerBuilderTests.cs index 73f86f2..c8159b4 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Core/ConfigManagerBuilderTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Core/ConfigManagerBuilderTests.cs @@ -1,221 +1,221 @@ -using Cocoar.Configuration.Core.Tests.Helpers; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Cocoar.Configuration.Core.Tests.Core; - -public class ConfigManagerBuilderTests -{ - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public void Create_WithRulesOnly_Works() - { - using var manager = ConfigManager.Create(c => c - .UseConfiguration(rules => [ - rules.For().FromStaticJson("""{"Value": 42}""") - ])); - - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.Equal(42, config!.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public void Create_WithRulesAndSetup_Works() - { - using var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [rules.For().FromStaticJson("""{"Value": 7}""")], - setup => [setup.ConcreteType()])); - - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.Equal(7, config!.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public void Create_WithPrebuiltRules_Works() - { - var rule = TestRules.StaticJson("""{"Enabled": true}"""); - - using var manager = ConfigManager.Create(c => c - .UseConfiguration(new[] { rule })); - - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.True(config!.Enabled); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public void Create_WithEmptyBuilder_Works() - { - using var manager = ConfigManager.Create(c => { }); - - // No rules = no configs, but manager should be valid - var result = manager.TryGetConfig(out _); - Assert.False(result); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public void Create_WithLogger_PassesLogger() - { - using var manager = ConfigManager.Create(c => c - .UseConfiguration(rules => [ - rules.For().FromStaticJson("""{"Value": 1}""") - ]) - .UseLogger(NullLogger.Instance)); - - var config = manager.GetConfig(); - Assert.NotNull(config); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public void Create_WithDebounce_PassesDebounce() - { - using var manager = ConfigManager.Create(c => c - .UseConfiguration(rules => [ - rules.For().FromStaticJson("""{"Value": 1}""") - ]) - .UseDebounce(50)); - - var config = manager.GetConfig(); - Assert.NotNull(config); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public void AfterBuild_ExecutesAfterInitialization() - { - var afterBuildCalled = false; - ConfigManager? capturedManager = null; - - using var manager = ConfigManager.Create(c => c - .UseConfiguration(rules => [ - rules.For().FromStaticJson("""{"Value": 99}""") - ]) - .AfterBuild(m => - { - afterBuildCalled = true; - capturedManager = m; - // Manager should be initialized — we can access config - var config = m.GetConfig(); - Assert.NotNull(config); - Assert.Equal(99, config!.Value); - })); - - Assert.True(afterBuildCalled); - Assert.Same(manager, capturedManager); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public void AfterBuild_MultipleActions_ExecuteInOrder() - { - var order = new List(); - - using var manager = ConfigManager.Create(c => c - .UseConfiguration(rules => [ - rules.For().FromStaticJson("""{"Value": 1}""") - ]) - .AfterBuild(_ => order.Add(1)) - .AfterBuild(_ => order.Add(2)) - .AfterBuild(_ => order.Add(3))); - - Assert.Equal([1, 2, 3], order); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public void Create_NullConfigure_Throws() - { - Assert.Throws(() => ConfigManager.Create(null!)); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public void AfterBuild_NullAction_Throws() - { - Assert.Throws(() => - { - ConfigManager.Create(c => c.AfterBuild(null!)); - }); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public async Task CreateAsync_ReturnsInitializedManager() - { - await using var manager = await ConfigManager.CreateAsync(c => c - .UseConfiguration(rules => [ - rules.For().FromStaticJson("""{"Value": 42}""") - ])); - - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.Equal(42, config!.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public async Task CreateAsync_WithCancellation_Throws() - { - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - await Assert.ThrowsAnyAsync(() => - ConfigManager.CreateAsync( - c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson("""{"Value": 1}""") - ]), - cts.Token)); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public async Task CreateAsync_WithStaticJson_ProducesCorrectConfig() - { - await using var manager = await ConfigManager.CreateAsync(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson("""{"Value": 7, "Enabled": true}""") - ], - setup => [setup.ConcreteType()])); - - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.Equal(7, config!.Value); - Assert.True(config.Enabled); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManagerBuilder")] - public async Task CreateAsync_NullConfigure_Throws() - { - await Assert.ThrowsAsync(() => - ConfigManager.CreateAsync(null!)); - } - - public sealed class TestConfig - { - public bool Enabled { get; set; } - public int Value { get; set; } - } -} +using Cocoar.Configuration.Core.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cocoar.Configuration.Core.Tests.Core; + +public class ConfigManagerBuilderTests +{ + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public void Create_WithRulesOnly_Works() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => [ + rules.For().FromStaticJson("""{"Value": 42}""") + ])); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Equal(42, config!.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public void Create_WithRulesAndSetup_Works() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [rules.For().FromStaticJson("""{"Value": 7}""")], + setup => [setup.ConcreteType()])); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Equal(7, config!.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public void Create_WithPrebuiltRules_Works() + { + var rule = TestRules.StaticJson("""{"Enabled": true}"""); + + using var manager = ConfigManager.Create(c => c + .UseConfiguration(new[] { rule })); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.True(config!.Enabled); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public void Create_WithEmptyBuilder_Works() + { + using var manager = ConfigManager.Create(c => { }); + + // No rules = no configs, but manager should be valid + var result = manager.TryGetConfig(out _); + Assert.False(result); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public void Create_WithLogger_PassesLogger() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => [ + rules.For().FromStaticJson("""{"Value": 1}""") + ]) + .UseLogger(NullLogger.Instance)); + + var config = manager.GetConfig(); + Assert.NotNull(config); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public void Create_WithDebounce_PassesDebounce() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => [ + rules.For().FromStaticJson("""{"Value": 1}""") + ]) + .UseDebounce(50)); + + var config = manager.GetConfig(); + Assert.NotNull(config); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public void AfterBuild_ExecutesAfterInitialization() + { + var afterBuildCalled = false; + ConfigManager? capturedManager = null; + + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => [ + rules.For().FromStaticJson("""{"Value": 99}""") + ]) + .AfterBuild(m => + { + afterBuildCalled = true; + capturedManager = m; + // Manager should be initialized — we can access config + var config = m.GetConfig(); + Assert.NotNull(config); + Assert.Equal(99, config!.Value); + })); + + Assert.True(afterBuildCalled); + Assert.Same(manager, capturedManager); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public void AfterBuild_MultipleActions_ExecuteInOrder() + { + var order = new List(); + + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => [ + rules.For().FromStaticJson("""{"Value": 1}""") + ]) + .AfterBuild(_ => order.Add(1)) + .AfterBuild(_ => order.Add(2)) + .AfterBuild(_ => order.Add(3))); + + Assert.Equal([1, 2, 3], order); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public void Create_NullConfigure_Throws() + { + Assert.Throws(() => ConfigManager.Create(null!)); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public void AfterBuild_NullAction_Throws() + { + Assert.Throws(() => + { + ConfigManager.Create(c => c.AfterBuild(null!)); + }); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public async Task CreateAsync_ReturnsInitializedManager() + { + await using var manager = await ConfigManager.CreateAsync(c => c + .UseConfiguration(rules => [ + rules.For().FromStaticJson("""{"Value": 42}""") + ])); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Equal(42, config!.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public async Task CreateAsync_WithCancellation_Throws() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => + ConfigManager.CreateAsync( + c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson("""{"Value": 1}""") + ]), + cts.Token)); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public async Task CreateAsync_WithStaticJson_ProducesCorrectConfig() + { + await using var manager = await ConfigManager.CreateAsync(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson("""{"Value": 7, "Enabled": true}""") + ], + setup => [setup.ConcreteType()])); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Equal(7, config!.Value); + Assert.True(config.Enabled); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManagerBuilder")] + public async Task CreateAsync_NullConfigure_Throws() + { + await Assert.ThrowsAsync(() => + ConfigManager.CreateAsync(null!)); + } + + public sealed class TestConfig + { + public bool Enabled { get; set; } + public int Value { get; set; } + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Core/ConfigManagerOrchestrationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Core/ConfigManagerOrchestrationTests.cs index b9c4fea..efb930a 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Core/ConfigManagerOrchestrationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Core/ConfigManagerOrchestrationTests.cs @@ -1,96 +1,96 @@ -using System.Reactive.Subjects; -using Cocoar.Configuration.Providers; - -using Cocoar.Configuration.Core.Tests.Helpers; -using Cocoar.Configuration.Core.Tests.TestUtilities; - -namespace Cocoar.Configuration.Core.Tests.Core; - -public class ConfigManagerOrchestrationTests -{ - private readonly struct Unit - { - public static readonly Unit Default = new(); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManager")] - public async Task Initialize_Does_Not_Recompute_From_Subscription_And_Recomputes_On_Change() - { - var initialJson = """{"Ok": true, "Count": 1}"""; - var changedJson = """{"Ok": true, "Count": 2}"""; - - var behaviorSubject = new BehaviorSubject(initialJson); - - var rule = TestRules.ObservableString(behaviorSubject, required: true); - - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { rule })); - - var initialConfig = manager.GetConfig(); - Assert.NotNull(initialConfig); - Assert.Equal(1, initialConfig!.Count); - - behaviorSubject.OnNext(changedJson); - - // Wait for the configuration to update using condition-based waiting - await ActiveWaitHelpers.WaitUntilAsync( - () => manager.GetConfig()?.Count == 2, - description: "configuration Count to update to 2"); - - var updatedConfig = manager.GetConfig(); - Assert.NotNull(updatedConfig); - Assert.Equal(2, updatedConfig!.Count); - - behaviorSubject.OnCompleted(); - behaviorSubject.Dispose(); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManager")] - public void StaticProvider_Configuration_LoadsCorrectly() - { - var rule = TestRules.StaticJson("""{"Enabled": true, "Value": 42}""", required: true); - - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { rule })); - var config = manager.GetConfig(); - - Assert.NotNull(config); - Assert.True(config!.Enabled); - Assert.Equal(42, config.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "ConfigManager")] - public void MultipleStaticRules_MergeCorrectly() - { - var rule1 = TestRules.StaticJson>( - """{"base": "config1", "shared": "from-first"}""", - required: true); - - var rule2 = TestRules.StaticJson>( - """{"additional": "config2", "shared": "from-second"}""", - required: true); - - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { rule1, rule2 })); - var config = manager.GetConfig>(); - - Assert.NotNull(config); - Assert.True(config!.ContainsKey("base")); - Assert.True(config.ContainsKey("additional")); - Assert.Equal("from-second", config["shared"].ToString()); // Later rule wins - } - - public sealed class TestConfig - { - public bool Enabled { get; set; } - public int Value { get; set; } - public bool Ok { get; set; } - public int Count { get; set; } - } -} - - - +using System.Reactive.Subjects; +using Cocoar.Configuration.Providers; + +using Cocoar.Configuration.Core.Tests.Helpers; +using Cocoar.Configuration.Core.Tests.TestUtilities; + +namespace Cocoar.Configuration.Core.Tests.Core; + +public class ConfigManagerOrchestrationTests +{ + private readonly struct Unit + { + public static readonly Unit Default = new(); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManager")] + public async Task Initialize_Does_Not_Recompute_From_Subscription_And_Recomputes_On_Change() + { + var initialJson = """{"Ok": true, "Count": 1}"""; + var changedJson = """{"Ok": true, "Count": 2}"""; + + var behaviorSubject = new BehaviorSubject(initialJson); + + var rule = TestRules.ObservableString(behaviorSubject, required: true); + + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { rule })); + + var initialConfig = manager.GetConfig(); + Assert.NotNull(initialConfig); + Assert.Equal(1, initialConfig!.Count); + + behaviorSubject.OnNext(changedJson); + + // Wait for the configuration to update using condition-based waiting + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()?.Count == 2, + description: "configuration Count to update to 2"); + + var updatedConfig = manager.GetConfig(); + Assert.NotNull(updatedConfig); + Assert.Equal(2, updatedConfig!.Count); + + behaviorSubject.OnCompleted(); + behaviorSubject.Dispose(); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManager")] + public void StaticProvider_Configuration_LoadsCorrectly() + { + var rule = TestRules.StaticJson("""{"Enabled": true, "Value": 42}""", required: true); + + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { rule })); + var config = manager.GetConfig(); + + Assert.NotNull(config); + Assert.True(config!.Enabled); + Assert.Equal(42, config.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "ConfigManager")] + public void MultipleStaticRules_MergeCorrectly() + { + var rule1 = TestRules.StaticJson>( + """{"base": "config1", "shared": "from-first"}""", + required: true); + + var rule2 = TestRules.StaticJson>( + """{"additional": "config2", "shared": "from-second"}""", + required: true); + + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { rule1, rule2 })); + var config = manager.GetConfig>(); + + Assert.NotNull(config); + Assert.True(config!.ContainsKey("base")); + Assert.True(config.ContainsKey("additional")); + Assert.Equal("from-second", config["shared"].ToString()); // Later rule wins + } + + public sealed class TestConfig + { + public bool Enabled { get; set; } + public int Value { get; set; } + public bool Ok { get; set; } + public int Count { get; set; } + } +} + + + diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Core/SecretsFluentApiTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Core/SecretsFluentApiTests.cs index bebe4af..e7ca022 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Core/SecretsFluentApiTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Core/SecretsFluentApiTests.cs @@ -1,152 +1,152 @@ -using Cocoar.Configuration.Secrets; -using Cocoar.Configuration.Secrets.Core; -using Cocoar.Configuration.X509Encryption; -using Cocoar.Configuration.Secrets.Protectors.Hybrid; - -namespace Cocoar.Configuration.Core.Tests.Core; - -/// -/// Tests for the UseSecretsSetup() extension method on ConfigManagerBuilder. -/// -public class SecretsFluentApiTests -{ - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void Secrets_AcceptsFluentConfiguration() - { - // Arrange & Act - var kid = "test-key"; - var pfxPath = Path.Combine(Path.GetTempPath(), $"{kid}.pfx"); - - try - { - // Generate certificate explicitly - X509CertificateGenerator.GenerateAndSave( - pfxPath, - null, // Password-less certificate - "CN=Test Certificate", - validYears: 1, - keySize: 2048); - - var manager = ConfigManager.Create(c => c - .UseConfiguration(rules: Array.Empty()) - .UseSecretsSetup(secrets => secrets - .UseCertificateFromFile(pfxPath) - .WithKeyId(kid))); - - // Assert - Assert.NotNull(manager); - } - finally - { - if (File.Exists(pfxPath)) - File.Delete(pfxPath); - } - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void Secrets_AllowsFluentProtectorConfiguration() - { - // Arrange - var kid = $"test-{Guid.NewGuid():N}"; - - var pfxPath = Path.Combine(Path.GetTempPath(), $"{kid}.pfx"); - - try - { - // Generate certificate explicitly - X509CertificateGenerator.GenerateAndSave( - pfxPath, - null, // Password-less certificate - "CN=Test Certificate", - validYears: 1, - keySize: 2048); - - var manager = ConfigManager.Create(c => c - .UseConfiguration(rules: Array.Empty()) - .UseSecretsSetup(secrets => secrets - .UseCertificateFromFile(pfxPath) - .WithKeyId(kid))); - - // Assert - Assert.NotNull(manager); - - // Verify the capability-based implementation was used by checking the composition - // Use Owner.GetComposition() - no generic parameter needed with ConfigManagerCapabilityScope! - var composition = manager.CapabilityScope.Owner.GetComposition(); - Assert.NotNull(composition); - - // Should have SecretsSetupDeferredConfiguration - Assert.True(composition!.Has()); - - // Should have CertificateProtectorConfig (unified) - Assert.True(composition.Has()); - } - finally - { - if (File.Exists(pfxPath)) - File.Delete(pfxPath); - } - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void Secrets_EachConfigManagerGetsIsolatedRuntime() - { - // Arrange - var kid1 = $"manager1-{Guid.NewGuid():N}"; - var kid2 = $"manager2-{Guid.NewGuid():N}"; - var pfxPath1 = Path.Combine(Path.GetTempPath(), $"{kid1}.pfx"); - var pfxPath2 = Path.Combine(Path.GetTempPath(), $"{kid2}.pfx"); - - try - { - // Generate certificates explicitly - X509CertificateGenerator.GenerateAndSave( - pfxPath1, - null, // Password-less certificate - "CN=Test Certificate 1", - validYears: 1, - keySize: 2048); - - X509CertificateGenerator.GenerateAndSave( - pfxPath2, - null, // Password-less certificate - "CN=Test Certificate 2", - validYears: 1, - keySize: 2048); - - // Act - create two different ConfigManagers with different secrets configurations - var manager1 = ConfigManager.Create(c => c - .UseConfiguration(rules: Array.Empty()) - .UseSecretsSetup(secrets => secrets - .UseCertificateFromFile(pfxPath1) - .WithKeyId(kid1))); - - var manager2 = ConfigManager.Create(c => c - .UseConfiguration(rules: Array.Empty()) - .UseSecretsSetup(secrets => secrets - .UseCertificateFromFile(pfxPath2) - .WithKeyId(kid2))); - - // Assert - both should be created successfully without interfering with each other - Assert.NotNull(manager1); - Assert.NotNull(manager2); - - // Note: We'd need to expose the runtime or capabilities API to verify complete isolation, - // but this test at least proves that two different managers can be created with - // different secrets configurations without errors - } - finally - { - if (File.Exists(pfxPath1)) - File.Delete(pfxPath1); - if (File.Exists(pfxPath2)) - File.Delete(pfxPath2); - } - } -} +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.X509Encryption; +using Cocoar.Configuration.Secrets.Protectors.Hybrid; + +namespace Cocoar.Configuration.Core.Tests.Core; + +/// +/// Tests for the UseSecretsSetup() extension method on ConfigManagerBuilder. +/// +public class SecretsFluentApiTests +{ + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void Secrets_AcceptsFluentConfiguration() + { + // Arrange & Act + var kid = "test-key"; + var pfxPath = Path.Combine(Path.GetTempPath(), $"{kid}.pfx"); + + try + { + // Generate certificate explicitly + X509CertificateGenerator.GenerateAndSave( + pfxPath, + null, // Password-less certificate + "CN=Test Certificate", + validYears: 1, + keySize: 2048); + + var manager = ConfigManager.Create(c => c + .UseConfiguration(rules: Array.Empty()) + .UseSecretsSetup(secrets => secrets + .UseCertificateFromFile(pfxPath) + .WithKeyId(kid))); + + // Assert + Assert.NotNull(manager); + } + finally + { + if (File.Exists(pfxPath)) + File.Delete(pfxPath); + } + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void Secrets_AllowsFluentProtectorConfiguration() + { + // Arrange + var kid = $"test-{Guid.NewGuid():N}"; + + var pfxPath = Path.Combine(Path.GetTempPath(), $"{kid}.pfx"); + + try + { + // Generate certificate explicitly + X509CertificateGenerator.GenerateAndSave( + pfxPath, + null, // Password-less certificate + "CN=Test Certificate", + validYears: 1, + keySize: 2048); + + var manager = ConfigManager.Create(c => c + .UseConfiguration(rules: Array.Empty()) + .UseSecretsSetup(secrets => secrets + .UseCertificateFromFile(pfxPath) + .WithKeyId(kid))); + + // Assert + Assert.NotNull(manager); + + // Verify the capability-based implementation was used by checking the composition + // Use Owner.GetComposition() - no generic parameter needed with ConfigManagerCapabilityScope! + var composition = manager.CapabilityScope.Owner.GetComposition(); + Assert.NotNull(composition); + + // Should have SecretsSetupDeferredConfiguration + Assert.True(composition!.Has()); + + // Should have CertificateProtectorConfig (unified) + Assert.True(composition.Has()); + } + finally + { + if (File.Exists(pfxPath)) + File.Delete(pfxPath); + } + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void Secrets_EachConfigManagerGetsIsolatedRuntime() + { + // Arrange + var kid1 = $"manager1-{Guid.NewGuid():N}"; + var kid2 = $"manager2-{Guid.NewGuid():N}"; + var pfxPath1 = Path.Combine(Path.GetTempPath(), $"{kid1}.pfx"); + var pfxPath2 = Path.Combine(Path.GetTempPath(), $"{kid2}.pfx"); + + try + { + // Generate certificates explicitly + X509CertificateGenerator.GenerateAndSave( + pfxPath1, + null, // Password-less certificate + "CN=Test Certificate 1", + validYears: 1, + keySize: 2048); + + X509CertificateGenerator.GenerateAndSave( + pfxPath2, + null, // Password-less certificate + "CN=Test Certificate 2", + validYears: 1, + keySize: 2048); + + // Act - create two different ConfigManagers with different secrets configurations + var manager1 = ConfigManager.Create(c => c + .UseConfiguration(rules: Array.Empty()) + .UseSecretsSetup(secrets => secrets + .UseCertificateFromFile(pfxPath1) + .WithKeyId(kid1))); + + var manager2 = ConfigManager.Create(c => c + .UseConfiguration(rules: Array.Empty()) + .UseSecretsSetup(secrets => secrets + .UseCertificateFromFile(pfxPath2) + .WithKeyId(kid2))); + + // Assert - both should be created successfully without interfering with each other + Assert.NotNull(manager1); + Assert.NotNull(manager2); + + // Note: We'd need to expose the runtime or capabilities API to verify complete isolation, + // but this test at least proves that two different managers can be created with + // different secrets configurations without errors + } + finally + { + if (File.Exists(pfxPath1)) + File.Delete(pfxPath1); + if (File.Exists(pfxPath2)) + File.Delete(pfxPath2); + } + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/GlobalUsings.cs b/src/tests/Cocoar.Configuration.Core.Tests/GlobalUsings.cs index afcba11..f2119be 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/GlobalUsings.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/GlobalUsings.cs @@ -1,10 +1,10 @@ -global using Cocoar.Configuration.Rules; -global using Cocoar.Configuration.Core.Tests.Helpers; -global using Cocoar.Configuration.Providers; -global using Cocoar.Configuration.Providers.Abstractions; -global using Cocoar.Configuration.Health; -global using System.Reactive.Subjects; -global using Microsoft.Extensions.Logging.Abstractions; - - - +global using Cocoar.Configuration.Rules; +global using Cocoar.Configuration.Core.Tests.Helpers; +global using Cocoar.Configuration.Providers; +global using Cocoar.Configuration.Providers.Abstractions; +global using Cocoar.Configuration.Health; +global using System.Reactive.Subjects; +global using Microsoft.Extensions.Logging.Abstractions; + + + diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Health/LeanHealthIntegrationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Health/LeanHealthIntegrationTests.cs index 0a5364f..27f65fe 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Health/LeanHealthIntegrationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Health/LeanHealthIntegrationTests.cs @@ -1,115 +1,115 @@ -using System.Text.Json; -using Cocoar.Configuration.Health; -using Cocoar.Configuration.Providers.Abstractions; -using Cocoar.Configuration.Rules; - -using Cocoar.Configuration.Core.Tests.Helpers; - -namespace Cocoar.Configuration.Core.Tests.Health; - -public class LeanHealthIntegrationTests -{ - [Fact] - public void AfterCreate_ShouldBeHealthy() - { - var rule = BuildStaticRule(required: true); - using var mgr = ConfigManager.Create(c => c.UseConfiguration(new[] { rule }).UseLogger(NullLogger.Instance)); - Assert.Equal(HealthStatus.Healthy, mgr.HealthStatus); - Assert.True(mgr.IsHealthy); - } - - [Fact] - public void AllRequiredUp_IsHealthy() - { - var rule = BuildStaticRule(required: true); - using var mgr = ConfigManager.Create(c => c.UseConfiguration(new[] { rule }).UseLogger(NullLogger.Instance)); - Assert.Equal(HealthStatus.Healthy, mgr.HealthStatus); - } - - [Fact] - public void OptionalFailure_ShouldYieldDegraded() - { - var ok = BuildStaticRule(required: true); - var failing = BuildFailingRule(required: false, new InvalidOperationException("json parse failed")); - using var mgr = ConfigManager.Create(c => c.UseConfiguration(new[] { ok, failing }).UseLogger(NullLogger.Instance)); - Assert.Equal(HealthStatus.Degraded, mgr.HealthStatus); - Assert.False(mgr.IsHealthy); - } - - [Fact] - public void RequiredFailure_ShouldThrow() - { - var ok = BuildStaticRule(required: true); - var failing = BuildFailingRule(required: true, new TimeoutException("timeout")); - Assert.ThrowsAny(() => - ConfigManager.Create(c => c.UseConfiguration(new[] { ok, failing }).UseLogger(NullLogger.Instance))); - } - - [Fact] - public void MidSequenceRequiredFailure_ShouldThrow() - { - var r1 = BuildStaticRule(true); - var r2 = BuildStaticRule(true); - var failing = BuildFailingRule(true, new InvalidOperationException("boom")); - var r4 = BuildStaticRule(true); - var r5 = BuildStaticRule(true); - Assert.ThrowsAny(() => - ConfigManager.Create(c => c.UseConfiguration(new[] { r1, r2, failing, r4, r5 }).UseLogger(NullLogger.Instance))); - } - - [Fact] - public void SkippedRule_ShouldNotDegradeHealth() - { - var requiredRule = BuildStaticRule(true); - var skipOptions = new SimpleStaticProviderOptions("{\"Value\":2}"); - var skipQuery = new SimpleStaticProviderQuery(); - var skippedRule = new ConfigRule( - typeof(SimpleStaticProvider), - skipOptions, - skipQuery, - typeof(object), - new(Required: true, UseWhen: _ => false) - ); - using var mgr = ConfigManager.Create(c => c.UseConfiguration(new[] { requiredRule, skippedRule }).UseLogger(NullLogger.Instance)); - Assert.Equal(HealthStatus.Healthy, mgr.HealthStatus); - } - - private static ConfigRule BuildStaticRule(bool required) - { - var providerOptions = new SimpleStaticProviderOptions("{\"Value\":1}"); - var query = new SimpleStaticProviderQuery(); - return new(typeof(SimpleStaticProvider), providerOptions, query, typeof(object), new(Required: required)); - } - - private static ConfigRule BuildFailingRule(bool required, Exception ex) - { - var providerOptions = new FailingProviderOptions(ex); - var query = new FailingProviderQuery(); - return new(typeof(FailingProvider), providerOptions, query, typeof(object), new(Required: required)); - } -} - -internal sealed record SimpleStaticProviderOptions(string Json) : IProviderConfiguration; -internal sealed record SimpleStaticProviderQuery() : IProviderQuery; - -internal sealed class SimpleStaticProvider : ConfigurationProvider -{ - public SimpleStaticProvider(SimpleStaticProviderOptions options) : base(options) { } - public override Task FetchConfigurationBytesAsync(SimpleStaticProviderQuery query, CancellationToken ct = default) - { - using var doc = JsonDocument.Parse(ProviderOptions.Json); - var bytes = JsonSerializer.SerializeToUtf8Bytes(doc.RootElement.Clone()); - return Task.FromResult(bytes); - } - public override IObservable ChangesAsBytes(SimpleStaticProviderQuery query) => System.Reactive.Linq.Observable.Empty(); -} - -internal sealed record FailingProviderOptions(Exception Ex) : IProviderConfiguration; -internal sealed record FailingProviderQuery() : IProviderQuery; -internal sealed class FailingProvider : ConfigurationProvider -{ - public FailingProvider(FailingProviderOptions options) : base(options) { } - public override Task FetchConfigurationBytesAsync(FailingProviderQuery query, CancellationToken ct = default) - => Task.FromException(ProviderOptions.Ex); - public override IObservable ChangesAsBytes(FailingProviderQuery query) => System.Reactive.Linq.Observable.Empty(); -} +using System.Text.Json; +using Cocoar.Configuration.Health; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.Configuration.Rules; + +using Cocoar.Configuration.Core.Tests.Helpers; + +namespace Cocoar.Configuration.Core.Tests.Health; + +public class LeanHealthIntegrationTests +{ + [Fact] + public void AfterCreate_ShouldBeHealthy() + { + var rule = BuildStaticRule(required: true); + using var mgr = ConfigManager.Create(c => c.UseConfiguration(new[] { rule }).UseLogger(NullLogger.Instance)); + Assert.Equal(HealthStatus.Healthy, mgr.HealthStatus); + Assert.True(mgr.IsHealthy); + } + + [Fact] + public void AllRequiredUp_IsHealthy() + { + var rule = BuildStaticRule(required: true); + using var mgr = ConfigManager.Create(c => c.UseConfiguration(new[] { rule }).UseLogger(NullLogger.Instance)); + Assert.Equal(HealthStatus.Healthy, mgr.HealthStatus); + } + + [Fact] + public void OptionalFailure_ShouldYieldDegraded() + { + var ok = BuildStaticRule(required: true); + var failing = BuildFailingRule(required: false, new InvalidOperationException("json parse failed")); + using var mgr = ConfigManager.Create(c => c.UseConfiguration(new[] { ok, failing }).UseLogger(NullLogger.Instance)); + Assert.Equal(HealthStatus.Degraded, mgr.HealthStatus); + Assert.False(mgr.IsHealthy); + } + + [Fact] + public void RequiredFailure_ShouldThrow() + { + var ok = BuildStaticRule(required: true); + var failing = BuildFailingRule(required: true, new TimeoutException("timeout")); + Assert.ThrowsAny(() => + ConfigManager.Create(c => c.UseConfiguration(new[] { ok, failing }).UseLogger(NullLogger.Instance))); + } + + [Fact] + public void MidSequenceRequiredFailure_ShouldThrow() + { + var r1 = BuildStaticRule(true); + var r2 = BuildStaticRule(true); + var failing = BuildFailingRule(true, new InvalidOperationException("boom")); + var r4 = BuildStaticRule(true); + var r5 = BuildStaticRule(true); + Assert.ThrowsAny(() => + ConfigManager.Create(c => c.UseConfiguration(new[] { r1, r2, failing, r4, r5 }).UseLogger(NullLogger.Instance))); + } + + [Fact] + public void SkippedRule_ShouldNotDegradeHealth() + { + var requiredRule = BuildStaticRule(true); + var skipOptions = new SimpleStaticProviderOptions("{\"Value\":2}"); + var skipQuery = new SimpleStaticProviderQuery(); + var skippedRule = new ConfigRule( + typeof(SimpleStaticProvider), + skipOptions, + skipQuery, + typeof(object), + new(Required: true, UseWhen: _ => false) + ); + using var mgr = ConfigManager.Create(c => c.UseConfiguration(new[] { requiredRule, skippedRule }).UseLogger(NullLogger.Instance)); + Assert.Equal(HealthStatus.Healthy, mgr.HealthStatus); + } + + private static ConfigRule BuildStaticRule(bool required) + { + var providerOptions = new SimpleStaticProviderOptions("{\"Value\":1}"); + var query = new SimpleStaticProviderQuery(); + return new(typeof(SimpleStaticProvider), providerOptions, query, typeof(object), new(Required: required)); + } + + private static ConfigRule BuildFailingRule(bool required, Exception ex) + { + var providerOptions = new FailingProviderOptions(ex); + var query = new FailingProviderQuery(); + return new(typeof(FailingProvider), providerOptions, query, typeof(object), new(Required: required)); + } +} + +internal sealed record SimpleStaticProviderOptions(string Json) : IProviderConfiguration; +internal sealed record SimpleStaticProviderQuery() : IProviderQuery; + +internal sealed class SimpleStaticProvider : ConfigurationProvider +{ + public SimpleStaticProvider(SimpleStaticProviderOptions options) : base(options) { } + public override Task FetchConfigurationBytesAsync(SimpleStaticProviderQuery query, CancellationToken ct = default) + { + using var doc = JsonDocument.Parse(ProviderOptions.Json); + var bytes = JsonSerializer.SerializeToUtf8Bytes(doc.RootElement.Clone()); + return Task.FromResult(bytes); + } + public override IObservable ChangesAsBytes(SimpleStaticProviderQuery query) => System.Reactive.Linq.Observable.Empty(); +} + +internal sealed record FailingProviderOptions(Exception Ex) : IProviderConfiguration; +internal sealed record FailingProviderQuery() : IProviderQuery; +internal sealed class FailingProvider : ConfigurationProvider +{ + public FailingProvider(FailingProviderOptions options) : base(options) { } + public override Task FetchConfigurationBytesAsync(FailingProviderQuery query, CancellationToken ct = default) + => Task.FromException(ProviderOptions.Ex); + public override IObservable ChangesAsBytes(FailingProviderQuery query) => System.Reactive.Linq.Observable.Empty(); +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Helpers/ByteTestHelpers.cs b/src/tests/Cocoar.Configuration.Core.Tests/Helpers/ByteTestHelpers.cs index cfb3dee..579e105 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Helpers/ByteTestHelpers.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Helpers/ByteTestHelpers.cs @@ -1,40 +1,40 @@ -using System.Text.Json; - -namespace Cocoar.Configuration.Core.Tests.Helpers; - -/// -/// Test helper to convert byte-based provider responses back to JsonElement for test assertions. -/// This is purely for testing - in production, the ConfigManager handles the conversion internally. -/// -internal static class ByteTestHelpers -{ - /// - /// Converts byte[] (UTF-8 JSON) to JsonElement for test assertions. - /// - public static JsonElement ToJsonElement(this byte[] bytes) - { - using var doc = JsonDocument.Parse(bytes); - return doc.RootElement.Clone(); - } - - /// - /// Converts ReadOnlyMemory<byte> (UTF-8 JSON) to JsonElement for test assertions. - /// - public static JsonElement ToJsonElement(this ReadOnlyMemory bytes) - { - using var doc = JsonDocument.Parse(bytes); - return doc.RootElement.Clone(); - } - - /// - /// Converts Task<byte[]> to Task<JsonElement> for test assertions. - /// - public static async Task ToJsonElementAsync(this Task bytesTask) - { - var bytes = await bytesTask; - return bytes.ToJsonElement(); - } -} - - - +using System.Text.Json; + +namespace Cocoar.Configuration.Core.Tests.Helpers; + +/// +/// Test helper to convert byte-based provider responses back to JsonElement for test assertions. +/// This is purely for testing - in production, the ConfigManager handles the conversion internally. +/// +internal static class ByteTestHelpers +{ + /// + /// Converts byte[] (UTF-8 JSON) to JsonElement for test assertions. + /// + public static JsonElement ToJsonElement(this byte[] bytes) + { + using var doc = JsonDocument.Parse(bytes); + return doc.RootElement.Clone(); + } + + /// + /// Converts ReadOnlyMemory<byte> (UTF-8 JSON) to JsonElement for test assertions. + /// + public static JsonElement ToJsonElement(this ReadOnlyMemory bytes) + { + using var doc = JsonDocument.Parse(bytes); + return doc.RootElement.Clone(); + } + + /// + /// Converts Task<byte[]> to Task<JsonElement> for test assertions. + /// + public static async Task ToJsonElementAsync(this Task bytesTask) + { + var bytes = await bytesTask; + return bytes.ToJsonElement(); + } +} + + + diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Helpers/JsonTransformStreamingTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Helpers/JsonTransformStreamingTests.cs index 838d196..ae1613a 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Helpers/JsonTransformStreamingTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Helpers/JsonTransformStreamingTests.cs @@ -1,89 +1,89 @@ -using System.Text; -using System.Text.Json; -using Cocoar.Configuration.Helper; -using Xunit; - -namespace Cocoar.Configuration.Core.Tests.Helpers; - -public class JsonTransformStreamingTests -{ - private static ReadOnlyMemory Utf8(string json) => Encoding.UTF8.GetBytes(json); - - private static JsonElement Parse(ReadOnlyMemory bytes) => JsonDocument.Parse(bytes).RootElement.Clone(); - - [Fact] - public void MountOnly_WrapsRootObject() - { - var input = JsonSerializer.SerializeToUtf8Bytes(new { a = 1 }); - var result = JsonTransform.SelectAndMount(input, selectPath: null, mountPath: "x:y"); - - using var doc = JsonDocument.Parse(result); - var root = doc.RootElement; - Assert.True(root.TryGetProperty("x", out var x)); - Assert.True(x.TryGetProperty("y", out var y)); - Assert.True(y.TryGetProperty("a", out var a)); - Assert.Equal(1, a.GetInt32()); - } - - [Fact] - public void SelectOnly_ObjectProperty_YieldsPrimitive() - { - var input = JsonSerializer.SerializeToUtf8Bytes(new { user = new { name = "neo", age = 2 } }); - var result = JsonTransform.SelectAndMount(input, selectPath: "user:name", mountPath: null); - - using var doc = JsonDocument.Parse(result); - var root = doc.RootElement; - Assert.Equal(JsonValueKind.String, root.ValueKind); - Assert.Equal("neo", root.GetString()); - } - - [Fact] - public void Select_ArrayIndex_YieldsNumber() - { - var input = JsonSerializer.SerializeToUtf8Bytes(new { items = new[] { new { id = 1 }, new { id = 2 } } }); - var result = JsonTransform.SelectAndMount(input, selectPath: "items:1:id", mountPath: null); - - using var doc = JsonDocument.Parse(result); - var root = doc.RootElement; - Assert.Equal(JsonValueKind.Number, root.ValueKind); - Assert.Equal(2, root.GetInt32()); - } - - [Fact] - public void SelectAndMount_WrapsSelectedSubtree() - { - var input = JsonSerializer.SerializeToUtf8Bytes(new { user = new { name = "neo", age = 2 } }); - var result = JsonTransform.SelectAndMount(input, selectPath: "user", mountPath: "root:payload"); - - using var doc = JsonDocument.Parse(result); - var root = doc.RootElement; - Assert.True(root.TryGetProperty("root", out var r)); - Assert.True(r.TryGetProperty("payload", out var payload)); - Assert.True(payload.TryGetProperty("name", out var name)); - Assert.Equal("neo", name.GetString()); - Assert.True(payload.TryGetProperty("age", out var age)); - Assert.Equal(2, age.GetInt32()); - } - - [Fact] - public void MissingSelectPath_Throws() - { - var input = JsonSerializer.SerializeToUtf8Bytes(new { a = new { b = 1 } }); - Assert.Throws(() => JsonTransform.SelectAndMount(input, selectPath: "a:c", mountPath: null)); - } - - [Fact] - public void MountOnly_PrimitiveRoot_WrapsValue() - { - var input = JsonSerializer.SerializeToUtf8Bytes(123); - var result = JsonTransform.SelectAndMount(input, selectPath: null, mountPath: "v"); - - using var doc = JsonDocument.Parse(result); - var root = doc.RootElement; - Assert.True(root.TryGetProperty("v", out var v)); - Assert.Equal(123, v.GetInt32()); - } -} - - - +using System.Text; +using System.Text.Json; +using Cocoar.Configuration.Helper; +using Xunit; + +namespace Cocoar.Configuration.Core.Tests.Helpers; + +public class JsonTransformStreamingTests +{ + private static ReadOnlyMemory Utf8(string json) => Encoding.UTF8.GetBytes(json); + + private static JsonElement Parse(ReadOnlyMemory bytes) => JsonDocument.Parse(bytes).RootElement.Clone(); + + [Fact] + public void MountOnly_WrapsRootObject() + { + var input = JsonSerializer.SerializeToUtf8Bytes(new { a = 1 }); + var result = JsonTransform.SelectAndMount(input, selectPath: null, mountPath: "x:y"); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + Assert.True(root.TryGetProperty("x", out var x)); + Assert.True(x.TryGetProperty("y", out var y)); + Assert.True(y.TryGetProperty("a", out var a)); + Assert.Equal(1, a.GetInt32()); + } + + [Fact] + public void SelectOnly_ObjectProperty_YieldsPrimitive() + { + var input = JsonSerializer.SerializeToUtf8Bytes(new { user = new { name = "neo", age = 2 } }); + var result = JsonTransform.SelectAndMount(input, selectPath: "user:name", mountPath: null); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + Assert.Equal(JsonValueKind.String, root.ValueKind); + Assert.Equal("neo", root.GetString()); + } + + [Fact] + public void Select_ArrayIndex_YieldsNumber() + { + var input = JsonSerializer.SerializeToUtf8Bytes(new { items = new[] { new { id = 1 }, new { id = 2 } } }); + var result = JsonTransform.SelectAndMount(input, selectPath: "items:1:id", mountPath: null); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + Assert.Equal(JsonValueKind.Number, root.ValueKind); + Assert.Equal(2, root.GetInt32()); + } + + [Fact] + public void SelectAndMount_WrapsSelectedSubtree() + { + var input = JsonSerializer.SerializeToUtf8Bytes(new { user = new { name = "neo", age = 2 } }); + var result = JsonTransform.SelectAndMount(input, selectPath: "user", mountPath: "root:payload"); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + Assert.True(root.TryGetProperty("root", out var r)); + Assert.True(r.TryGetProperty("payload", out var payload)); + Assert.True(payload.TryGetProperty("name", out var name)); + Assert.Equal("neo", name.GetString()); + Assert.True(payload.TryGetProperty("age", out var age)); + Assert.Equal(2, age.GetInt32()); + } + + [Fact] + public void MissingSelectPath_Throws() + { + var input = JsonSerializer.SerializeToUtf8Bytes(new { a = new { b = 1 } }); + Assert.Throws(() => JsonTransform.SelectAndMount(input, selectPath: "a:c", mountPath: null)); + } + + [Fact] + public void MountOnly_PrimitiveRoot_WrapsValue() + { + var input = JsonSerializer.SerializeToUtf8Bytes(123); + var result = JsonTransform.SelectAndMount(input, selectPath: null, mountPath: "v"); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + Assert.True(root.TryGetProperty("v", out var v)); + Assert.Equal(123, v.GetInt32()); + } +} + + + diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Helpers/TestRules.cs b/src/tests/Cocoar.Configuration.Core.Tests/Helpers/TestRules.cs index 84855cb..fe84089 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Helpers/TestRules.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Helpers/TestRules.cs @@ -1,52 +1,52 @@ -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Rules; - -namespace Cocoar.Configuration.Core.Tests.Helpers; - -/// -/// Test helper to create configuration rules without using the fluent RulesBuilder API. -/// This allows tests to build individual rules and store them in variables. -/// -public static class TestRules -{ - private static readonly RulesBuilder Builder = new(); - - public static ConfigRule StaticJson(string json, bool required = false) where T : class - { - var rule = Builder.For().FromStaticJson(json); - return required ? rule.Required() : rule; - } - - public static ConfigRule Observable(System.IObservable observable, bool required = false) where T : class - { - var rule = Builder.For().FromObservable(observable); - return required ? rule.Required() : rule; - } - - public static ConfigRule ObservableString(System.IObservable jsonObservable, bool required = false) where T : class - { - var rule = Builder.For().FromObservable(jsonObservable); - return required ? rule.Required() : rule; - } - - public static ConfigRule File(string filePath, string? selectPath = null, bool required = false) where T : class - { - var builder = Builder.For().FromFile(filePath); - if (selectPath != null) - { - builder = builder.Select(selectPath); - } - var rule = builder; - return required ? rule.Required() : rule; - } - - public static ConfigRule Environment(string? prefix = null, bool required = false) where T : class - { - var rule = Builder.For().FromEnvironment(prefix); - return required ? rule.Required() : rule; - } -} - - - +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Rules; + +namespace Cocoar.Configuration.Core.Tests.Helpers; + +/// +/// Test helper to create configuration rules without using the fluent RulesBuilder API. +/// This allows tests to build individual rules and store them in variables. +/// +public static class TestRules +{ + private static readonly RulesBuilder Builder = new(); + + public static ConfigRule StaticJson(string json, bool required = false) where T : class + { + var rule = Builder.For().FromStaticJson(json); + return required ? rule.Required() : rule; + } + + public static ConfigRule Observable(System.IObservable observable, bool required = false) where T : class + { + var rule = Builder.For().FromObservable(observable); + return required ? rule.Required() : rule; + } + + public static ConfigRule ObservableString(System.IObservable jsonObservable, bool required = false) where T : class + { + var rule = Builder.For().FromObservable(jsonObservable); + return required ? rule.Required() : rule; + } + + public static ConfigRule File(string filePath, string? selectPath = null, bool required = false) where T : class + { + var builder = Builder.For().FromFile(filePath); + if (selectPath != null) + { + builder = builder.Select(selectPath); + } + var rule = builder; + return required ? rule.Required() : rule; + } + + public static ConfigRule Environment(string? prefix = null, bool required = false) where T : class + { + var rule = Builder.For().FromEnvironment(prefix); + return required ? rule.Required() : rule; + } +} + + + diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Integration/ConfigMergingDebugTest.cs b/src/tests/Cocoar.Configuration.Core.Tests/Integration/ConfigMergingDebugTest.cs index 3490670..44a1f2b 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Integration/ConfigMergingDebugTest.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Integration/ConfigMergingDebugTest.cs @@ -1,77 +1,77 @@ -using System.Text.Json; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; - -using Cocoar.Configuration.Core.Tests.Helpers; - -namespace Cocoar.Configuration.Core.Tests.Integration; - -/// -/// Debug test to understand how configuration merging works -/// -public class ConfigMergingDebugTest -{ - public class TestConfig - { - public string Name { get; set; } = string.Empty; - public int Value { get; set; } - public SubConfig Sub { get; set; } = new(); - } - - public class SubConfig - { - public string Property { get; set; } = string.Empty; - public int Number { get; set; } - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public void Debug_HowConfigMergingWorks() - { - - var staticBase = """ - { - "Name": "Static", - "Value": 100, - "Sub": { - "Property": "StaticProp", - "Number": 50 - } - } - """; - - // Observable with only partial data (this should use JSON to test flattening) - var observablePartialJson = """ - { - "Name": "Observable", - "Sub": { - "Property": "ObservableProp" - } - } - """; - - var rules = new List - { - TestRules.StaticJson(staticBase), - TestRules.StaticJson(observablePartialJson) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); - var config = configManager.GetConfig(); - - // Expected flattened merge: - // Rule 0: Name=Static, Value=100, Sub.Property=StaticProp, Sub.Number=50 - // Rule 1: Name=Observable, Sub.Property=ObservableProp - // Result: Name=Observable (overridden), Value=100 (kept), Sub.Property=ObservableProp (overridden), Sub.Number=50 (kept) - - Assert.NotNull(config); - Assert.Equal("Observable", config.Name); // Should be overridden - Assert.Equal(100, config.Value); // Should be kept from static - Assert.Equal("ObservableProp", config.Sub.Property); // Should be overridden - Assert.Equal(50, config.Sub.Number); // Should be kept from static - } -} - - - +using System.Text.Json; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; + +using Cocoar.Configuration.Core.Tests.Helpers; + +namespace Cocoar.Configuration.Core.Tests.Integration; + +/// +/// Debug test to understand how configuration merging works +/// +public class ConfigMergingDebugTest +{ + public class TestConfig + { + public string Name { get; set; } = string.Empty; + public int Value { get; set; } + public SubConfig Sub { get; set; } = new(); + } + + public class SubConfig + { + public string Property { get; set; } = string.Empty; + public int Number { get; set; } + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public void Debug_HowConfigMergingWorks() + { + + var staticBase = """ + { + "Name": "Static", + "Value": 100, + "Sub": { + "Property": "StaticProp", + "Number": 50 + } + } + """; + + // Observable with only partial data (this should use JSON to test flattening) + var observablePartialJson = """ + { + "Name": "Observable", + "Sub": { + "Property": "ObservableProp" + } + } + """; + + var rules = new List + { + TestRules.StaticJson(staticBase), + TestRules.StaticJson(observablePartialJson) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); + var config = configManager.GetConfig(); + + // Expected flattened merge: + // Rule 0: Name=Static, Value=100, Sub.Property=StaticProp, Sub.Number=50 + // Rule 1: Name=Observable, Sub.Property=ObservableProp + // Result: Name=Observable (overridden), Value=100 (kept), Sub.Property=ObservableProp (overridden), Sub.Number=50 (kept) + + Assert.NotNull(config); + Assert.Equal("Observable", config.Name); // Should be overridden + Assert.Equal(100, config.Value); // Should be kept from static + Assert.Equal("ObservableProp", config.Sub.Property); // Should be overridden + Assert.Equal(50, config.Sub.Number); // Should be kept from static + } +} + + + diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Integration/InterfaceDeserializationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Integration/InterfaceDeserializationTests.cs index be0fee2..40fe41f 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Integration/InterfaceDeserializationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Integration/InterfaceDeserializationTests.cs @@ -1,329 +1,329 @@ -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Providers; - -namespace Cocoar.Configuration.Core.Tests.Integration; - -/// -/// Tests for interface deserialization in configuration objects. -/// This addresses the scenario where configuration classes have interface-typed properties -/// that need to be deserialized from JSON (e.g., from environment variables or files). -/// -public class InterfaceDeserializationTests -{ - // Test interfaces and implementations - public interface ILoggingConfig - { - string LogPath { get; set; } - Dictionary LogLevel { get; set; } - } - - public class LoggingConfig : ILoggingConfig - { - public string LogPath { get; set; } = ""; - public Dictionary LogLevel { get; set; } = new(); - } - - public class AppConfiguration - { - public string AppName { get; set; } = ""; - public ILoggingConfig Logging { get; set; } = new LoggingConfig(); - } - - [Fact] - public void Should_Deserialize_Interface_Property_From_StaticJson() - { - // Arrange: JSON with interface-typed property - var json = """ - { - "AppName": "TestApp", - "Logging": { - "LogPath": "/var/log/app.log", - "LogLevel": { - "Default": "Warning", - "Microsoft": "Information" - } - } - } - """; - - var configManager = ConfigManager.Create(c => c.UseConfiguration( - rules: rule => [ - rule.For().FromStaticJson(json) - ], - setup: setup => [ - setup.Interface().DeserializeTo() - ])); - - // Act - var config = configManager.GetRequiredConfig(); - - // Assert - Assert.NotNull(config); - Assert.Equal("TestApp", config.AppName); - Assert.NotNull(config.Logging); - Assert.IsType(config.Logging); - Assert.Equal("/var/log/app.log", config.Logging.LogPath); - Assert.Equal(2, config.Logging.LogLevel.Count); - Assert.Equal("Warning", config.Logging.LogLevel["Default"]); - Assert.Equal("Information", config.Logging.LogLevel["Microsoft"]); - } - - [Fact] - public void Should_Deserialize_Interface_Property_From_Environment_Variables() - { - // Arrange: Set environment variables that create a nested structure - Environment.SetEnvironmentVariable("AppName", "EnvTestApp"); - Environment.SetEnvironmentVariable("Logging__LogPath", "/tmp/env.log"); - Environment.SetEnvironmentVariable("Logging__LogLevel__Default", "Debug"); - Environment.SetEnvironmentVariable("Logging__LogLevel__System", "Error"); - - try - { - var configManager = ConfigManager.Create(c => c.UseConfiguration( - rules: rule => [ - rule.For().FromEnvironment() - ], - setup: setup => [ - setup.Interface().DeserializeTo() - ])); - - // Act - var config = configManager.GetRequiredConfig(); - - // Assert - Assert.NotNull(config); - Assert.Equal("EnvTestApp", config.AppName); - Assert.NotNull(config.Logging); - Assert.IsType(config.Logging); - Assert.Equal("/tmp/env.log", config.Logging.LogPath); - Assert.Equal(2, config.Logging.LogLevel.Count); - Assert.Equal("Debug", config.Logging.LogLevel["Default"]); - Assert.Equal("Error", config.Logging.LogLevel["System"]); - } - finally - { - // Cleanup - Environment.SetEnvironmentVariable("AppName", null); - Environment.SetEnvironmentVariable("Logging__LogPath", null); - Environment.SetEnvironmentVariable("Logging__LogLevel__Default", null); - Environment.SetEnvironmentVariable("Logging__LogLevel__System", null); - } - } - - // Additional interface for testing multiple mappings - public interface IDatabaseConfig - { - string ConnectionString { get; set; } - } - - public class DatabaseConfig : IDatabaseConfig - { - public string ConnectionString { get; set; } = ""; - } - - public class ComplexConfiguration - { - public ILoggingConfig Logging { get; set; } = new LoggingConfig(); - public IDatabaseConfig Database { get; set; } = new DatabaseConfig(); - } - - [Fact] - public void Should_Handle_Multiple_Interface_Mappings() - { - - // Arrange - var json = """ - { - "Logging": { - "LogPath": "/var/log/app.log", - "LogLevel": { - "Default": "Info" - } - }, - "Database": { - "ConnectionString": "Server=localhost;Database=test" - } - } - """; - - var configManager = ConfigManager.Create(c => c.UseConfiguration( - rules: rule => [ - rule.For().FromStaticJson(json) - ], - setup: setup => [ - setup.Interface().DeserializeTo(), - setup.Interface().DeserializeTo() - ])); - - // Act - var config = configManager.GetRequiredConfig(); - - // Assert - Assert.NotNull(config.Logging); - Assert.IsType(config.Logging); - Assert.Equal("/var/log/app.log", config.Logging.LogPath); - - Assert.NotNull(config.Database); - Assert.IsType(config.Database); - Assert.Equal("Server=localhost;Database=test", config.Database.ConnectionString); - } - - // Nested interface types for testing - public interface IRetryPolicy - { - int MaxRetries { get; set; } - int DelayMs { get; set; } - } - - public class RetryPolicy : IRetryPolicy - { - public int MaxRetries { get; set; } - public int DelayMs { get; set; } - } - - public interface IAdvancedLoggingConfig - { - string LogPath { get; set; } - IRetryPolicy RetryPolicy { get; set; } // Nested interface! - } - - public class AdvancedLoggingConfig : IAdvancedLoggingConfig - { - public string LogPath { get; set; } = ""; - public IRetryPolicy RetryPolicy { get; set; } = new RetryPolicy(); - } - - public class NestedConfiguration - { - public string AppName { get; set; } = ""; - public IAdvancedLoggingConfig Logging { get; set; } = new AdvancedLoggingConfig(); - } - - [Fact] - public void Should_Handle_Nested_Interface_Properties() - { - // Arrange: JSON with nested interface properties - var json = """ - { - "AppName": "NestedTest", - "Logging": { - "LogPath": "/var/log/nested.log", - "RetryPolicy": { - "MaxRetries": 3, - "DelayMs": 1000 - } - } - } - """; - - var configManager = ConfigManager.Create(c => c.UseConfiguration( - rules: rule => [ - rule.For().FromStaticJson(json) - ], - setup: setup => [ - setup.Interface().DeserializeTo(), - setup.Interface().DeserializeTo() - ])); - - // Act - var config = configManager.GetRequiredConfig(); - - // Assert - Assert.NotNull(config); - Assert.Equal("NestedTest", config.AppName); - - Assert.NotNull(config.Logging); - Assert.IsType(config.Logging); - Assert.Equal("/var/log/nested.log", config.Logging.LogPath); - - Assert.NotNull(config.Logging.RetryPolicy); - Assert.IsType(config.Logging.RetryPolicy); - Assert.Equal(3, config.Logging.RetryPolicy.MaxRetries); - Assert.Equal(1000, config.Logging.RetryPolicy.DelayMs); - } - - // Types for deeply nested testing - public interface ICircuitBreaker - { - int Threshold { get; set; } - } - - public class CircuitBreaker : ICircuitBreaker - { - public int Threshold { get; set; } - } - - public interface IAdvancedRetryPolicy - { - int MaxRetries { get; set; } - ICircuitBreaker CircuitBreaker { get; set; } // 3 levels deep! - } - - public class AdvancedRetryPolicy : IAdvancedRetryPolicy - { - public int MaxRetries { get; set; } - public ICircuitBreaker CircuitBreaker { get; set; } = new CircuitBreaker(); - } - - public interface IDeepLoggingConfig - { - string LogPath { get; set; } - IAdvancedRetryPolicy RetryPolicy { get; set; } - } - - public class DeepLoggingConfig : IDeepLoggingConfig - { - public string LogPath { get; set; } = ""; - public IAdvancedRetryPolicy RetryPolicy { get; set; } = new AdvancedRetryPolicy(); - } - - public class DeepConfiguration - { - public IDeepLoggingConfig Logging { get; set; } = new DeepLoggingConfig(); - } - - [Fact] - public void Should_Handle_Deeply_Nested_Interface_Properties() - { - - // Arrange: 3 levels of nested interfaces - var json = """ - { - "Logging": { - "LogPath": "/var/log/deep.log", - "RetryPolicy": { - "MaxRetries": 5, - "CircuitBreaker": { - "Threshold": 10 - } - } - } - } - """; - - var configManager = ConfigManager.Create(c => c.UseConfiguration( - rules: rule => [ - rule.For().FromStaticJson(json) - ], - setup: setup => [ - setup.Interface().DeserializeTo(), - setup.Interface().DeserializeTo(), - setup.Interface().DeserializeTo() - ])); - - // Act - var config = configManager.GetRequiredConfig(); - - // Assert - Assert.NotNull(config.Logging); - Assert.Equal("/var/log/deep.log", config.Logging.LogPath); - Assert.NotNull(config.Logging.RetryPolicy); - Assert.Equal(5, config.Logging.RetryPolicy.MaxRetries); - Assert.NotNull(config.Logging.RetryPolicy.CircuitBreaker); - Assert.Equal(10, config.Logging.RetryPolicy.CircuitBreaker.Threshold); - } -} - - - +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; + +namespace Cocoar.Configuration.Core.Tests.Integration; + +/// +/// Tests for interface deserialization in configuration objects. +/// This addresses the scenario where configuration classes have interface-typed properties +/// that need to be deserialized from JSON (e.g., from environment variables or files). +/// +public class InterfaceDeserializationTests +{ + // Test interfaces and implementations + public interface ILoggingConfig + { + string LogPath { get; set; } + Dictionary LogLevel { get; set; } + } + + public class LoggingConfig : ILoggingConfig + { + public string LogPath { get; set; } = ""; + public Dictionary LogLevel { get; set; } = new(); + } + + public class AppConfiguration + { + public string AppName { get; set; } = ""; + public ILoggingConfig Logging { get; set; } = new LoggingConfig(); + } + + [Fact] + public void Should_Deserialize_Interface_Property_From_StaticJson() + { + // Arrange: JSON with interface-typed property + var json = """ + { + "AppName": "TestApp", + "Logging": { + "LogPath": "/var/log/app.log", + "LogLevel": { + "Default": "Warning", + "Microsoft": "Information" + } + } + } + """; + + var configManager = ConfigManager.Create(c => c.UseConfiguration( + rules: rule => [ + rule.For().FromStaticJson(json) + ], + setup: setup => [ + setup.Interface().DeserializeTo() + ])); + + // Act + var config = configManager.GetRequiredConfig(); + + // Assert + Assert.NotNull(config); + Assert.Equal("TestApp", config.AppName); + Assert.NotNull(config.Logging); + Assert.IsType(config.Logging); + Assert.Equal("/var/log/app.log", config.Logging.LogPath); + Assert.Equal(2, config.Logging.LogLevel.Count); + Assert.Equal("Warning", config.Logging.LogLevel["Default"]); + Assert.Equal("Information", config.Logging.LogLevel["Microsoft"]); + } + + [Fact] + public void Should_Deserialize_Interface_Property_From_Environment_Variables() + { + // Arrange: Set environment variables that create a nested structure + Environment.SetEnvironmentVariable("AppName", "EnvTestApp"); + Environment.SetEnvironmentVariable("Logging__LogPath", "/tmp/env.log"); + Environment.SetEnvironmentVariable("Logging__LogLevel__Default", "Debug"); + Environment.SetEnvironmentVariable("Logging__LogLevel__System", "Error"); + + try + { + var configManager = ConfigManager.Create(c => c.UseConfiguration( + rules: rule => [ + rule.For().FromEnvironment() + ], + setup: setup => [ + setup.Interface().DeserializeTo() + ])); + + // Act + var config = configManager.GetRequiredConfig(); + + // Assert + Assert.NotNull(config); + Assert.Equal("EnvTestApp", config.AppName); + Assert.NotNull(config.Logging); + Assert.IsType(config.Logging); + Assert.Equal("/tmp/env.log", config.Logging.LogPath); + Assert.Equal(2, config.Logging.LogLevel.Count); + Assert.Equal("Debug", config.Logging.LogLevel["Default"]); + Assert.Equal("Error", config.Logging.LogLevel["System"]); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("AppName", null); + Environment.SetEnvironmentVariable("Logging__LogPath", null); + Environment.SetEnvironmentVariable("Logging__LogLevel__Default", null); + Environment.SetEnvironmentVariable("Logging__LogLevel__System", null); + } + } + + // Additional interface for testing multiple mappings + public interface IDatabaseConfig + { + string ConnectionString { get; set; } + } + + public class DatabaseConfig : IDatabaseConfig + { + public string ConnectionString { get; set; } = ""; + } + + public class ComplexConfiguration + { + public ILoggingConfig Logging { get; set; } = new LoggingConfig(); + public IDatabaseConfig Database { get; set; } = new DatabaseConfig(); + } + + [Fact] + public void Should_Handle_Multiple_Interface_Mappings() + { + + // Arrange + var json = """ + { + "Logging": { + "LogPath": "/var/log/app.log", + "LogLevel": { + "Default": "Info" + } + }, + "Database": { + "ConnectionString": "Server=localhost;Database=test" + } + } + """; + + var configManager = ConfigManager.Create(c => c.UseConfiguration( + rules: rule => [ + rule.For().FromStaticJson(json) + ], + setup: setup => [ + setup.Interface().DeserializeTo(), + setup.Interface().DeserializeTo() + ])); + + // Act + var config = configManager.GetRequiredConfig(); + + // Assert + Assert.NotNull(config.Logging); + Assert.IsType(config.Logging); + Assert.Equal("/var/log/app.log", config.Logging.LogPath); + + Assert.NotNull(config.Database); + Assert.IsType(config.Database); + Assert.Equal("Server=localhost;Database=test", config.Database.ConnectionString); + } + + // Nested interface types for testing + public interface IRetryPolicy + { + int MaxRetries { get; set; } + int DelayMs { get; set; } + } + + public class RetryPolicy : IRetryPolicy + { + public int MaxRetries { get; set; } + public int DelayMs { get; set; } + } + + public interface IAdvancedLoggingConfig + { + string LogPath { get; set; } + IRetryPolicy RetryPolicy { get; set; } // Nested interface! + } + + public class AdvancedLoggingConfig : IAdvancedLoggingConfig + { + public string LogPath { get; set; } = ""; + public IRetryPolicy RetryPolicy { get; set; } = new RetryPolicy(); + } + + public class NestedConfiguration + { + public string AppName { get; set; } = ""; + public IAdvancedLoggingConfig Logging { get; set; } = new AdvancedLoggingConfig(); + } + + [Fact] + public void Should_Handle_Nested_Interface_Properties() + { + // Arrange: JSON with nested interface properties + var json = """ + { + "AppName": "NestedTest", + "Logging": { + "LogPath": "/var/log/nested.log", + "RetryPolicy": { + "MaxRetries": 3, + "DelayMs": 1000 + } + } + } + """; + + var configManager = ConfigManager.Create(c => c.UseConfiguration( + rules: rule => [ + rule.For().FromStaticJson(json) + ], + setup: setup => [ + setup.Interface().DeserializeTo(), + setup.Interface().DeserializeTo() + ])); + + // Act + var config = configManager.GetRequiredConfig(); + + // Assert + Assert.NotNull(config); + Assert.Equal("NestedTest", config.AppName); + + Assert.NotNull(config.Logging); + Assert.IsType(config.Logging); + Assert.Equal("/var/log/nested.log", config.Logging.LogPath); + + Assert.NotNull(config.Logging.RetryPolicy); + Assert.IsType(config.Logging.RetryPolicy); + Assert.Equal(3, config.Logging.RetryPolicy.MaxRetries); + Assert.Equal(1000, config.Logging.RetryPolicy.DelayMs); + } + + // Types for deeply nested testing + public interface ICircuitBreaker + { + int Threshold { get; set; } + } + + public class CircuitBreaker : ICircuitBreaker + { + public int Threshold { get; set; } + } + + public interface IAdvancedRetryPolicy + { + int MaxRetries { get; set; } + ICircuitBreaker CircuitBreaker { get; set; } // 3 levels deep! + } + + public class AdvancedRetryPolicy : IAdvancedRetryPolicy + { + public int MaxRetries { get; set; } + public ICircuitBreaker CircuitBreaker { get; set; } = new CircuitBreaker(); + } + + public interface IDeepLoggingConfig + { + string LogPath { get; set; } + IAdvancedRetryPolicy RetryPolicy { get; set; } + } + + public class DeepLoggingConfig : IDeepLoggingConfig + { + public string LogPath { get; set; } = ""; + public IAdvancedRetryPolicy RetryPolicy { get; set; } = new AdvancedRetryPolicy(); + } + + public class DeepConfiguration + { + public IDeepLoggingConfig Logging { get; set; } = new DeepLoggingConfig(); + } + + [Fact] + public void Should_Handle_Deeply_Nested_Interface_Properties() + { + + // Arrange: 3 levels of nested interfaces + var json = """ + { + "Logging": { + "LogPath": "/var/log/deep.log", + "RetryPolicy": { + "MaxRetries": 5, + "CircuitBreaker": { + "Threshold": 10 + } + } + } + } + """; + + var configManager = ConfigManager.Create(c => c.UseConfiguration( + rules: rule => [ + rule.For().FromStaticJson(json) + ], + setup: setup => [ + setup.Interface().DeserializeTo(), + setup.Interface().DeserializeTo(), + setup.Interface().DeserializeTo() + ])); + + // Act + var config = configManager.GetRequiredConfig(); + + // Assert + Assert.NotNull(config.Logging); + Assert.Equal("/var/log/deep.log", config.Logging.LogPath); + Assert.NotNull(config.Logging.RetryPolicy); + Assert.Equal(5, config.Logging.RetryPolicy.MaxRetries); + Assert.NotNull(config.Logging.RetryPolicy.CircuitBreaker); + Assert.Equal(10, config.Logging.RetryPolicy.CircuitBreaker.Threshold); + } +} + + + diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderIsolationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderIsolationTests.cs index 1e4ee60..007bed8 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderIsolationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderIsolationTests.cs @@ -1,486 +1,486 @@ -using System.Reactive.Subjects; -using System.Text.Json; -using Cocoar.Configuration.Core.Tests.TestUtilities; -using static Cocoar.Configuration.Core.Tests.Integration.MultiProviderTestModels; - -namespace Cocoar.Configuration.Core.Tests.Integration; -[Trait("Category", "Integration")] -[Trait("Component", "ConfigManager")] -public class MultiProviderIsolationTests -{ - #region Provider Isolation Regression Tests - - #region Provider Isolation Regression Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public async Task ObservableProvider_MultipleRules_SameSource_NoCrossRuleBleed() - { - - var initialJson = @"{ - ""AppSettings"": { - ""Name"": ""TestApp"", - ""Version"": 1 - }, - ""DatabaseSettings"": { - ""ConnectionString"": ""server=test"", - ""Timeout"": 30 - } - }"; - - using var subject = new BehaviorSubject(initialJson); - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules => [ - rules.For().FromObservable(subject).Select("AppSettings"), - - rules.For().FromObservable(subject).Select("DatabaseSettings") - ]).UseDebounce(100)); - - var appConfig = configManager.GetReactiveConfig(); - var databaseConfig = configManager.GetReactiveConfig(); - - var appEmissions = new List(); - var dbEmissions = new List(); - - var appSubscription = appConfig.Subscribe(config => appEmissions.Add(config)); - var dbSubscription = databaseConfig.Subscribe(config => dbEmissions.Add(config)); - - await ActiveWaitHelpers.WaitUntilAsync(() => appEmissions.Count >= 1 && dbEmissions.Count >= 1, - description: "initial configurations"); - - var initialApp = appEmissions.Last(); - var initialDb = dbEmissions.Last(); - - Assert.Equal("TestApp", initialApp.Name); - Assert.Equal(1, initialApp.Version); - Assert.Equal("server=test", initialDb.ConnectionString); - Assert.Equal(30, initialDb.Timeout); - - var updatedJson = @"{ - ""AppSettings"": { - ""Name"": ""UpdatedApp"", - ""Version"": 2 - }, - ""DatabaseSettings"": { - ""ConnectionString"": ""server=updated"", - ""Timeout"": 60 - } - }"; - - subject.OnNext(updatedJson); - - await ActiveWaitHelpers.WaitUntilAsync(() => appEmissions.Count >= 2 && dbEmissions.Count >= 2, - description: "updated configurations after observable change"); - - var updatedApp = appEmissions.Last(); - var updatedDb = dbEmissions.Last(); - Assert.Equal("UpdatedApp", updatedApp.Name); - Assert.Equal(2, updatedApp.Version); - Assert.Equal("", updatedApp.Database.ConnectionString); Assert.Equal("server=updated", updatedDb.ConnectionString); - Assert.Equal(60, updatedDb.Timeout); - Assert.NotEqual("server=updated", updatedApp.Database.ConnectionString); - Assert.NotEqual("UpdatedApp", updatedDb.ConnectionString); // Connection string is not the app name - appSubscription.Dispose(); - dbSubscription.Dispose(); - } - - /// - /// CRITICAL MULTI-WAVE RECOMPUTE TEST: Validates consecutive partial recompute bursts correctly reuse prefix optimizations. - /// This test ensures that when multiple waves of changes occur in rapid succession, the incremental recompute pipeline - /// properly tracks wave progression and maintains prefix reuse without causing redundant refetches of unaffected providers. - /// Critical for preventing performance regression where multi-wave bursts bypass partial recompute optimizations. - /// - [Fact] - [Trait("Type", "Performance")] - [Trait("Provider", "ConfigManager")] - public async Task ConfigManager_MultiWavePartialRecompute_CorrectlyReusesPrefixes() - { - - var subject1 = new BehaviorSubject("""{"Rule1Value": 1}"""); - var subject2 = new BehaviorSubject("""{"Rule2Value": 10}"""); - var subject3 = new BehaviorSubject("""{"Rule3Value": 100}"""); - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules => [ - rules.For>().FromStaticJson("""{"Static": "Base", "Priority": 1}"""), - - rules.For>().FromObservable(subject1), - - rules.For>().FromObservable(subject2), - - rules.For>().FromObservable(subject3) - ]).UseDebounce(30)); - - var config = configManager.GetReactiveConfig>(); - - var emissions = new List>(); - var subscription = config.Subscribe(c => emissions.Add(c)); - - await ActiveWaitHelpers.WaitUntilAsync(() => emissions.Count >= 1, - timeout: TimeSpan.FromMilliseconds(500), - description: "initial configuration load"); - - var baselineEmissions = emissions.Count; - subject3.OnNext("""{"Rule3Value": 101}"""); - await Task.Delay(10); // Small interval between rapid changes - subject2.OnNext("""{"Rule2Value": 11}"""); - await Task.Delay(10); // Small interval between rapid changes - subject3.OnNext("""{"Rule3Value": 102}"""); - await Task.Delay(10); // Small interval between rapid changes - subject1.OnNext("""{"Rule1Value": 2}"""); - - // Wait for all waves to complete - expect at least one emission after baseline - await ActiveWaitHelpers.WaitUntilAsync(() => emissions.Count > baselineEmissions, - timeout: TimeSpan.FromMilliseconds(500), - description: "multi-wave burst completion"); - - var finalConfig = emissions.Last(); - - Assert.True(finalConfig.ContainsKey("Static")); - Assert.Equal("Base", finalConfig["Static"].ToString()); - Assert.True(finalConfig.ContainsKey("Rule1Value")); - var rule1Value = ((JsonElement)finalConfig["Rule1Value"]).GetInt32(); - Assert.True(rule1Value == 1 || rule1Value == 2, - $"Rule1Value should be 1 (initial) or 2 (final), got {rule1Value}"); - - Assert.True(finalConfig.ContainsKey("Rule2Value")); - var rule2Value = ((JsonElement)finalConfig["Rule2Value"]).GetInt32(); - Assert.True(rule2Value == 10 || rule2Value == 11, - $"Rule2Value should be 10 (initial) or 11 (updated), got {rule2Value}"); - - Assert.True(finalConfig.ContainsKey("Rule3Value")); - var rule3Value = ((JsonElement)finalConfig["Rule3Value"]).GetInt32(); - Assert.True(rule3Value >= 100 && rule3Value <= 102, - $"Rule3Value should be 100-102, got {rule3Value}"); - // We expect fewer emissions than individual wave changes due to debouncing - var totalWaveChanges = 4; // 4 individual OnNext calls - var actualEmissions = emissions.Count - baselineEmissions; - - Assert.True(actualEmissions <= totalWaveChanges, - $"Multi-wave burst should be debounced. Expected Γëñ{totalWaveChanges} emissions, got {actualEmissions}"); - Assert.True(actualEmissions >= 1, "At least one emission expected after multi-wave burst"); - subscription.Dispose(); - } - - /// - /// CRITICAL EMISSION MINIMALITY PROOF TEST: Validates the documented behavior "fewer emissions than changes". - /// This test proves that ConfigManager's debouncing and coalescing mechanisms produce fewer reactive emissions - /// than the raw number of provider changes, demonstrating efficiency and preventing emission floods. - /// Critical for confirming the performance guarantee that reactive subscribers won't be overwhelmed. - /// - [Fact] - [Trait("Type", "Performance")] - [Trait("Provider", "ConfigManager")] - public async Task ConfigManager_EmissionMinimalityProof_FewerEmissionsThanChanges() - { - var subject = new BehaviorSubject("""{"Value": 0, "Timestamp": "initial"}"""); - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules => [ - // Single observable rule to isolate emission behavior - rules.For>().FromObservable(subject) - ]).UseDebounce(50)); - - var config = configManager.GetReactiveConfig>(); - - var emissions = new List>(); - var subscription = config.Subscribe(c => emissions.Add(c)); - - await ActiveWaitHelpers.WaitUntilAsync(() => emissions.Count >= 1, - timeout: TimeSpan.FromMilliseconds(500), - description: "initial emission"); - - var baselineEmissions = emissions.Count; - - const int TOTAL_CHANGES = 10; - - for (var i = 1; i <= TOTAL_CHANGES; i++) - { - subject.OnNext($$$"""{"Value": {{{i}}}, "Timestamp": "change_{{{i}}}"}"""); - await Task.Delay(2); // 2ms between changes to test debouncing (20ms total < 50ms debounce) - } - - // Wait for debouncing to complete and final value to arrive - await ActiveWaitHelpers.WaitUntilAsync(() => - emissions.Count > baselineEmissions && - emissions.Last().ContainsKey("Value") && - ((JsonElement)emissions.Last()["Value"]).GetInt32() == TOTAL_CHANGES, - timeout: TimeSpan.FromSeconds(1), - description: "final debounced value arrival"); - - var actualEmissions = emissions.Count - baselineEmissions; - - // We explicitly pushed TOTAL_CHANGES updates, so emissions should be fewer - Assert.True(actualEmissions < TOTAL_CHANGES, - $"EMISSION MINIMALITY FAILED: Expected emissions ({actualEmissions}) to be less than raw changes ({TOTAL_CHANGES})"); - - var finalConfig = emissions.Last(); - Assert.True(finalConfig.ContainsKey("Value")); - var finalValue = ((JsonElement)finalConfig["Value"]).GetInt32(); - Assert.Equal(TOTAL_CHANGES, finalValue); // Final value should be 10 (last change) - - // Performance assertion: Significant emission reduction (at least 2:1 ratio) - var emissionReductionRatio = (double)TOTAL_CHANGES / actualEmissions; - Assert.True(emissionReductionRatio >= 2.0, - $"Expected significant emission reduction (≥2:1), got {emissionReductionRatio:F2}:1"); - subscription.Dispose(); - } - - #endregion - - #endregion - - #region Type Merging Edge Cases - - #region MEDIUM Priority Tests - Type Merging Edge Cases - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Priority", "Medium")] - public void Merge_ObjectVsScalar_LastRuleWins_ObjectReplacedEntirely() - { - - var objectBase = """ - { - "Database": { - "ConnectionString": "server=localhost;", - "Timeout": 30, - "EnableRetry": true - } - } - """; - - var scalarOverride = """ - { - "Database": "simple-connection-string" - } - """; - - var rules = new List - { - TestRules.StaticJson(objectBase), - TestRules.StaticJson(scalarOverride) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); - var config = configManager.GetConfig(); - Assert.NotNull(config); - - Assert.Equal("simple-connection-string", config.Database); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Priority", "Medium")] - public void Merge_ArrayVsObject_LastRuleWins_ArrayReplacedEntirely() - { - - var arrayBase = """ - { - "Settings": ["setting1", "setting2", "setting3"] - } - """; - - var objectOverride = """ - { - "Settings": { - "Primary": "newValue", - "Secondary": "anotherValue" - } - } - """; - - var rules = new List - { - TestRules.StaticJson(arrayBase), - TestRules.StaticJson(objectOverride) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); - var config = configManager.GetConfig(); - Assert.NotNull(config); - - Assert.NotNull(config.Settings); - Assert.Equal("newValue", config.Settings.Primary); - Assert.Equal("anotherValue", config.Settings.Secondary); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Priority", "Medium")] - public void Merge_NullVsValue_NullUsesDefault_LastRuleWins() - { - - var valuesBase = """ - { - "Name": "Original", - "Count": 100, - "Enabled": true, - "Score": 95.5 - } - """; - - var nullOverride = """ - { - "Name": null, - "Count": null, - "Enabled": null - } - """; - - var rules = new List - { - TestRules.StaticJson(valuesBase), - TestRules.StaticJson(nullOverride) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); - var config = configManager.GetConfig(); - Assert.NotNull(config); - - // Note: In .NET 9.0 with nullable reference types, even "non-nullable" strings can be null - // when deserialized from JSON null values. This is the actual behavior we're testing. - Assert.Null(config.Name); // string: JSON null ΓåÆ C# null (actual behavior) - Assert.Equal(0, config.Count); // int: null ΓåÆ 0 - Assert.False(config.Enabled); // bool: null ΓåÆ false - Assert.Equal(95.5, config.Score, 1); // Score not overridden, keeps original value - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Priority", "Medium")] - public async Task ConfigManager_SnapshotStable_DuringLiveUpdates() - { - - var initialConfig = """{"Name": "Initial", "Value": 100}"""; - var observable = new BehaviorSubject(initialConfig); - - var rules = new List - { - TestRules.ObservableString(observable) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); - - var snapshot1 = configManager.GetConfig(); - Assert.NotNull(snapshot1); - Assert.Equal("Initial", snapshot1!.Name); - Assert.Equal(100, snapshot1.Value); - - observable.OnNext("""{"Name": "Update1", "Value": 200}"""); - var snapshot2 = configManager.GetConfig(); // Should still be stable during debounce - Assert.NotNull(snapshot2); - - observable.OnNext("""{"Name": "Update2", "Value": 300}"""); - var snapshot3 = configManager.GetConfig(); // Should still be stable during debounce - Assert.NotNull(snapshot3); - // The key test is that GetConfig() doesn't throw or return inconsistent state - Assert.NotNull(snapshot2); - Assert.NotNull(snapshot3); - Assert.False(string.IsNullOrEmpty(snapshot2.Name)); - Assert.False(string.IsNullOrEmpty(snapshot3.Name)); - - await ActiveWaitHelpers.WaitUntilAsync(() => - { - var currentSnapshot = configManager.GetConfig(); - return currentSnapshot != null && currentSnapshot.Name == "Update2" && currentSnapshot.Value == 300; - }, TimeSpan.FromSeconds(2)); - - var finalSnapshot = configManager.GetConfig(); - Assert.NotNull(finalSnapshot); - Assert.Equal("Update2", finalSnapshot!.Name); - Assert.Equal(300, finalSnapshot.Value); - observable.Dispose(); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Priority", "Medium")] - public void Rule_SelectEmptyPath_DoesNotContributeToFinalConfig() - { - - var jsonWithoutPath = """ - { - "ExistingSection": { - "Value": "exists" - } - } - """; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules => [ - // This rule selects a non-existent path - should contribute nothing - rules.For().FromStaticJson(jsonWithoutPath) - .Select("NonExistentSection"), // This path doesn't exist - selection will be empty - - // Base rule to ensure we have some configuration - rules.For().FromStaticJson("""{"DefaultValue": "present"}""") - ])); - - var config = configManager.GetConfig(); - Assert.NotNull(config); - - Assert.Equal("present", config.DefaultValue); - Assert.Null(config.MountedSection); } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Priority", "Medium")] - public void Merging_FlattenedKeyOrder_Irrelevant_FinalJsonStructuralEquality() - { - - var config1 = """ - { - "Database": { - "ConnectionString": "server=prod;", - "Timeout": 60, - "EnableRetry": true - }, - "Features": { - "EnableNewUI": true, - "LogLevel": "Info" - } - } - """; - - var config2 = """ - { - "Features": { - "LogLevel": "Info", - "EnableNewUI": true - }, - "Database": { - "EnableRetry": true, - "Timeout": 60, - "ConnectionString": "server=prod;" - } - } - """; - - var configManager1 = ConfigManager.Create(c => c.UseConfiguration([TestRules.StaticJson(config1)])); - var configManager2 = ConfigManager.Create(c => c.UseConfiguration([TestRules.StaticJson(config2)])); - - var result1 = configManager1.GetConfig(); - var result2 = configManager2.GetConfig(); - Assert.NotNull(result1); - Assert.NotNull(result2); - - Assert.Equal(result1.Database.ConnectionString, result2.Database.ConnectionString); - Assert.Equal(result1.Database.Timeout, result2.Database.Timeout); - Assert.Equal(result1.Database.EnableRetry, result2.Database.EnableRetry); - Assert.Equal(result1.Features.EnableNewUI, result2.Features.EnableNewUI); - Assert.Equal(result1.Features.LogLevel, result2.Features.LogLevel); - - var json1 = JsonSerializer.Serialize(result1); - var json2 = JsonSerializer.Serialize(result2); - - // Parse and compare as JsonElements to ignore property order differences - using var doc1 = JsonDocument.Parse(json1); - using var doc2 = JsonDocument.Parse(json2); - - Assert.True(MultiProviderTestModels.JsonElementsEqual(doc1.RootElement, doc2.RootElement), - "Serialized JSON should be structurally equivalent regardless of source property order"); - } - - #endregion - - #endregion -} - +using System.Reactive.Subjects; +using System.Text.Json; +using Cocoar.Configuration.Core.Tests.TestUtilities; +using static Cocoar.Configuration.Core.Tests.Integration.MultiProviderTestModels; + +namespace Cocoar.Configuration.Core.Tests.Integration; +[Trait("Category", "Integration")] +[Trait("Component", "ConfigManager")] +public class MultiProviderIsolationTests +{ + #region Provider Isolation Regression Tests + + #region Provider Isolation Regression Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public async Task ObservableProvider_MultipleRules_SameSource_NoCrossRuleBleed() + { + + var initialJson = @"{ + ""AppSettings"": { + ""Name"": ""TestApp"", + ""Version"": 1 + }, + ""DatabaseSettings"": { + ""ConnectionString"": ""server=test"", + ""Timeout"": 30 + } + }"; + + using var subject = new BehaviorSubject(initialJson); + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules => [ + rules.For().FromObservable(subject).Select("AppSettings"), + + rules.For().FromObservable(subject).Select("DatabaseSettings") + ]).UseDebounce(100)); + + var appConfig = configManager.GetReactiveConfig(); + var databaseConfig = configManager.GetReactiveConfig(); + + var appEmissions = new List(); + var dbEmissions = new List(); + + var appSubscription = appConfig.Subscribe(config => appEmissions.Add(config)); + var dbSubscription = databaseConfig.Subscribe(config => dbEmissions.Add(config)); + + await ActiveWaitHelpers.WaitUntilAsync(() => appEmissions.Count >= 1 && dbEmissions.Count >= 1, + description: "initial configurations"); + + var initialApp = appEmissions.Last(); + var initialDb = dbEmissions.Last(); + + Assert.Equal("TestApp", initialApp.Name); + Assert.Equal(1, initialApp.Version); + Assert.Equal("server=test", initialDb.ConnectionString); + Assert.Equal(30, initialDb.Timeout); + + var updatedJson = @"{ + ""AppSettings"": { + ""Name"": ""UpdatedApp"", + ""Version"": 2 + }, + ""DatabaseSettings"": { + ""ConnectionString"": ""server=updated"", + ""Timeout"": 60 + } + }"; + + subject.OnNext(updatedJson); + + await ActiveWaitHelpers.WaitUntilAsync(() => appEmissions.Count >= 2 && dbEmissions.Count >= 2, + description: "updated configurations after observable change"); + + var updatedApp = appEmissions.Last(); + var updatedDb = dbEmissions.Last(); + Assert.Equal("UpdatedApp", updatedApp.Name); + Assert.Equal(2, updatedApp.Version); + Assert.Equal("", updatedApp.Database.ConnectionString); Assert.Equal("server=updated", updatedDb.ConnectionString); + Assert.Equal(60, updatedDb.Timeout); + Assert.NotEqual("server=updated", updatedApp.Database.ConnectionString); + Assert.NotEqual("UpdatedApp", updatedDb.ConnectionString); // Connection string is not the app name + appSubscription.Dispose(); + dbSubscription.Dispose(); + } + + /// + /// CRITICAL MULTI-WAVE RECOMPUTE TEST: Validates consecutive partial recompute bursts correctly reuse prefix optimizations. + /// This test ensures that when multiple waves of changes occur in rapid succession, the incremental recompute pipeline + /// properly tracks wave progression and maintains prefix reuse without causing redundant refetches of unaffected providers. + /// Critical for preventing performance regression where multi-wave bursts bypass partial recompute optimizations. + /// + [Fact] + [Trait("Type", "Performance")] + [Trait("Provider", "ConfigManager")] + public async Task ConfigManager_MultiWavePartialRecompute_CorrectlyReusesPrefixes() + { + + var subject1 = new BehaviorSubject("""{"Rule1Value": 1}"""); + var subject2 = new BehaviorSubject("""{"Rule2Value": 10}"""); + var subject3 = new BehaviorSubject("""{"Rule3Value": 100}"""); + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules => [ + rules.For>().FromStaticJson("""{"Static": "Base", "Priority": 1}"""), + + rules.For>().FromObservable(subject1), + + rules.For>().FromObservable(subject2), + + rules.For>().FromObservable(subject3) + ]).UseDebounce(30)); + + var config = configManager.GetReactiveConfig>(); + + var emissions = new List>(); + var subscription = config.Subscribe(c => emissions.Add(c)); + + await ActiveWaitHelpers.WaitUntilAsync(() => emissions.Count >= 1, + timeout: TimeSpan.FromMilliseconds(500), + description: "initial configuration load"); + + var baselineEmissions = emissions.Count; + subject3.OnNext("""{"Rule3Value": 101}"""); + await Task.Delay(10); // Small interval between rapid changes + subject2.OnNext("""{"Rule2Value": 11}"""); + await Task.Delay(10); // Small interval between rapid changes + subject3.OnNext("""{"Rule3Value": 102}"""); + await Task.Delay(10); // Small interval between rapid changes + subject1.OnNext("""{"Rule1Value": 2}"""); + + // Wait for all waves to complete - expect at least one emission after baseline + await ActiveWaitHelpers.WaitUntilAsync(() => emissions.Count > baselineEmissions, + timeout: TimeSpan.FromMilliseconds(500), + description: "multi-wave burst completion"); + + var finalConfig = emissions.Last(); + + Assert.True(finalConfig.ContainsKey("Static")); + Assert.Equal("Base", finalConfig["Static"].ToString()); + Assert.True(finalConfig.ContainsKey("Rule1Value")); + var rule1Value = ((JsonElement)finalConfig["Rule1Value"]).GetInt32(); + Assert.True(rule1Value == 1 || rule1Value == 2, + $"Rule1Value should be 1 (initial) or 2 (final), got {rule1Value}"); + + Assert.True(finalConfig.ContainsKey("Rule2Value")); + var rule2Value = ((JsonElement)finalConfig["Rule2Value"]).GetInt32(); + Assert.True(rule2Value == 10 || rule2Value == 11, + $"Rule2Value should be 10 (initial) or 11 (updated), got {rule2Value}"); + + Assert.True(finalConfig.ContainsKey("Rule3Value")); + var rule3Value = ((JsonElement)finalConfig["Rule3Value"]).GetInt32(); + Assert.True(rule3Value >= 100 && rule3Value <= 102, + $"Rule3Value should be 100-102, got {rule3Value}"); + // We expect fewer emissions than individual wave changes due to debouncing + var totalWaveChanges = 4; // 4 individual OnNext calls + var actualEmissions = emissions.Count - baselineEmissions; + + Assert.True(actualEmissions <= totalWaveChanges, + $"Multi-wave burst should be debounced. Expected Γëñ{totalWaveChanges} emissions, got {actualEmissions}"); + Assert.True(actualEmissions >= 1, "At least one emission expected after multi-wave burst"); + subscription.Dispose(); + } + + /// + /// CRITICAL EMISSION MINIMALITY PROOF TEST: Validates the documented behavior "fewer emissions than changes". + /// This test proves that ConfigManager's debouncing and coalescing mechanisms produce fewer reactive emissions + /// than the raw number of provider changes, demonstrating efficiency and preventing emission floods. + /// Critical for confirming the performance guarantee that reactive subscribers won't be overwhelmed. + /// + [Fact] + [Trait("Type", "Performance")] + [Trait("Provider", "ConfigManager")] + public async Task ConfigManager_EmissionMinimalityProof_FewerEmissionsThanChanges() + { + var subject = new BehaviorSubject("""{"Value": 0, "Timestamp": "initial"}"""); + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules => [ + // Single observable rule to isolate emission behavior + rules.For>().FromObservable(subject) + ]).UseDebounce(50)); + + var config = configManager.GetReactiveConfig>(); + + var emissions = new List>(); + var subscription = config.Subscribe(c => emissions.Add(c)); + + await ActiveWaitHelpers.WaitUntilAsync(() => emissions.Count >= 1, + timeout: TimeSpan.FromMilliseconds(500), + description: "initial emission"); + + var baselineEmissions = emissions.Count; + + const int TOTAL_CHANGES = 10; + + for (var i = 1; i <= TOTAL_CHANGES; i++) + { + subject.OnNext($$$"""{"Value": {{{i}}}, "Timestamp": "change_{{{i}}}"}"""); + await Task.Delay(2); // 2ms between changes to test debouncing (20ms total < 50ms debounce) + } + + // Wait for debouncing to complete and final value to arrive + await ActiveWaitHelpers.WaitUntilAsync(() => + emissions.Count > baselineEmissions && + emissions.Last().ContainsKey("Value") && + ((JsonElement)emissions.Last()["Value"]).GetInt32() == TOTAL_CHANGES, + timeout: TimeSpan.FromSeconds(1), + description: "final debounced value arrival"); + + var actualEmissions = emissions.Count - baselineEmissions; + + // We explicitly pushed TOTAL_CHANGES updates, so emissions should be fewer + Assert.True(actualEmissions < TOTAL_CHANGES, + $"EMISSION MINIMALITY FAILED: Expected emissions ({actualEmissions}) to be less than raw changes ({TOTAL_CHANGES})"); + + var finalConfig = emissions.Last(); + Assert.True(finalConfig.ContainsKey("Value")); + var finalValue = ((JsonElement)finalConfig["Value"]).GetInt32(); + Assert.Equal(TOTAL_CHANGES, finalValue); // Final value should be 10 (last change) + + // Performance assertion: Significant emission reduction (at least 2:1 ratio) + var emissionReductionRatio = (double)TOTAL_CHANGES / actualEmissions; + Assert.True(emissionReductionRatio >= 2.0, + $"Expected significant emission reduction (≥2:1), got {emissionReductionRatio:F2}:1"); + subscription.Dispose(); + } + + #endregion + + #endregion + + #region Type Merging Edge Cases + + #region MEDIUM Priority Tests - Type Merging Edge Cases + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Priority", "Medium")] + public void Merge_ObjectVsScalar_LastRuleWins_ObjectReplacedEntirely() + { + + var objectBase = """ + { + "Database": { + "ConnectionString": "server=localhost;", + "Timeout": 30, + "EnableRetry": true + } + } + """; + + var scalarOverride = """ + { + "Database": "simple-connection-string" + } + """; + + var rules = new List + { + TestRules.StaticJson(objectBase), + TestRules.StaticJson(scalarOverride) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); + var config = configManager.GetConfig(); + Assert.NotNull(config); + + Assert.Equal("simple-connection-string", config.Database); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Priority", "Medium")] + public void Merge_ArrayVsObject_LastRuleWins_ArrayReplacedEntirely() + { + + var arrayBase = """ + { + "Settings": ["setting1", "setting2", "setting3"] + } + """; + + var objectOverride = """ + { + "Settings": { + "Primary": "newValue", + "Secondary": "anotherValue" + } + } + """; + + var rules = new List + { + TestRules.StaticJson(arrayBase), + TestRules.StaticJson(objectOverride) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); + var config = configManager.GetConfig(); + Assert.NotNull(config); + + Assert.NotNull(config.Settings); + Assert.Equal("newValue", config.Settings.Primary); + Assert.Equal("anotherValue", config.Settings.Secondary); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Priority", "Medium")] + public void Merge_NullVsValue_NullUsesDefault_LastRuleWins() + { + + var valuesBase = """ + { + "Name": "Original", + "Count": 100, + "Enabled": true, + "Score": 95.5 + } + """; + + var nullOverride = """ + { + "Name": null, + "Count": null, + "Enabled": null + } + """; + + var rules = new List + { + TestRules.StaticJson(valuesBase), + TestRules.StaticJson(nullOverride) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); + var config = configManager.GetConfig(); + Assert.NotNull(config); + + // Note: In .NET 9.0 with nullable reference types, even "non-nullable" strings can be null + // when deserialized from JSON null values. This is the actual behavior we're testing. + Assert.Null(config.Name); // string: JSON null ΓåÆ C# null (actual behavior) + Assert.Equal(0, config.Count); // int: null ΓåÆ 0 + Assert.False(config.Enabled); // bool: null ΓåÆ false + Assert.Equal(95.5, config.Score, 1); // Score not overridden, keeps original value + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Priority", "Medium")] + public async Task ConfigManager_SnapshotStable_DuringLiveUpdates() + { + + var initialConfig = """{"Name": "Initial", "Value": 100}"""; + var observable = new BehaviorSubject(initialConfig); + + var rules = new List + { + TestRules.ObservableString(observable) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); + + var snapshot1 = configManager.GetConfig(); + Assert.NotNull(snapshot1); + Assert.Equal("Initial", snapshot1!.Name); + Assert.Equal(100, snapshot1.Value); + + observable.OnNext("""{"Name": "Update1", "Value": 200}"""); + var snapshot2 = configManager.GetConfig(); // Should still be stable during debounce + Assert.NotNull(snapshot2); + + observable.OnNext("""{"Name": "Update2", "Value": 300}"""); + var snapshot3 = configManager.GetConfig(); // Should still be stable during debounce + Assert.NotNull(snapshot3); + // The key test is that GetConfig() doesn't throw or return inconsistent state + Assert.NotNull(snapshot2); + Assert.NotNull(snapshot3); + Assert.False(string.IsNullOrEmpty(snapshot2.Name)); + Assert.False(string.IsNullOrEmpty(snapshot3.Name)); + + await ActiveWaitHelpers.WaitUntilAsync(() => + { + var currentSnapshot = configManager.GetConfig(); + return currentSnapshot != null && currentSnapshot.Name == "Update2" && currentSnapshot.Value == 300; + }, TimeSpan.FromSeconds(2)); + + var finalSnapshot = configManager.GetConfig(); + Assert.NotNull(finalSnapshot); + Assert.Equal("Update2", finalSnapshot!.Name); + Assert.Equal(300, finalSnapshot.Value); + observable.Dispose(); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Priority", "Medium")] + public void Rule_SelectEmptyPath_DoesNotContributeToFinalConfig() + { + + var jsonWithoutPath = """ + { + "ExistingSection": { + "Value": "exists" + } + } + """; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules => [ + // This rule selects a non-existent path - should contribute nothing + rules.For().FromStaticJson(jsonWithoutPath) + .Select("NonExistentSection"), // This path doesn't exist - selection will be empty + + // Base rule to ensure we have some configuration + rules.For().FromStaticJson("""{"DefaultValue": "present"}""") + ])); + + var config = configManager.GetConfig(); + Assert.NotNull(config); + + Assert.Equal("present", config.DefaultValue); + Assert.Null(config.MountedSection); } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Priority", "Medium")] + public void Merging_FlattenedKeyOrder_Irrelevant_FinalJsonStructuralEquality() + { + + var config1 = """ + { + "Database": { + "ConnectionString": "server=prod;", + "Timeout": 60, + "EnableRetry": true + }, + "Features": { + "EnableNewUI": true, + "LogLevel": "Info" + } + } + """; + + var config2 = """ + { + "Features": { + "LogLevel": "Info", + "EnableNewUI": true + }, + "Database": { + "EnableRetry": true, + "Timeout": 60, + "ConnectionString": "server=prod;" + } + } + """; + + var configManager1 = ConfigManager.Create(c => c.UseConfiguration([TestRules.StaticJson(config1)])); + var configManager2 = ConfigManager.Create(c => c.UseConfiguration([TestRules.StaticJson(config2)])); + + var result1 = configManager1.GetConfig(); + var result2 = configManager2.GetConfig(); + Assert.NotNull(result1); + Assert.NotNull(result2); + + Assert.Equal(result1.Database.ConnectionString, result2.Database.ConnectionString); + Assert.Equal(result1.Database.Timeout, result2.Database.Timeout); + Assert.Equal(result1.Database.EnableRetry, result2.Database.EnableRetry); + Assert.Equal(result1.Features.EnableNewUI, result2.Features.EnableNewUI); + Assert.Equal(result1.Features.LogLevel, result2.Features.LogLevel); + + var json1 = JsonSerializer.Serialize(result1); + var json2 = JsonSerializer.Serialize(result2); + + // Parse and compare as JsonElements to ignore property order differences + using var doc1 = JsonDocument.Parse(json1); + using var doc2 = JsonDocument.Parse(json2); + + Assert.True(MultiProviderTestModels.JsonElementsEqual(doc1.RootElement, doc2.RootElement), + "Serialized JSON should be structurally equivalent regardless of source property order"); + } + + #endregion + + #endregion +} + diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderMergingTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderMergingTests.cs index 5c0ee5a..82170f8 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderMergingTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderMergingTests.cs @@ -1,215 +1,215 @@ -using System.Reactive.Subjects; -using System.Text.Json; -using Cocoar.Configuration.Core.Tests.TestUtilities; -using static Cocoar.Configuration.Core.Tests.Integration.MultiProviderTestModels; - -namespace Cocoar.Configuration.Core.Tests.Integration; -[Trait("Category", "Integration")] -[Trait("Component", "ConfigManager")] -public class MultiProviderMergingTests -{ - #region Last-Write-Wins Semantics Tests - - #region Last-Write-Wins Semantics Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public void ConfigManager_StaticPlusObservable_LastRuleWins() - { - - var baseConfig = """ - { - "Name": "BaseApp", - "Version": 1, - "Database": { - "ConnectionString": "server=base;", - "Timeout": 30, - "EnableRetry": true - }, - "Features": { - "EnableNewUI": false, - "EnableLogging": true, - "LogLevel": "Info" - } - } - """; - - var overrideConfigJson = """ - { - "Name": "OverriddenApp", - "Version": 2, - "Database": { - "ConnectionString": "server=override;", - "EnableRetry": false - }, - "Features": { - "EnableNewUI": true, - "LogLevel": "Debug" - } - } - """; - - var rules = new List - { - TestRules.StaticJson(baseConfig), // Rule 0 (base) - TestRules.StaticJson(overrideConfigJson) // Rule 1 (wins) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); - var config = configManager.GetConfig(); - Assert.NotNull(config); - - Assert.Equal("OverriddenApp", config!.Name); // From Rule 1 (override) - Assert.Equal(2, config.Version); // From Rule 1 (override) - Assert.Equal("server=override;", config.Database.ConnectionString); // From Rule 1 (override) - Assert.Equal(30, config.Database.Timeout); // From Rule 0 (not overridden in Rule 1) - Assert.False(config.Database.EnableRetry); // From Rule 1 (override) - Assert.True(config.Features.EnableNewUI); // From Rule 1 (override) - Assert.True(config.Features.EnableLogging); // From Rule 0 (not overridden in Rule 1) - Assert.Equal("Debug", config.Features.LogLevel); // From Rule 1 (override) - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public void ConfigManager_ObservablePlusStatic_StaticWins() - { - - var baseConfig = """ - { - "Name": "StaticApp", - "Version": 99, - "Features": { - "EnableNewUI": false, - "LogLevel": "Error" - } - } - """; - - var observableConfig = new AppConfig - { - Name = "ObservableApp", - Version = 1, - Features = new() - { - EnableNewUI = true, - LogLevel = "Debug" - } - }; - - var behaviorSubject = new BehaviorSubject(observableConfig); - - var rules = new List - { - TestRules.Observable(behaviorSubject), // Rule 0 (base) - TestRules.StaticJson(baseConfig) // Rule 1 (wins!) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); - var config = configManager.GetConfig(); - Assert.NotNull(config); - - Assert.Equal("StaticApp", config!.Name); // From Static (wins) - Assert.Equal(99, config.Version); // From Static (wins) - Assert.False(config.Features.EnableNewUI); // From Static (wins) - Assert.Equal("Error", config.Features.LogLevel); // From Static (wins) - } - - #endregion - - #endregion - - #region Complex Configuration Tests - - #region Complex Configuration Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public void ConfigManager_NestedObjectMerging_MergesCorrectly() - { - - var staticConfig = """ - { - "Name": "StaticApp", - "Version": 1, - "Database": { - "ConnectionString": "server=static;database=main;", - "Timeout": 60, - "EnableRetry": true - }, - "Features": { - "EnableNewUI": false, - "EnableLogging": true, - "LogLevel": "Info" - } - } - """; var observablePartialJson = """ - { - "Database": { - "ConnectionString": "server=prod;database=main;", - "Timeout": 120 - }, - "Features": { - "EnableNewUI": true, - "LogLevel": "Debug" - } - } - """; - - var rules = new List - { - TestRules.StaticJson(staticConfig), - TestRules.StaticJson(observablePartialJson) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); - var config = configManager.GetConfig(); - Assert.NotNull(config); - - Assert.Equal("StaticApp", config.Name); // From Static (not overridden) - Assert.Equal(1, config.Version); // From Static (not overridden) - Assert.Equal("server=prod;database=main;", config.Database.ConnectionString); // From Rule 1 - Assert.Equal(120, config.Database.Timeout); // From Rule 1 - Assert.True(config.Database.EnableRetry); // From Static (not overridden) - Assert.True(config.Features.EnableNewUI); // From Rule 1 - Assert.True(config.Features.EnableLogging); // From Static (not overridden) - Assert.Equal("Debug", config.Features.LogLevel); // From Rule 1 - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public void ConfigManager_ThreeProviders_LayersCorrectly() - { - - var baseConfig = """{"Name": "Base", "Version": 1, "Features": {"LogLevel": "Info"}}"""; - var finalConfig = """{"Version": 999, "Features": {"LogLevel": "Error", "EnableNewUI": true}}"""; - - var observableOverride = new - { - Name = "Observable", - Features = new { LogLevel = "Debug" } - }; - - var behaviorSubject = new BehaviorSubject(System.Text.Json.JsonSerializer.Serialize(observableOverride)); - - var rules = new List - { - TestRules.StaticJson(baseConfig), // Rule 0: Base - TestRules.ObservableString(behaviorSubject), // Rule 1: Override - TestRules.StaticJson(finalConfig) // Rule 2: Final (wins!) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); - var config = configManager.GetConfig(); - Assert.NotNull(config); - - Assert.Equal("Observable", config.Name); // From Observable (rule 1, no conflict with rule 2) - Assert.Equal(999, config.Version); // From Final (rule 2, wins over all) - Assert.Equal("Error", config.Features.LogLevel); // From Final (rule 2, wins over all) - Assert.True(config.Features.EnableNewUI); // From Final (rule 2, only provider) - } - - #endregion - - #endregion -} - +using System.Reactive.Subjects; +using System.Text.Json; +using Cocoar.Configuration.Core.Tests.TestUtilities; +using static Cocoar.Configuration.Core.Tests.Integration.MultiProviderTestModels; + +namespace Cocoar.Configuration.Core.Tests.Integration; +[Trait("Category", "Integration")] +[Trait("Component", "ConfigManager")] +public class MultiProviderMergingTests +{ + #region Last-Write-Wins Semantics Tests + + #region Last-Write-Wins Semantics Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public void ConfigManager_StaticPlusObservable_LastRuleWins() + { + + var baseConfig = """ + { + "Name": "BaseApp", + "Version": 1, + "Database": { + "ConnectionString": "server=base;", + "Timeout": 30, + "EnableRetry": true + }, + "Features": { + "EnableNewUI": false, + "EnableLogging": true, + "LogLevel": "Info" + } + } + """; + + var overrideConfigJson = """ + { + "Name": "OverriddenApp", + "Version": 2, + "Database": { + "ConnectionString": "server=override;", + "EnableRetry": false + }, + "Features": { + "EnableNewUI": true, + "LogLevel": "Debug" + } + } + """; + + var rules = new List + { + TestRules.StaticJson(baseConfig), // Rule 0 (base) + TestRules.StaticJson(overrideConfigJson) // Rule 1 (wins) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); + var config = configManager.GetConfig(); + Assert.NotNull(config); + + Assert.Equal("OverriddenApp", config!.Name); // From Rule 1 (override) + Assert.Equal(2, config.Version); // From Rule 1 (override) + Assert.Equal("server=override;", config.Database.ConnectionString); // From Rule 1 (override) + Assert.Equal(30, config.Database.Timeout); // From Rule 0 (not overridden in Rule 1) + Assert.False(config.Database.EnableRetry); // From Rule 1 (override) + Assert.True(config.Features.EnableNewUI); // From Rule 1 (override) + Assert.True(config.Features.EnableLogging); // From Rule 0 (not overridden in Rule 1) + Assert.Equal("Debug", config.Features.LogLevel); // From Rule 1 (override) + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public void ConfigManager_ObservablePlusStatic_StaticWins() + { + + var baseConfig = """ + { + "Name": "StaticApp", + "Version": 99, + "Features": { + "EnableNewUI": false, + "LogLevel": "Error" + } + } + """; + + var observableConfig = new AppConfig + { + Name = "ObservableApp", + Version = 1, + Features = new() + { + EnableNewUI = true, + LogLevel = "Debug" + } + }; + + var behaviorSubject = new BehaviorSubject(observableConfig); + + var rules = new List + { + TestRules.Observable(behaviorSubject), // Rule 0 (base) + TestRules.StaticJson(baseConfig) // Rule 1 (wins!) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); + var config = configManager.GetConfig(); + Assert.NotNull(config); + + Assert.Equal("StaticApp", config!.Name); // From Static (wins) + Assert.Equal(99, config.Version); // From Static (wins) + Assert.False(config.Features.EnableNewUI); // From Static (wins) + Assert.Equal("Error", config.Features.LogLevel); // From Static (wins) + } + + #endregion + + #endregion + + #region Complex Configuration Tests + + #region Complex Configuration Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public void ConfigManager_NestedObjectMerging_MergesCorrectly() + { + + var staticConfig = """ + { + "Name": "StaticApp", + "Version": 1, + "Database": { + "ConnectionString": "server=static;database=main;", + "Timeout": 60, + "EnableRetry": true + }, + "Features": { + "EnableNewUI": false, + "EnableLogging": true, + "LogLevel": "Info" + } + } + """; var observablePartialJson = """ + { + "Database": { + "ConnectionString": "server=prod;database=main;", + "Timeout": 120 + }, + "Features": { + "EnableNewUI": true, + "LogLevel": "Debug" + } + } + """; + + var rules = new List + { + TestRules.StaticJson(staticConfig), + TestRules.StaticJson(observablePartialJson) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); + var config = configManager.GetConfig(); + Assert.NotNull(config); + + Assert.Equal("StaticApp", config.Name); // From Static (not overridden) + Assert.Equal(1, config.Version); // From Static (not overridden) + Assert.Equal("server=prod;database=main;", config.Database.ConnectionString); // From Rule 1 + Assert.Equal(120, config.Database.Timeout); // From Rule 1 + Assert.True(config.Database.EnableRetry); // From Static (not overridden) + Assert.True(config.Features.EnableNewUI); // From Rule 1 + Assert.True(config.Features.EnableLogging); // From Static (not overridden) + Assert.Equal("Debug", config.Features.LogLevel); // From Rule 1 + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public void ConfigManager_ThreeProviders_LayersCorrectly() + { + + var baseConfig = """{"Name": "Base", "Version": 1, "Features": {"LogLevel": "Info"}}"""; + var finalConfig = """{"Version": 999, "Features": {"LogLevel": "Error", "EnableNewUI": true}}"""; + + var observableOverride = new + { + Name = "Observable", + Features = new { LogLevel = "Debug" } + }; + + var behaviorSubject = new BehaviorSubject(System.Text.Json.JsonSerializer.Serialize(observableOverride)); + + var rules = new List + { + TestRules.StaticJson(baseConfig), // Rule 0: Base + TestRules.ObservableString(behaviorSubject), // Rule 1: Override + TestRules.StaticJson(finalConfig) // Rule 2: Final (wins!) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); + var config = configManager.GetConfig(); + Assert.NotNull(config); + + Assert.Equal("Observable", config.Name); // From Observable (rule 1, no conflict with rule 2) + Assert.Equal(999, config.Version); // From Final (rule 2, wins over all) + Assert.Equal("Error", config.Features.LogLevel); // From Final (rule 2, wins over all) + Assert.True(config.Features.EnableNewUI); // From Final (rule 2, only provider) + } + + #endregion + + #endregion +} + diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderPerformanceTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderPerformanceTests.cs index 73a45cf..43e1ff3 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderPerformanceTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderPerformanceTests.cs @@ -1,715 +1,715 @@ -using System.Reactive.Subjects; -using System.Reactive.Linq; -using System.Text.Json; -using Cocoar.Configuration.Core.Tests.TestUtilities; -using static Cocoar.Configuration.Core.Tests.Integration.MultiProviderTestModels; - -namespace Cocoar.Configuration.Core.Tests.Integration; - -/// -/// Performance and provider count validation tests for ConfigManager. -/// Validates provider tracking, recompute minimization, and emission efficiency. -/// -[Trait("Category", "Integration")] -[Trait("Component", "ConfigManager")] -[Trait("Type", "Performance")] -public class MultiProviderPerformanceTests -{ - #region Provider Count and Performance Validation - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public void ConfigManager_EmptyProvider_HandlesGracefully() - { - - var staticConfig = """{"Name": "OnlyStatic", "Version": 42}"""; - var emptyObservable = new { }; // Empty object - - var behaviorSubject = new BehaviorSubject(System.Text.Json.JsonSerializer.Serialize(emptyObservable)); - - var rules = new List - { - TestRules.StaticJson(staticConfig), - TestRules.ObservableString(behaviorSubject) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); - var config = configManager.GetConfig(); - Assert.NotNull(config); - - Assert.Equal("OnlyStatic", config!.Name); - Assert.Equal(42, config.Version); - Assert.NotNull(config.Database); Assert.NotNull(config.Features); - behaviorSubject.Dispose(); - } - - /// - /// Performance validation: Multi-provider config resolution should be fast. - /// This ensures the integration doesn't introduce significant overhead. - /// - [Fact] - [Trait("Type", "Performance")] - [Trait("Provider", "ConfigManager")] - public void ConfigManager_MultiProvider_PerformanceUnder50ms() - { - var staticConfig = """ - { - "Name": "PerfTest", - "Database": {"ConnectionString": "server=perf;", "Timeout": 30}, - "Features": {"EnableLogging": true, "LogLevel": "Info"} - } - """; - - var observableConfigJson = """ - { - "Version": 100, - "Features": { - "EnableNewUI": true - } - } - """; - - var rules = new List - { - TestRules.StaticJson(staticConfig), - TestRules.StaticJson(observableConfigJson) - }; - - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); - var config = configManager.GetConfig(); - - stopwatch.Stop(); - - Assert.True(stopwatch.ElapsedMilliseconds < 50, $"Multi-provider config resolution took {stopwatch.ElapsedMilliseconds}ms, expected < 50ms"); - Assert.NotNull(config); - - Assert.Equal("PerfTest", config!.Name); // From Static (not overridden) - Assert.Equal(100, config.Version); // From Rule 1 - Assert.Equal("server=perf;", config.Database.ConnectionString); // From Static (not overridden) - Assert.Equal(30, config.Database.Timeout); // From Static (not overridden) - Assert.True(config.Features.EnableNewUI); // From Rule 1 - Assert.True(config.Features.EnableLogging); // From Static (not overridden) - } - - /// - /// CRITICAL PERFORMANCE TEST: Validates that ConfigManager partial recompute optimization works correctly. - /// When a later provider (ObservableProvider) changes, earlier providers (StaticJsonProvider) should NOT be refetched. - /// This tests the core incremental recompute pipeline - only the suffix (affected rule + rules after it) is refetched, - /// while the prefix (earlier unchanged rules) is reconstructed from cached flattened contributions. - /// - [Fact] - [Trait("Type", "Performance")] - [Trait("Provider", "ConfigManager")] - public async Task ConfigManager_ObservableProviderChange_DoesNotRefetchStaticProvider() - { - - var fetchCount = 0; - var trackableStaticProvider = new TrackableStaticJsonProvider( - """{"Name": "StaticBase", "Priority": 100, "Settings": {"ReadOnly": true}}""", - () => fetchCount++); - - var subject = new BehaviorSubject("""{"Name": "InitialObservable", "Priority": 200, "Settings": {"Dynamic": true}}"""); - var observableProvider = new ObservableProvider(new(subject)); - - var providers = new Queue(new ConfigurationProvider[] { trackableStaticProvider, observableProvider }); - ConfigurationProvider Factory(Type t, IProviderConfiguration _) => providers.Dequeue(); - - var staticOptions = new DummyProviderOptions("static"); - var observableOptions = new ObservableProviderOptions(subject); - var dummyQuery = new DummyProviderQuery(); - var observableQuery = new ObservableProviderQuery(); - - var staticRule = new ConfigRule(typeof(TrackableStaticJsonProvider), staticOptions, dummyQuery, typeof(TestConfig)); - var observableRule = new ConfigRule(typeof(ObservableProvider), observableOptions, observableQuery, typeof(TestConfig)); - - var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { staticRule, observableRule }).UseLogger(NullLogger.Instance).UseProviderFactory(Factory).UseDebounce(50)); - - await ActiveWaitHelpers.WaitUntilAsync( - () => fetchCount > 0, - timeout: TimeSpan.FromSeconds(1), - description: "StaticJsonProvider to be fetched during initialization"); - var initialFetchCount = fetchCount; - - Assert.True(initialFetchCount > 0, "StaticJsonProvider should have been fetched during initialization"); - - var initialConfig = manager.GetConfig(); - Assert.NotNull(initialConfig); - Assert.Equal("InitialObservable", initialConfig!.Name); // Observable overrides Static - Assert.Equal(200, initialConfig.Priority); // Observable overrides Static - Assert.True(initialConfig.Settings.ReadOnly); // From Static - Assert.True(initialConfig.Settings.Dynamic); // From Observable - - subject.OnNext("""{ "Name": "UpdatedObservable", "Priority": 300, "Settings": {"Dynamic": false, "NewField": "Added"}}}"""); - - await ActiveWaitHelpers.WaitUntilAsync( - () => manager.GetConfig()?.Name == "UpdatedObservable", - timeout: TimeSpan.FromSeconds(1), - description: "observable update to propagate"); - - Assert.Equal(initialFetchCount, fetchCount); - - var finalConfig = manager.GetConfig(); - Assert.NotNull(finalConfig); - Assert.Equal("UpdatedObservable", finalConfig!.Name); // Observable change applied - Assert.Equal(300, finalConfig.Priority); // Observable change applied - Assert.True(finalConfig.Settings.ReadOnly); // Static provider data still present (from cache) - Assert.False(finalConfig.Settings.Dynamic); // Observable change applied - Assert.Equal("Added", finalConfig.Settings.NewField); // New Observable field - } - - /// - /// CRITICAL DEBOUNCING TEST: Validates that rapid changes across multiple providers are properly debounced. - /// This tests that ConfigManager with 50ms debounce properly coalesces rapid changes from different sources - /// and produces the correct final merged configuration state without excessive recompute operations. - /// - [Fact] - [Trait("Type", "Performance")] - [Trait("Provider", "ConfigManager")] - public async Task ConfigManager_RapidMultiProviderChanges_ProperDebouncing() - { - - var staticRecomputeCount = 0; - - var trackableStaticProvider = new TrackableStaticJsonProvider( - """{"Name": "StaticBase", "Environment": "Test", "Settings": {"ReadOnly": true}}""", - () => staticRecomputeCount++); - - var subject1 = new BehaviorSubject("""{"Name": "Observable1", "Priority": 100, "Settings": {"Feature1": true}}"""); - var subject2 = new BehaviorSubject("""{"Name": "Observable2", "Priority": 200, "Settings": {"Feature2": true}}"""); - - var observable1Provider = new ObservableProvider(new(subject1)); - var observable2Provider = new ObservableProvider(new(subject2)); - - var providers = new Queue(new ConfigurationProvider[] - { - trackableStaticProvider, - observable1Provider, - observable2Provider - }); - ConfigurationProvider Factory(Type t, IProviderConfiguration _) => providers.Dequeue(); - - var staticOptions = new DummyProviderOptions("static"); - var obs1Options = new ObservableProviderOptions(subject1); - var obs2Options = new ObservableProviderOptions(subject2); - var dummyQuery = new DummyProviderQuery(); - var observableQuery = new ObservableProviderQuery(); - - var staticRule = new ConfigRule(typeof(TrackableStaticJsonProvider), staticOptions, dummyQuery, typeof(TestConfig)); - var obs1Rule = new ConfigRule(typeof(ObservableProvider), obs1Options, observableQuery, typeof(TestConfig)); - var obs2Rule = new ConfigRule(typeof(ObservableProvider), obs2Options, observableQuery, typeof(TestConfig)); - var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { staticRule, obs1Rule, obs2Rule }).UseLogger(NullLogger.Instance).UseProviderFactory(Factory).UseDebounce(100)); - - await ActiveWaitHelpers.WaitUntilAsync( - () => manager.GetConfig()?.Name == "Observable2", - timeout: TimeSpan.FromSeconds(1), - description: "initial configuration to be ready"); - var initialStaticCount = staticRecomputeCount; - - var initialConfig = manager.GetConfig(); - Assert.NotNull(initialConfig); - Assert.Equal("Observable2", initialConfig!.Name); // Last rule wins - Assert.Equal(200, initialConfig.Priority); // From Observable2 - Assert.Equal("Test", initialConfig.Environment); // From Static - Assert.True(initialConfig.Settings.ReadOnly); // From Static - Assert.True(initialConfig.Settings.Feature1); // From Observable1 - Assert.True(initialConfig.Settings.Feature2); // From Observable2 - - var changeStartTime = DateTimeOffset.UtcNow; - subject1.OnNext("""{"Name": "Rapid1", "Priority": 150, "Settings": {"Feature1": false, "NewProp1": "Value1"}}"""); - await Task.Delay(10); - - subject2.OnNext("""{"Name": "Rapid2", "Priority": 250, "Settings": {"Feature2": false, "NewProp2": "Value2"}}"""); - await Task.Delay(10); - - subject1.OnNext("""{"Name": "FinalRapid1", "Priority": 175, "Settings": {"Feature1": true, "NewProp1": "FinalValue1"}}"""); - await Task.Delay(10); - - subject2.OnNext("""{ "Name": "FinalRapid2", "Priority": 275, "Settings": {"Feature2": true, "NewProp2": "FinalValue2"}}}"""); - - await ActiveWaitHelpers.WaitUntilAsync( - () => manager.GetConfig()?.Name == "FinalRapid2", - timeout: TimeSpan.FromSeconds(1), - description: "final rapid changes to propagate"); - - var finalConfig = manager.GetConfig(); - Assert.NotNull(finalConfig); - - Assert.Equal("FinalRapid2", finalConfig.Name); // Last rule wins with final value - Assert.Equal(275, finalConfig.Priority); // Final value from Observable2 - Assert.Equal("Test", finalConfig.Environment); // Static unchanged - Assert.True(finalConfig.Settings.ReadOnly); // Static unchanged - Assert.True(finalConfig.Settings.Feature1); // Final value from Observable1 - Assert.True(finalConfig.Settings.Feature2); // Final value from Observable2 - Assert.Equal("FinalValue1", finalConfig.Settings.NewProp1); // Final value from Observable1 - Assert.Equal("FinalValue2", finalConfig.Settings.NewProp2); // Final value from Observable2 - - // CRITICAL: Static provider should not be recomputed - debouncing is working correctly - Assert.Equal(initialStaticCount, staticRecomputeCount); - - var totalChangeTime = DateTimeOffset.UtcNow - changeStartTime; - // Sanity check: ensure test completes in reasonable time (not hung) - // This is not a strict performance requirement - the functional assertions above are what matter - Assert.True(totalChangeTime.TotalMilliseconds < 5000, - $"Test took unexpectedly long ({totalChangeTime.TotalMilliseconds}ms), possible hang or performance regression"); - } - - /// - /// CRITICAL ERROR HANDLING TEST: Validates ObservableProvider behavior when BehaviorSubject encounters error conditions. - /// Tests error propagation, graceful degradation, and recovery scenarios in multi-provider configurations. - /// This ensures the system remains stable when individual providers fail. - /// - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task ObservableProvider_ErrorHandling_GracefulDegradation() - { - - var staticProvider = new TrackableStaticJsonProvider( - """{"Name": "SafeStatic", "BackupValue": "Available", "Settings": {"Reliable": true}}""", - () => { }); - - var errorSubject = new BehaviorSubject("""{"Name": "InitialValue", "DynamicValue": "Working"}"""); - - var providers = new Queue(new ConfigurationProvider[] - { - staticProvider, - new ObservableProvider(new(errorSubject)) - }); - ConfigurationProvider Factory(Type t, IProviderConfiguration _) => providers.Dequeue(); - - var staticOptions = new DummyProviderOptions("static"); - var observableOptions = new ObservableProviderOptions(errorSubject); - var dummyQuery = new DummyProviderQuery(); - var observableQuery = new ObservableProviderQuery(); - - var staticRule = new ConfigRule(typeof(TrackableStaticJsonProvider), staticOptions, dummyQuery, typeof(TestConfig)); - var observableRule = new ConfigRule(typeof(ObservableProvider), observableOptions, observableQuery, typeof(TestConfig)); - - var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { staticRule, observableRule }).UseLogger(NullLogger.Instance).UseProviderFactory(Factory).UseDebounce(50)); - - await ActiveWaitHelpers.WaitUntilAsync( - () => manager.GetConfig()?.Name == "InitialValue", - timeout: TimeSpan.FromSeconds(1), - description: "initial configuration from observable provider"); - - var initialConfig = manager.GetConfig(); - Assert.NotNull(initialConfig); - Assert.Equal("InitialValue", initialConfig!.Name); // From ObservableProvider - Assert.Equal("Available", initialConfig.BackupValue); // From StaticProvider - Assert.True(initialConfig.Settings.Reliable); // From StaticProvider - - Exception? errorCaught = null; - try - { - errorSubject.OnError(new InvalidOperationException("Test error from observable")); - await Task.Delay(150); var configAfterError = manager.GetConfig(); - Assert.NotNull(configAfterError); - Assert.Equal("Available", configAfterError!.BackupValue); // Static provider still works - } - catch (Exception ex) - { - errorCaught = ex; - } - - var disposableSubject = new BehaviorSubject("""{"Name": "DisposableTest", "TempValue": "BeforeDispose"}"""); - - var disposableProvider = new ObservableProvider(new(disposableSubject)); - var preDisposeResult = await disposableProvider.FetchConfigurationBytesAsync(new()); - Assert.Equal("DisposableTest", preDisposeResult.ToJsonElement().GetProperty("Name").GetString()); - disposableSubject.Dispose(); - Exception? disposeErrorCaught = null; - try - { - var postDisposeResult = await disposableProvider.FetchConfigurationBytesAsync(new()); - Assert.NotNull(postDisposeResult); - } - catch (Exception ex) - { - disposeErrorCaught = ex; - // This is also acceptable - the provider may throw on disposed observables - } - - // The key assertion is that we didn't crash the test or have unhandled exceptions - Assert.True(true, "ObservableProvider handled error scenarios without crashing the system"); - } - - /// - /// CRITICAL COMPLETION TEST: Validates ObservableProvider behavior when BehaviorSubject completes. - /// Tests that completed observables are handled gracefully in ongoing configuration management. - /// - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task ObservableProvider_CompletionHandling_GracefulBehavior() - { - var completableSubject = new BehaviorSubject("""{"Name": "CompletableTest", "Status": "Active"}"""); - var provider = new ObservableProvider(new(completableSubject)); - var query = new ObservableProviderQuery(); - - var initialResult = await provider.FetchConfigurationBytesAsync(query); - Assert.NotNull(initialResult); - Assert.Equal("CompletableTest", initialResult.ToJsonElement().GetProperty("Name").GetString()); - Assert.Equal("Active", initialResult.ToJsonElement().GetProperty("Status").GetString()); - - completableSubject.OnNext("""{"Name": "FinalValue", "Status": "Completing"}"""); - completableSubject.OnCompleted(); - - Exception? completionErrorCaught = null; - try - { - var postCompletionResult = await provider.FetchConfigurationBytesAsync(query); - Assert.NotNull(postCompletionResult); - Assert.Equal("FinalValue", postCompletionResult.ToJsonElement().GetProperty("Name").GetString()); - } - catch (Exception ex) - { - completionErrorCaught = ex; - // This may also be acceptable depending on implementation - } - - // Key point: system should remain stable after observable completion - Assert.True(true, "ObservableProvider handled completion scenario without system instability"); - } - - /// - /// CRITICAL SELECTION AND MOUNTING TEST: Validates ConfigManager with Select() and MountAt() operations. - /// Tests the full pipeline: Fetch ΓåÆ Select ΓåÆ Mount ΓåÆ Merge with multi-provider scenarios. - /// This ensures flattened merging works correctly with nested configuration paths and rule order precedence. - /// - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public void ConfigManager_SelectAndMount_CorrectFlattendMerging() - { - - var baseConfig = """ - { - "App": { - "Name": "TestApp", - "Version": "1.0.0" - }, - "Database": { - "ConnectionString": "server=base;", - "Timeout": 30, - "Pool": { - "MinSize": 5, - "MaxSize": 100 - } - }, - "Features": { - "EnableLogging": true, - "EnableMetrics": false - } - } - """; - - var databaseOverride = """ - { - "ConnectionString": "server=override;database=prod;", - "Timeout": 60, - "Pool": { - "MaxSize": 200, - "IdleTimeout": 300 - } - } - """; - - var featuresConfig = """ - { - "NewFeatures": { - "EnableCaching": true, - "EnableRetry": true - }, - "Legacy": { - "EnableOldUI": false - } - } - """; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(baseConfig), // Rule 0: Base config - rules.For().FromStaticJson(databaseOverride).MountAt("Database"), // Rule 1: Replace Database section - rules.For().FromStaticJson(featuresConfig).Select("NewFeatures").MountAt("Features"), // Rule 2: Mount NewFeatures as Features - rules.For().FromStaticJson(featuresConfig).Select("Legacy").MountAt("LegacySettings") // Rule 3: Mount Legacy under new path - ])); - - var config = configManager.GetConfig(); - Assert.NotNull(config); - - // App section (from base, unchanged) - Assert.Equal("TestApp", config!.App.Name); - Assert.Equal("1.0.0", config.App.Version); - - // Database section (completely replaced by Rule 1) - Assert.Equal("server=override;database=prod;", config.Database.ConnectionString); // From override - Assert.Equal(60, config.Database.Timeout); // From override - Assert.Equal(5, config.Database.Pool.MinSize); // From base (not overridden, key not present in override) - Assert.Equal(200, config.Database.Pool.MaxSize); // From override (nested merge) - Assert.Equal(300, config.Database.Pool.IdleTimeout); // From override (new field) - - // Features section (Rule 2: NewFeatures selected and mounted as Features, overriding base Features) - Assert.True(config.Features.EnableCaching); // From Rule 2 (NewFeaturesΓåÆFeatures) - Assert.True(config.Features.EnableRetry); // From Rule 2 (NewFeaturesΓåÆFeatures) - - // LegacySettings section (Rule 3: Legacy selected and mounted under LegacySettings) - Assert.False(config.LegacySettings.EnableOldUI); // From Rule 3 (LegacyΓåÆLegacySettings) - Assert.NotNull(config.App); - Assert.NotNull(config.Database); - Assert.NotNull(config.Features); - Assert.NotNull(config.LegacySettings); - } - - /// - /// ADVANCED MOUNTING TEST: Tests complex nested path mounting with Observable providers. - /// Validates that Select() and MountAt() work correctly with dynamic configuration changes - /// and that flattened key merging handles complex nested paths properly. - /// - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public async Task ConfigManager_DynamicSelectAndMount_ComplexNesting() - { - - var baseConfig = """ - { - "Root": { - "Static": { - "Value": "BaseStatic" - } - }, - "Services": { - "Database": { - "Host": "localhost" - } - } - } - """; - - // Dynamic config source with deep nesting - var dynamicSubject = new BehaviorSubject(""" - { - "DynamicSection": { - "Deep": { - "Nested": { - "Value": "InitialDynamic", - "Config": { - "Setting1": "A", - "Setting2": 42 - } - } - } - }, - "Other": { - "Ignored": "This will not be selected" - } - } - """); - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(baseConfig), // Rule 0: Base - rules.For().FromObservable(dynamicSubject) // Rule 1: Select deep path, mount at new location - .Select("DynamicSection:Deep:Nested") - .MountAt("Root:Dynamic") - ])); - - // Wait for initial configuration to be available - await ActiveWaitHelpers.WaitUntilAsync( - () => configManager.GetConfig() != null, - description: "initial config to be available"); - - var initialConfig = configManager.GetConfig(); - Assert.NotNull(initialConfig); - - Assert.Equal("BaseStatic", initialConfig.Root.Static.Value); // From base - Assert.Equal("localhost", initialConfig.Services.Database.Host); // From base - Assert.Equal("InitialDynamic", initialConfig.Root.Dynamic.Value); // From dynamic: DynamicSection:Deep:NestedΓåÆRoot:Dynamic - Assert.Equal("A", initialConfig.Root.Dynamic.Config.Setting1); // From dynamic, nested - Assert.Equal(42, initialConfig.Root.Dynamic.Config.Setting2); // From dynamic, nested - - dynamicSubject.OnNext(""" - { - "DynamicSection": { - "Deep": { - "Nested": { - "Value": "UpdatedDynamic", - "Config": { - "Setting1": "B", - "Setting2": 99, - "NewSetting": "Added" - } - } - } - }, - "Other": { - "Ignored": "Still ignored" - } - } - """); - - // Wait for the dynamic value to update - await ActiveWaitHelpers.WaitForValueAsync( - () => configManager.GetConfig()?.Root?.Dynamic?.Value, - "UpdatedDynamic", - description: "dynamic config value to update to 'UpdatedDynamic'"); - - var finalConfig = configManager.GetConfig(); - Assert.NotNull(finalConfig); - - Assert.Equal("BaseStatic", finalConfig.Root.Static.Value); // Unchanged - Assert.Equal("localhost", finalConfig.Services.Database.Host); // Unchanged - Assert.Equal("UpdatedDynamic", finalConfig.Root.Dynamic.Value); // Updated - Assert.Equal("B", finalConfig.Root.Dynamic.Config.Setting1); // Updated - Assert.Equal(99, finalConfig.Root.Dynamic.Config.Setting2); // Updated - Assert.Equal("Added", finalConfig.Root.Dynamic.Config.NewSetting); // New field - } - - // Configuration models for testing Select() and MountAt() operations - public class ComplexConfig - { - public AppSection App { get; set; } = new(); - public DatabaseSection Database { get; set; } = new(); - public FeaturesSection Features { get; set; } = new(); - public LegacySection LegacySettings { get; set; } = new(); - } - - public class AppSection - { - public string Name { get; set; } = ""; - public string Version { get; set; } = ""; - } - - public class DatabaseSection - { - public string ConnectionString { get; set; } = ""; - public int Timeout { get; set; } - public PoolSection Pool { get; set; } = new(); - } - - public class PoolSection - { - public int MinSize { get; set; } - public int MaxSize { get; set; } - public int IdleTimeout { get; set; } - } - - public class FeaturesSection - { - public bool EnableLogging { get; set; } - public bool EnableMetrics { get; set; } - public bool EnableCaching { get; set; } - public bool EnableRetry { get; set; } - } - - public class LegacySection - { - public bool EnableOldUI { get; set; } - } - - public class NestedConfig - { - public RootSection Root { get; set; } = new(); - public ServicesSection Services { get; set; } = new(); - } - - public class RootSection - { - public StaticSection Static { get; set; } = new(); - public DynamicSection Dynamic { get; set; } = new(); - } - - public class StaticSection - { - public string Value { get; set; } = ""; - } - - public class DynamicSection - { - public string Value { get; set; } = ""; - public DynamicConfigSection Config { get; set; } = new(); - } - - public class DynamicConfigSection - { - public string Setting1 { get; set; } = ""; - public int Setting2 { get; set; } - public string NewSetting { get; set; } = ""; - } - - public class ServicesSection - { - public DatabaseServiceSection Database { get; set; } = new(); - } - - public class DatabaseServiceSection - { - public string Host { get; set; } = ""; - } - - private class DummyProviderOptions : IProviderConfiguration - { - private readonly string _key; - public DummyProviderOptions(string key) => _key = key; - public string GenerateProviderKey() => _key; - } - - private class DummyProviderQuery : IProviderQuery - { - public string GenerateProviderKey() => "default"; - } - - /// - /// Trackable wrapper around StaticJsonProvider to count fetch operations. - /// This allows us to verify the partial recompute optimization in ConfigManager. - /// - private class TrackableStaticJsonProvider : ConfigurationProvider - { - private readonly JsonElement _data; - private readonly Action _onFetch; - - public TrackableStaticJsonProvider(string jsonData, Action onFetch) - { - using var document = JsonDocument.Parse(jsonData); - _data = document.RootElement.Clone(); - _onFetch = onFetch; - } - - public override Task FetchConfigurationBytesAsync(IProviderQuery query, CancellationToken ct = default) - { - _onFetch(); // Track the fetch - var bytes = JsonSerializer.SerializeToUtf8Bytes(_data); - return Task.FromResult(bytes); - } - - public override IObservable ChangesAsBytes(IProviderQuery query) => - // Static provider never changes - Observable.Never(); - } - - public class TestConfig - { - public string Name { get; set; } = ""; - public int Priority { get; set; } - public string Environment { get; set; } = ""; - public string BackupValue { get; set; } = ""; - public string DynamicValue { get; set; } = ""; - public string TempValue { get; set; } = ""; - public string Status { get; set; } = ""; - public NestedSettings Settings { get; set; } = new(); - } - - public class NestedSettings - { - public bool ReadOnly { get; set; } - public bool Dynamic { get; set; } - public bool Feature1 { get; set; } - public bool Feature2 { get; set; } - public bool Reliable { get; set; } - public string NewField { get; set; } = ""; - public string NewProp1 { get; set; } = ""; - public string NewProp2 { get; set; } = ""; - } - - #endregion -} +using System.Reactive.Subjects; +using System.Reactive.Linq; +using System.Text.Json; +using Cocoar.Configuration.Core.Tests.TestUtilities; +using static Cocoar.Configuration.Core.Tests.Integration.MultiProviderTestModels; + +namespace Cocoar.Configuration.Core.Tests.Integration; + +/// +/// Performance and provider count validation tests for ConfigManager. +/// Validates provider tracking, recompute minimization, and emission efficiency. +/// +[Trait("Category", "Integration")] +[Trait("Component", "ConfigManager")] +[Trait("Type", "Performance")] +public class MultiProviderPerformanceTests +{ + #region Provider Count and Performance Validation + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public void ConfigManager_EmptyProvider_HandlesGracefully() + { + + var staticConfig = """{"Name": "OnlyStatic", "Version": 42}"""; + var emptyObservable = new { }; // Empty object + + var behaviorSubject = new BehaviorSubject(System.Text.Json.JsonSerializer.Serialize(emptyObservable)); + + var rules = new List + { + TestRules.StaticJson(staticConfig), + TestRules.ObservableString(behaviorSubject) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); + var config = configManager.GetConfig(); + Assert.NotNull(config); + + Assert.Equal("OnlyStatic", config!.Name); + Assert.Equal(42, config.Version); + Assert.NotNull(config.Database); Assert.NotNull(config.Features); + behaviorSubject.Dispose(); + } + + /// + /// Performance validation: Multi-provider config resolution should be fast. + /// This ensures the integration doesn't introduce significant overhead. + /// + [Fact] + [Trait("Type", "Performance")] + [Trait("Provider", "ConfigManager")] + public void ConfigManager_MultiProvider_PerformanceUnder50ms() + { + var staticConfig = """ + { + "Name": "PerfTest", + "Database": {"ConnectionString": "server=perf;", "Timeout": 30}, + "Features": {"EnableLogging": true, "LogLevel": "Info"} + } + """; + + var observableConfigJson = """ + { + "Version": 100, + "Features": { + "EnableNewUI": true + } + } + """; + + var rules = new List + { + TestRules.StaticJson(staticConfig), + TestRules.StaticJson(observableConfigJson) + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); + var config = configManager.GetConfig(); + + stopwatch.Stop(); + + Assert.True(stopwatch.ElapsedMilliseconds < 50, $"Multi-provider config resolution took {stopwatch.ElapsedMilliseconds}ms, expected < 50ms"); + Assert.NotNull(config); + + Assert.Equal("PerfTest", config!.Name); // From Static (not overridden) + Assert.Equal(100, config.Version); // From Rule 1 + Assert.Equal("server=perf;", config.Database.ConnectionString); // From Static (not overridden) + Assert.Equal(30, config.Database.Timeout); // From Static (not overridden) + Assert.True(config.Features.EnableNewUI); // From Rule 1 + Assert.True(config.Features.EnableLogging); // From Static (not overridden) + } + + /// + /// CRITICAL PERFORMANCE TEST: Validates that ConfigManager partial recompute optimization works correctly. + /// When a later provider (ObservableProvider) changes, earlier providers (StaticJsonProvider) should NOT be refetched. + /// This tests the core incremental recompute pipeline - only the suffix (affected rule + rules after it) is refetched, + /// while the prefix (earlier unchanged rules) is reconstructed from cached flattened contributions. + /// + [Fact] + [Trait("Type", "Performance")] + [Trait("Provider", "ConfigManager")] + public async Task ConfigManager_ObservableProviderChange_DoesNotRefetchStaticProvider() + { + + var fetchCount = 0; + var trackableStaticProvider = new TrackableStaticJsonProvider( + """{"Name": "StaticBase", "Priority": 100, "Settings": {"ReadOnly": true}}""", + () => fetchCount++); + + var subject = new BehaviorSubject("""{"Name": "InitialObservable", "Priority": 200, "Settings": {"Dynamic": true}}"""); + var observableProvider = new ObservableProvider(new(subject)); + + var providers = new Queue(new ConfigurationProvider[] { trackableStaticProvider, observableProvider }); + ConfigurationProvider Factory(Type t, IProviderConfiguration _) => providers.Dequeue(); + + var staticOptions = new DummyProviderOptions("static"); + var observableOptions = new ObservableProviderOptions(subject); + var dummyQuery = new DummyProviderQuery(); + var observableQuery = new ObservableProviderQuery(); + + var staticRule = new ConfigRule(typeof(TrackableStaticJsonProvider), staticOptions, dummyQuery, typeof(TestConfig)); + var observableRule = new ConfigRule(typeof(ObservableProvider), observableOptions, observableQuery, typeof(TestConfig)); + + var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { staticRule, observableRule }).UseLogger(NullLogger.Instance).UseProviderFactory(Factory).UseDebounce(50)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => fetchCount > 0, + timeout: TimeSpan.FromSeconds(1), + description: "StaticJsonProvider to be fetched during initialization"); + var initialFetchCount = fetchCount; + + Assert.True(initialFetchCount > 0, "StaticJsonProvider should have been fetched during initialization"); + + var initialConfig = manager.GetConfig(); + Assert.NotNull(initialConfig); + Assert.Equal("InitialObservable", initialConfig!.Name); // Observable overrides Static + Assert.Equal(200, initialConfig.Priority); // Observable overrides Static + Assert.True(initialConfig.Settings.ReadOnly); // From Static + Assert.True(initialConfig.Settings.Dynamic); // From Observable + + subject.OnNext("""{ "Name": "UpdatedObservable", "Priority": 300, "Settings": {"Dynamic": false, "NewField": "Added"}}}"""); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()?.Name == "UpdatedObservable", + timeout: TimeSpan.FromSeconds(1), + description: "observable update to propagate"); + + Assert.Equal(initialFetchCount, fetchCount); + + var finalConfig = manager.GetConfig(); + Assert.NotNull(finalConfig); + Assert.Equal("UpdatedObservable", finalConfig!.Name); // Observable change applied + Assert.Equal(300, finalConfig.Priority); // Observable change applied + Assert.True(finalConfig.Settings.ReadOnly); // Static provider data still present (from cache) + Assert.False(finalConfig.Settings.Dynamic); // Observable change applied + Assert.Equal("Added", finalConfig.Settings.NewField); // New Observable field + } + + /// + /// CRITICAL DEBOUNCING TEST: Validates that rapid changes across multiple providers are properly debounced. + /// This tests that ConfigManager with 50ms debounce properly coalesces rapid changes from different sources + /// and produces the correct final merged configuration state without excessive recompute operations. + /// + [Fact] + [Trait("Type", "Performance")] + [Trait("Provider", "ConfigManager")] + public async Task ConfigManager_RapidMultiProviderChanges_ProperDebouncing() + { + + var staticRecomputeCount = 0; + + var trackableStaticProvider = new TrackableStaticJsonProvider( + """{"Name": "StaticBase", "Environment": "Test", "Settings": {"ReadOnly": true}}""", + () => staticRecomputeCount++); + + var subject1 = new BehaviorSubject("""{"Name": "Observable1", "Priority": 100, "Settings": {"Feature1": true}}"""); + var subject2 = new BehaviorSubject("""{"Name": "Observable2", "Priority": 200, "Settings": {"Feature2": true}}"""); + + var observable1Provider = new ObservableProvider(new(subject1)); + var observable2Provider = new ObservableProvider(new(subject2)); + + var providers = new Queue(new ConfigurationProvider[] + { + trackableStaticProvider, + observable1Provider, + observable2Provider + }); + ConfigurationProvider Factory(Type t, IProviderConfiguration _) => providers.Dequeue(); + + var staticOptions = new DummyProviderOptions("static"); + var obs1Options = new ObservableProviderOptions(subject1); + var obs2Options = new ObservableProviderOptions(subject2); + var dummyQuery = new DummyProviderQuery(); + var observableQuery = new ObservableProviderQuery(); + + var staticRule = new ConfigRule(typeof(TrackableStaticJsonProvider), staticOptions, dummyQuery, typeof(TestConfig)); + var obs1Rule = new ConfigRule(typeof(ObservableProvider), obs1Options, observableQuery, typeof(TestConfig)); + var obs2Rule = new ConfigRule(typeof(ObservableProvider), obs2Options, observableQuery, typeof(TestConfig)); + var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { staticRule, obs1Rule, obs2Rule }).UseLogger(NullLogger.Instance).UseProviderFactory(Factory).UseDebounce(100)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()?.Name == "Observable2", + timeout: TimeSpan.FromSeconds(1), + description: "initial configuration to be ready"); + var initialStaticCount = staticRecomputeCount; + + var initialConfig = manager.GetConfig(); + Assert.NotNull(initialConfig); + Assert.Equal("Observable2", initialConfig!.Name); // Last rule wins + Assert.Equal(200, initialConfig.Priority); // From Observable2 + Assert.Equal("Test", initialConfig.Environment); // From Static + Assert.True(initialConfig.Settings.ReadOnly); // From Static + Assert.True(initialConfig.Settings.Feature1); // From Observable1 + Assert.True(initialConfig.Settings.Feature2); // From Observable2 + + var changeStartTime = DateTimeOffset.UtcNow; + subject1.OnNext("""{"Name": "Rapid1", "Priority": 150, "Settings": {"Feature1": false, "NewProp1": "Value1"}}"""); + await Task.Delay(10); + + subject2.OnNext("""{"Name": "Rapid2", "Priority": 250, "Settings": {"Feature2": false, "NewProp2": "Value2"}}"""); + await Task.Delay(10); + + subject1.OnNext("""{"Name": "FinalRapid1", "Priority": 175, "Settings": {"Feature1": true, "NewProp1": "FinalValue1"}}"""); + await Task.Delay(10); + + subject2.OnNext("""{ "Name": "FinalRapid2", "Priority": 275, "Settings": {"Feature2": true, "NewProp2": "FinalValue2"}}}"""); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()?.Name == "FinalRapid2", + timeout: TimeSpan.FromSeconds(1), + description: "final rapid changes to propagate"); + + var finalConfig = manager.GetConfig(); + Assert.NotNull(finalConfig); + + Assert.Equal("FinalRapid2", finalConfig.Name); // Last rule wins with final value + Assert.Equal(275, finalConfig.Priority); // Final value from Observable2 + Assert.Equal("Test", finalConfig.Environment); // Static unchanged + Assert.True(finalConfig.Settings.ReadOnly); // Static unchanged + Assert.True(finalConfig.Settings.Feature1); // Final value from Observable1 + Assert.True(finalConfig.Settings.Feature2); // Final value from Observable2 + Assert.Equal("FinalValue1", finalConfig.Settings.NewProp1); // Final value from Observable1 + Assert.Equal("FinalValue2", finalConfig.Settings.NewProp2); // Final value from Observable2 + + // CRITICAL: Static provider should not be recomputed - debouncing is working correctly + Assert.Equal(initialStaticCount, staticRecomputeCount); + + var totalChangeTime = DateTimeOffset.UtcNow - changeStartTime; + // Sanity check: ensure test completes in reasonable time (not hung) + // This is not a strict performance requirement - the functional assertions above are what matter + Assert.True(totalChangeTime.TotalMilliseconds < 5000, + $"Test took unexpectedly long ({totalChangeTime.TotalMilliseconds}ms), possible hang or performance regression"); + } + + /// + /// CRITICAL ERROR HANDLING TEST: Validates ObservableProvider behavior when BehaviorSubject encounters error conditions. + /// Tests error propagation, graceful degradation, and recovery scenarios in multi-provider configurations. + /// This ensures the system remains stable when individual providers fail. + /// + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task ObservableProvider_ErrorHandling_GracefulDegradation() + { + + var staticProvider = new TrackableStaticJsonProvider( + """{"Name": "SafeStatic", "BackupValue": "Available", "Settings": {"Reliable": true}}""", + () => { }); + + var errorSubject = new BehaviorSubject("""{"Name": "InitialValue", "DynamicValue": "Working"}"""); + + var providers = new Queue(new ConfigurationProvider[] + { + staticProvider, + new ObservableProvider(new(errorSubject)) + }); + ConfigurationProvider Factory(Type t, IProviderConfiguration _) => providers.Dequeue(); + + var staticOptions = new DummyProviderOptions("static"); + var observableOptions = new ObservableProviderOptions(errorSubject); + var dummyQuery = new DummyProviderQuery(); + var observableQuery = new ObservableProviderQuery(); + + var staticRule = new ConfigRule(typeof(TrackableStaticJsonProvider), staticOptions, dummyQuery, typeof(TestConfig)); + var observableRule = new ConfigRule(typeof(ObservableProvider), observableOptions, observableQuery, typeof(TestConfig)); + + var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { staticRule, observableRule }).UseLogger(NullLogger.Instance).UseProviderFactory(Factory).UseDebounce(50)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()?.Name == "InitialValue", + timeout: TimeSpan.FromSeconds(1), + description: "initial configuration from observable provider"); + + var initialConfig = manager.GetConfig(); + Assert.NotNull(initialConfig); + Assert.Equal("InitialValue", initialConfig!.Name); // From ObservableProvider + Assert.Equal("Available", initialConfig.BackupValue); // From StaticProvider + Assert.True(initialConfig.Settings.Reliable); // From StaticProvider + + Exception? errorCaught = null; + try + { + errorSubject.OnError(new InvalidOperationException("Test error from observable")); + await Task.Delay(150); var configAfterError = manager.GetConfig(); + Assert.NotNull(configAfterError); + Assert.Equal("Available", configAfterError!.BackupValue); // Static provider still works + } + catch (Exception ex) + { + errorCaught = ex; + } + + var disposableSubject = new BehaviorSubject("""{"Name": "DisposableTest", "TempValue": "BeforeDispose"}"""); + + var disposableProvider = new ObservableProvider(new(disposableSubject)); + var preDisposeResult = await disposableProvider.FetchConfigurationBytesAsync(new()); + Assert.Equal("DisposableTest", preDisposeResult.ToJsonElement().GetProperty("Name").GetString()); + disposableSubject.Dispose(); + Exception? disposeErrorCaught = null; + try + { + var postDisposeResult = await disposableProvider.FetchConfigurationBytesAsync(new()); + Assert.NotNull(postDisposeResult); + } + catch (Exception ex) + { + disposeErrorCaught = ex; + // This is also acceptable - the provider may throw on disposed observables + } + + // The key assertion is that we didn't crash the test or have unhandled exceptions + Assert.True(true, "ObservableProvider handled error scenarios without crashing the system"); + } + + /// + /// CRITICAL COMPLETION TEST: Validates ObservableProvider behavior when BehaviorSubject completes. + /// Tests that completed observables are handled gracefully in ongoing configuration management. + /// + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task ObservableProvider_CompletionHandling_GracefulBehavior() + { + var completableSubject = new BehaviorSubject("""{"Name": "CompletableTest", "Status": "Active"}"""); + var provider = new ObservableProvider(new(completableSubject)); + var query = new ObservableProviderQuery(); + + var initialResult = await provider.FetchConfigurationBytesAsync(query); + Assert.NotNull(initialResult); + Assert.Equal("CompletableTest", initialResult.ToJsonElement().GetProperty("Name").GetString()); + Assert.Equal("Active", initialResult.ToJsonElement().GetProperty("Status").GetString()); + + completableSubject.OnNext("""{"Name": "FinalValue", "Status": "Completing"}"""); + completableSubject.OnCompleted(); + + Exception? completionErrorCaught = null; + try + { + var postCompletionResult = await provider.FetchConfigurationBytesAsync(query); + Assert.NotNull(postCompletionResult); + Assert.Equal("FinalValue", postCompletionResult.ToJsonElement().GetProperty("Name").GetString()); + } + catch (Exception ex) + { + completionErrorCaught = ex; + // This may also be acceptable depending on implementation + } + + // Key point: system should remain stable after observable completion + Assert.True(true, "ObservableProvider handled completion scenario without system instability"); + } + + /// + /// CRITICAL SELECTION AND MOUNTING TEST: Validates ConfigManager with Select() and MountAt() operations. + /// Tests the full pipeline: Fetch ΓåÆ Select ΓåÆ Mount ΓåÆ Merge with multi-provider scenarios. + /// This ensures flattened merging works correctly with nested configuration paths and rule order precedence. + /// + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public void ConfigManager_SelectAndMount_CorrectFlattendMerging() + { + + var baseConfig = """ + { + "App": { + "Name": "TestApp", + "Version": "1.0.0" + }, + "Database": { + "ConnectionString": "server=base;", + "Timeout": 30, + "Pool": { + "MinSize": 5, + "MaxSize": 100 + } + }, + "Features": { + "EnableLogging": true, + "EnableMetrics": false + } + } + """; + + var databaseOverride = """ + { + "ConnectionString": "server=override;database=prod;", + "Timeout": 60, + "Pool": { + "MaxSize": 200, + "IdleTimeout": 300 + } + } + """; + + var featuresConfig = """ + { + "NewFeatures": { + "EnableCaching": true, + "EnableRetry": true + }, + "Legacy": { + "EnableOldUI": false + } + } + """; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(baseConfig), // Rule 0: Base config + rules.For().FromStaticJson(databaseOverride).MountAt("Database"), // Rule 1: Replace Database section + rules.For().FromStaticJson(featuresConfig).Select("NewFeatures").MountAt("Features"), // Rule 2: Mount NewFeatures as Features + rules.For().FromStaticJson(featuresConfig).Select("Legacy").MountAt("LegacySettings") // Rule 3: Mount Legacy under new path + ])); + + var config = configManager.GetConfig(); + Assert.NotNull(config); + + // App section (from base, unchanged) + Assert.Equal("TestApp", config!.App.Name); + Assert.Equal("1.0.0", config.App.Version); + + // Database section (completely replaced by Rule 1) + Assert.Equal("server=override;database=prod;", config.Database.ConnectionString); // From override + Assert.Equal(60, config.Database.Timeout); // From override + Assert.Equal(5, config.Database.Pool.MinSize); // From base (not overridden, key not present in override) + Assert.Equal(200, config.Database.Pool.MaxSize); // From override (nested merge) + Assert.Equal(300, config.Database.Pool.IdleTimeout); // From override (new field) + + // Features section (Rule 2: NewFeatures selected and mounted as Features, overriding base Features) + Assert.True(config.Features.EnableCaching); // From Rule 2 (NewFeaturesΓåÆFeatures) + Assert.True(config.Features.EnableRetry); // From Rule 2 (NewFeaturesΓåÆFeatures) + + // LegacySettings section (Rule 3: Legacy selected and mounted under LegacySettings) + Assert.False(config.LegacySettings.EnableOldUI); // From Rule 3 (LegacyΓåÆLegacySettings) + Assert.NotNull(config.App); + Assert.NotNull(config.Database); + Assert.NotNull(config.Features); + Assert.NotNull(config.LegacySettings); + } + + /// + /// ADVANCED MOUNTING TEST: Tests complex nested path mounting with Observable providers. + /// Validates that Select() and MountAt() work correctly with dynamic configuration changes + /// and that flattened key merging handles complex nested paths properly. + /// + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public async Task ConfigManager_DynamicSelectAndMount_ComplexNesting() + { + + var baseConfig = """ + { + "Root": { + "Static": { + "Value": "BaseStatic" + } + }, + "Services": { + "Database": { + "Host": "localhost" + } + } + } + """; + + // Dynamic config source with deep nesting + var dynamicSubject = new BehaviorSubject(""" + { + "DynamicSection": { + "Deep": { + "Nested": { + "Value": "InitialDynamic", + "Config": { + "Setting1": "A", + "Setting2": 42 + } + } + } + }, + "Other": { + "Ignored": "This will not be selected" + } + } + """); + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(baseConfig), // Rule 0: Base + rules.For().FromObservable(dynamicSubject) // Rule 1: Select deep path, mount at new location + .Select("DynamicSection:Deep:Nested") + .MountAt("Root:Dynamic") + ])); + + // Wait for initial configuration to be available + await ActiveWaitHelpers.WaitUntilAsync( + () => configManager.GetConfig() != null, + description: "initial config to be available"); + + var initialConfig = configManager.GetConfig(); + Assert.NotNull(initialConfig); + + Assert.Equal("BaseStatic", initialConfig.Root.Static.Value); // From base + Assert.Equal("localhost", initialConfig.Services.Database.Host); // From base + Assert.Equal("InitialDynamic", initialConfig.Root.Dynamic.Value); // From dynamic: DynamicSection:Deep:NestedΓåÆRoot:Dynamic + Assert.Equal("A", initialConfig.Root.Dynamic.Config.Setting1); // From dynamic, nested + Assert.Equal(42, initialConfig.Root.Dynamic.Config.Setting2); // From dynamic, nested + + dynamicSubject.OnNext(""" + { + "DynamicSection": { + "Deep": { + "Nested": { + "Value": "UpdatedDynamic", + "Config": { + "Setting1": "B", + "Setting2": 99, + "NewSetting": "Added" + } + } + } + }, + "Other": { + "Ignored": "Still ignored" + } + } + """); + + // Wait for the dynamic value to update + await ActiveWaitHelpers.WaitForValueAsync( + () => configManager.GetConfig()?.Root?.Dynamic?.Value, + "UpdatedDynamic", + description: "dynamic config value to update to 'UpdatedDynamic'"); + + var finalConfig = configManager.GetConfig(); + Assert.NotNull(finalConfig); + + Assert.Equal("BaseStatic", finalConfig.Root.Static.Value); // Unchanged + Assert.Equal("localhost", finalConfig.Services.Database.Host); // Unchanged + Assert.Equal("UpdatedDynamic", finalConfig.Root.Dynamic.Value); // Updated + Assert.Equal("B", finalConfig.Root.Dynamic.Config.Setting1); // Updated + Assert.Equal(99, finalConfig.Root.Dynamic.Config.Setting2); // Updated + Assert.Equal("Added", finalConfig.Root.Dynamic.Config.NewSetting); // New field + } + + // Configuration models for testing Select() and MountAt() operations + public class ComplexConfig + { + public AppSection App { get; set; } = new(); + public DatabaseSection Database { get; set; } = new(); + public FeaturesSection Features { get; set; } = new(); + public LegacySection LegacySettings { get; set; } = new(); + } + + public class AppSection + { + public string Name { get; set; } = ""; + public string Version { get; set; } = ""; + } + + public class DatabaseSection + { + public string ConnectionString { get; set; } = ""; + public int Timeout { get; set; } + public PoolSection Pool { get; set; } = new(); + } + + public class PoolSection + { + public int MinSize { get; set; } + public int MaxSize { get; set; } + public int IdleTimeout { get; set; } + } + + public class FeaturesSection + { + public bool EnableLogging { get; set; } + public bool EnableMetrics { get; set; } + public bool EnableCaching { get; set; } + public bool EnableRetry { get; set; } + } + + public class LegacySection + { + public bool EnableOldUI { get; set; } + } + + public class NestedConfig + { + public RootSection Root { get; set; } = new(); + public ServicesSection Services { get; set; } = new(); + } + + public class RootSection + { + public StaticSection Static { get; set; } = new(); + public DynamicSection Dynamic { get; set; } = new(); + } + + public class StaticSection + { + public string Value { get; set; } = ""; + } + + public class DynamicSection + { + public string Value { get; set; } = ""; + public DynamicConfigSection Config { get; set; } = new(); + } + + public class DynamicConfigSection + { + public string Setting1 { get; set; } = ""; + public int Setting2 { get; set; } + public string NewSetting { get; set; } = ""; + } + + public class ServicesSection + { + public DatabaseServiceSection Database { get; set; } = new(); + } + + public class DatabaseServiceSection + { + public string Host { get; set; } = ""; + } + + private class DummyProviderOptions : IProviderConfiguration + { + private readonly string _key; + public DummyProviderOptions(string key) => _key = key; + public string GenerateProviderKey() => _key; + } + + private class DummyProviderQuery : IProviderQuery + { + public string GenerateProviderKey() => "default"; + } + + /// + /// Trackable wrapper around StaticJsonProvider to count fetch operations. + /// This allows us to verify the partial recompute optimization in ConfigManager. + /// + private class TrackableStaticJsonProvider : ConfigurationProvider + { + private readonly JsonElement _data; + private readonly Action _onFetch; + + public TrackableStaticJsonProvider(string jsonData, Action onFetch) + { + using var document = JsonDocument.Parse(jsonData); + _data = document.RootElement.Clone(); + _onFetch = onFetch; + } + + public override Task FetchConfigurationBytesAsync(IProviderQuery query, CancellationToken ct = default) + { + _onFetch(); // Track the fetch + var bytes = JsonSerializer.SerializeToUtf8Bytes(_data); + return Task.FromResult(bytes); + } + + public override IObservable ChangesAsBytes(IProviderQuery query) => + // Static provider never changes + Observable.Never(); + } + + public class TestConfig + { + public string Name { get; set; } = ""; + public int Priority { get; set; } + public string Environment { get; set; } = ""; + public string BackupValue { get; set; } = ""; + public string DynamicValue { get; set; } = ""; + public string TempValue { get; set; } = ""; + public string Status { get; set; } = ""; + public NestedSettings Settings { get; set; } = new(); + } + + public class NestedSettings + { + public bool ReadOnly { get; set; } + public bool Dynamic { get; set; } + public bool Feature1 { get; set; } + public bool Feature2 { get; set; } + public bool Reliable { get; set; } + public string NewField { get; set; } = ""; + public string NewProp1 { get; set; } = ""; + public string NewProp2 { get; set; } = ""; + } + + #endregion +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderReactiveTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderReactiveTests.cs index f359443..fbfd53a 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderReactiveTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderReactiveTests.cs @@ -1,316 +1,316 @@ -using System.Reactive.Subjects; -using System.Text.Json; -using Cocoar.Configuration.Core.Tests.TestUtilities; -using static Cocoar.Configuration.Core.Tests.Integration.MultiProviderTestModels; - -namespace Cocoar.Configuration.Core.Tests.Integration; -[Trait("Category", "Integration")] -[Trait("Component", "ConfigManager")] -public class MultiProviderReactiveTests -{ - #region Reactive Integration Tests - - #region Reactive Integration Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public async Task ConfigManager_ObservableChanges_UpdatesReactiveConfig() - { - var staticBase = """{"Name": "Static", "Version": 1, "Database": {"Timeout": 30}}"""; - var initialObservableJson = """{"Name": "Observable", "Version": 10}"""; - var behaviorSubject = new BehaviorSubject(initialObservableJson); - - var rules = new List - { - TestRules.StaticJson(staticBase), // Base (rule 0) - TestRules.ObservableString(behaviorSubject) // Observable (rule 1, wins) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(100)); - var reactiveConfig = configManager.GetReactiveConfig(); - - var emissions = new List(); - var subscription = reactiveConfig.Subscribe(config => emissions.Add(config)); - - // Wait for initial emission using active waiting - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count > 0 && emissions.Last().Name == "Observable", - description: "initial Observable configuration to emit"); - - var initialConfig = emissions.Last(); - Assert.NotNull(initialConfig); - Assert.Equal("Observable", initialConfig.Name); // From Observable - Assert.Equal(10, initialConfig.Version); // From Observable - Assert.Equal(30, initialConfig.Database.Timeout); // From Static (not overridden) - - var updatedObservableJson = """{"Name": "UpdatedObservable", "Version": 20}"""; - behaviorSubject.OnNext(updatedObservableJson); - - // Wait for updated emission using active waiting - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Any(e => e.Name == "UpdatedObservable"), - description: "updated Observable configuration to emit"); - - var latestConfig = emissions.Last(); - Assert.NotNull(latestConfig); - Assert.Equal("UpdatedObservable", latestConfig.Name); // Observable won - Assert.Equal(20, latestConfig.Version); // Observable won - Assert.Equal(30, latestConfig.Database.Timeout); // Static preserved - - var currentSnapshot = configManager.GetConfig(); - Assert.NotNull(currentSnapshot); - Assert.Equal("UpdatedObservable", currentSnapshot.Name); - Assert.Equal(20, currentSnapshot.Version); - Assert.Equal(30, currentSnapshot.Database.Timeout); - - subscription.Dispose(); - behaviorSubject.Dispose(); - } - [Fact] - [Trait("Type", "Concurrency")] - [Trait("Provider", "ConfigManager")] - public async Task ConfigManager_RapidObservableChanges_DebouncesCorrectly() - { - var staticBase = """ - { - "Name": "StaticBase", - "Database": { - "ConnectionString": "server=static;", - "Timeout": 30 - } - } - """; - - var initialObservableJson = """ - { - "Name": "Initial", - "Version": 0, - "Database": { - "ConnectionString": "server=initial;" - } - } - """; - - var behaviorSubject = new BehaviorSubject(initialObservableJson); - - var rules = new List - { - TestRules.StaticJson(staticBase), - TestRules.ObservableString(behaviorSubject) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(50)); - var reactiveConfig = configManager.GetReactiveConfig(); - - var emissions = new List(); - var subscription = reactiveConfig.Subscribe(config => emissions.Add(config)); - - // Wait for initial emission using active waiting - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count > 0, - description: "initial emission"); - var initialCount = emissions.Count; - - for (var i = 1; i <= 20; i++) - { - var updateJson = $$""" - { - "Name": "Change{{i}}", - "Version": {{i}}, - "Database": { - "ConnectionString": "server=change{{i}};" - } - } - """; - behaviorSubject.OnNext(updateJson); - } - - // Wait for final emission using active waiting - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Any(e => e.Name == "Change20"), - description: "final Change20 emission"); - - var finalEmissionCount = emissions.Count; - var finalConfig = emissions.Last(); - Assert.NotNull(finalConfig); - - Assert.Equal("Change20", finalConfig.Name); // Final Observable value - Assert.Equal(20, finalConfig.Version); // Final Observable value - Assert.Equal("server=change20;", finalConfig.Database.ConnectionString); // Final Observable value - Assert.Equal(30, finalConfig.Database.Timeout); // From Static base (not overridden) - - var newEmissions = finalEmissionCount - initialCount; - Assert.True(newEmissions > 0, "Should have at least one emission from changes"); - Assert.True(newEmissions < 20, "Should have fewer emissions than changes (debouncing)"); - - subscription.Dispose(); - behaviorSubject.Dispose(); - } - - #endregion - - #endregion - - #region Hash-Gated Emission Tests - - #region Hash-Gated Emission Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public async Task ConfigManager_Observable_NoEmission_WhenOnlyPropertyOrderDiffers() - { - - var initialJson = @"{ - ""Name"": ""TestApp"", - ""Version"": 1, - ""Database"": { - ""ConnectionString"": ""Server=test"", - ""Timeout"": 30 - } - }"; - - using var subject = new BehaviorSubject(initialJson); - - var rules = new List - { - TestRules.ObservableString(subject) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(100)); - var reactiveConfig = configManager.GetReactiveConfig(); - - var emissions = new List(); - var subscription = reactiveConfig.Subscribe(config => emissions.Add(config)); - - // Wait for initial emission using active waiting - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count > 0, - description: "initial emission"); - var initialEmissionCount = emissions.Count; - - var reorderedJson = @"{ - ""Database"": { - ""Timeout"": 30, - ""ConnectionString"": ""Server=test"" - }, - ""Version"": 1, - ""Name"": ""TestApp"" - }"; - - subject.OnNext(reorderedJson); - - // Wait beyond debounce window to ensure no spurious emissions - await ActiveWaitHelpers.WaitUntilAsync( - () => true, // Just wait for debounce to settle - timeout: TimeSpan.FromMilliseconds(200), - description: "debounce to settle after property reorder"); - - Assert.Equal(initialEmissionCount, emissions.Count); - - var currentConfig = reactiveConfig.CurrentValue; - Assert.Equal("TestApp", currentConfig.Name); - Assert.Equal(1, currentConfig.Version); - Assert.Equal("Server=test", currentConfig.Database.ConnectionString); - Assert.Equal(30, currentConfig.Database.Timeout); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public async Task ConfigManager_Observable_NoEmission_WhenWhitespaceChanges() - { - - var compactJson = @"{""Name"":""TestApp"",""Version"":2}"; - - using var subject = new BehaviorSubject(compactJson); - - var rules = new List - { - TestRules.ObservableString(subject) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(100)); - var reactiveConfig = configManager.GetReactiveConfig(); - - var emissions = new List(); - var subscription = reactiveConfig.Subscribe(config => emissions.Add(config)); - - // Wait for initial emission using active waiting - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count > 0, - description: "initial emission"); - var initialEmissionCount = emissions.Count; - - var formattedJson = @"{ - ""Name"" : ""TestApp"" , - ""Version"" : 2 - }"; - - subject.OnNext(formattedJson); - - // Wait beyond debounce window to ensure no spurious emissions - await ActiveWaitHelpers.WaitUntilAsync( - () => true, // Just wait for debounce to settle - timeout: TimeSpan.FromMilliseconds(200), - description: "debounce to settle after whitespace change"); - - Assert.Equal(initialEmissionCount, emissions.Count); - - var currentConfig = reactiveConfig.CurrentValue; - Assert.Equal("TestApp", currentConfig.Name); - Assert.Equal(2, currentConfig.Version); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - public async Task ConfigManager_Observable_EmissionWhen_ArrayOrderChanges_OrderSensitive() - { - - var initialJson = @"{ - ""Name"": ""TestApp"", - ""Environments"": [""dev"", ""prod"", ""test""] - }"; - - using var subject = new BehaviorSubject(initialJson); - - var rules = new List - { - TestRules.ObservableString(subject) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(100)); - var reactiveConfig = configManager.GetReactiveConfig(); - - var emissions = new List(); - var subscription = reactiveConfig.Subscribe(config => emissions.Add(config)); - - // Wait for initial emission using active waiting - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count > 0, - description: "initial emission"); - var initialEmissionCount = emissions.Count; - - var reorderedJson = @"{ - ""Name"": ""TestApp"", - ""Environments"": [""prod"", ""dev"", ""test""] - }"; - - subject.OnNext(reorderedJson); - - // Wait for potential emission using active waiting - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count > initialEmissionCount, - timeout: TimeSpan.FromSeconds(5), - description: "emission after array order change"); - - Assert.True(emissions.Count > initialEmissionCount, - "Expected emission when array order changes because arrays are order-sensitive"); - - var currentConfig = reactiveConfig.CurrentValue; - Assert.Equal("TestApp", currentConfig.Name); - Assert.Equal(new[] { "prod", "dev", "test" }, currentConfig.Environments); - } - - #endregion - - #endregion +using System.Reactive.Subjects; +using System.Text.Json; +using Cocoar.Configuration.Core.Tests.TestUtilities; +using static Cocoar.Configuration.Core.Tests.Integration.MultiProviderTestModels; + +namespace Cocoar.Configuration.Core.Tests.Integration; +[Trait("Category", "Integration")] +[Trait("Component", "ConfigManager")] +public class MultiProviderReactiveTests +{ + #region Reactive Integration Tests + + #region Reactive Integration Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public async Task ConfigManager_ObservableChanges_UpdatesReactiveConfig() + { + var staticBase = """{"Name": "Static", "Version": 1, "Database": {"Timeout": 30}}"""; + var initialObservableJson = """{"Name": "Observable", "Version": 10}"""; + var behaviorSubject = new BehaviorSubject(initialObservableJson); + + var rules = new List + { + TestRules.StaticJson(staticBase), // Base (rule 0) + TestRules.ObservableString(behaviorSubject) // Observable (rule 1, wins) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(100)); + var reactiveConfig = configManager.GetReactiveConfig(); + + var emissions = new List(); + var subscription = reactiveConfig.Subscribe(config => emissions.Add(config)); + + // Wait for initial emission using active waiting + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count > 0 && emissions.Last().Name == "Observable", + description: "initial Observable configuration to emit"); + + var initialConfig = emissions.Last(); + Assert.NotNull(initialConfig); + Assert.Equal("Observable", initialConfig.Name); // From Observable + Assert.Equal(10, initialConfig.Version); // From Observable + Assert.Equal(30, initialConfig.Database.Timeout); // From Static (not overridden) + + var updatedObservableJson = """{"Name": "UpdatedObservable", "Version": 20}"""; + behaviorSubject.OnNext(updatedObservableJson); + + // Wait for updated emission using active waiting + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Any(e => e.Name == "UpdatedObservable"), + description: "updated Observable configuration to emit"); + + var latestConfig = emissions.Last(); + Assert.NotNull(latestConfig); + Assert.Equal("UpdatedObservable", latestConfig.Name); // Observable won + Assert.Equal(20, latestConfig.Version); // Observable won + Assert.Equal(30, latestConfig.Database.Timeout); // Static preserved + + var currentSnapshot = configManager.GetConfig(); + Assert.NotNull(currentSnapshot); + Assert.Equal("UpdatedObservable", currentSnapshot.Name); + Assert.Equal(20, currentSnapshot.Version); + Assert.Equal(30, currentSnapshot.Database.Timeout); + + subscription.Dispose(); + behaviorSubject.Dispose(); + } + [Fact] + [Trait("Type", "Concurrency")] + [Trait("Provider", "ConfigManager")] + public async Task ConfigManager_RapidObservableChanges_DebouncesCorrectly() + { + var staticBase = """ + { + "Name": "StaticBase", + "Database": { + "ConnectionString": "server=static;", + "Timeout": 30 + } + } + """; + + var initialObservableJson = """ + { + "Name": "Initial", + "Version": 0, + "Database": { + "ConnectionString": "server=initial;" + } + } + """; + + var behaviorSubject = new BehaviorSubject(initialObservableJson); + + var rules = new List + { + TestRules.StaticJson(staticBase), + TestRules.ObservableString(behaviorSubject) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(50)); + var reactiveConfig = configManager.GetReactiveConfig(); + + var emissions = new List(); + var subscription = reactiveConfig.Subscribe(config => emissions.Add(config)); + + // Wait for initial emission using active waiting + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count > 0, + description: "initial emission"); + var initialCount = emissions.Count; + + for (var i = 1; i <= 20; i++) + { + var updateJson = $$""" + { + "Name": "Change{{i}}", + "Version": {{i}}, + "Database": { + "ConnectionString": "server=change{{i}};" + } + } + """; + behaviorSubject.OnNext(updateJson); + } + + // Wait for final emission using active waiting + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Any(e => e.Name == "Change20"), + description: "final Change20 emission"); + + var finalEmissionCount = emissions.Count; + var finalConfig = emissions.Last(); + Assert.NotNull(finalConfig); + + Assert.Equal("Change20", finalConfig.Name); // Final Observable value + Assert.Equal(20, finalConfig.Version); // Final Observable value + Assert.Equal("server=change20;", finalConfig.Database.ConnectionString); // Final Observable value + Assert.Equal(30, finalConfig.Database.Timeout); // From Static base (not overridden) + + var newEmissions = finalEmissionCount - initialCount; + Assert.True(newEmissions > 0, "Should have at least one emission from changes"); + Assert.True(newEmissions < 20, "Should have fewer emissions than changes (debouncing)"); + + subscription.Dispose(); + behaviorSubject.Dispose(); + } + + #endregion + + #endregion + + #region Hash-Gated Emission Tests + + #region Hash-Gated Emission Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public async Task ConfigManager_Observable_NoEmission_WhenOnlyPropertyOrderDiffers() + { + + var initialJson = @"{ + ""Name"": ""TestApp"", + ""Version"": 1, + ""Database"": { + ""ConnectionString"": ""Server=test"", + ""Timeout"": 30 + } + }"; + + using var subject = new BehaviorSubject(initialJson); + + var rules = new List + { + TestRules.ObservableString(subject) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(100)); + var reactiveConfig = configManager.GetReactiveConfig(); + + var emissions = new List(); + var subscription = reactiveConfig.Subscribe(config => emissions.Add(config)); + + // Wait for initial emission using active waiting + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count > 0, + description: "initial emission"); + var initialEmissionCount = emissions.Count; + + var reorderedJson = @"{ + ""Database"": { + ""Timeout"": 30, + ""ConnectionString"": ""Server=test"" + }, + ""Version"": 1, + ""Name"": ""TestApp"" + }"; + + subject.OnNext(reorderedJson); + + // Wait beyond debounce window to ensure no spurious emissions + await ActiveWaitHelpers.WaitUntilAsync( + () => true, // Just wait for debounce to settle + timeout: TimeSpan.FromMilliseconds(200), + description: "debounce to settle after property reorder"); + + Assert.Equal(initialEmissionCount, emissions.Count); + + var currentConfig = reactiveConfig.CurrentValue; + Assert.Equal("TestApp", currentConfig.Name); + Assert.Equal(1, currentConfig.Version); + Assert.Equal("Server=test", currentConfig.Database.ConnectionString); + Assert.Equal(30, currentConfig.Database.Timeout); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public async Task ConfigManager_Observable_NoEmission_WhenWhitespaceChanges() + { + + var compactJson = @"{""Name"":""TestApp"",""Version"":2}"; + + using var subject = new BehaviorSubject(compactJson); + + var rules = new List + { + TestRules.ObservableString(subject) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(100)); + var reactiveConfig = configManager.GetReactiveConfig(); + + var emissions = new List(); + var subscription = reactiveConfig.Subscribe(config => emissions.Add(config)); + + // Wait for initial emission using active waiting + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count > 0, + description: "initial emission"); + var initialEmissionCount = emissions.Count; + + var formattedJson = @"{ + ""Name"" : ""TestApp"" , + ""Version"" : 2 + }"; + + subject.OnNext(formattedJson); + + // Wait beyond debounce window to ensure no spurious emissions + await ActiveWaitHelpers.WaitUntilAsync( + () => true, // Just wait for debounce to settle + timeout: TimeSpan.FromMilliseconds(200), + description: "debounce to settle after whitespace change"); + + Assert.Equal(initialEmissionCount, emissions.Count); + + var currentConfig = reactiveConfig.CurrentValue; + Assert.Equal("TestApp", currentConfig.Name); + Assert.Equal(2, currentConfig.Version); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + public async Task ConfigManager_Observable_EmissionWhen_ArrayOrderChanges_OrderSensitive() + { + + var initialJson = @"{ + ""Name"": ""TestApp"", + ""Environments"": [""dev"", ""prod"", ""test""] + }"; + + using var subject = new BehaviorSubject(initialJson); + + var rules = new List + { + TestRules.ObservableString(subject) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(100)); + var reactiveConfig = configManager.GetReactiveConfig(); + + var emissions = new List(); + var subscription = reactiveConfig.Subscribe(config => emissions.Add(config)); + + // Wait for initial emission using active waiting + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count > 0, + description: "initial emission"); + var initialEmissionCount = emissions.Count; + + var reorderedJson = @"{ + ""Name"": ""TestApp"", + ""Environments"": [""prod"", ""dev"", ""test""] + }"; + + subject.OnNext(reorderedJson); + + // Wait for potential emission using active waiting + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count > initialEmissionCount, + timeout: TimeSpan.FromSeconds(5), + description: "emission after array order change"); + + Assert.True(emissions.Count > initialEmissionCount, + "Expected emission when array order changes because arrays are order-sensitive"); + + var currentConfig = reactiveConfig.CurrentValue; + Assert.Equal("TestApp", currentConfig.Name); + Assert.Equal(new[] { "prod", "dev", "test" }, currentConfig.Environments); + } + + #endregion + + #endregion } \ No newline at end of file diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderTestModels.cs b/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderTestModels.cs index a37e41e..899be03 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderTestModels.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Integration/MultiProviderTestModels.cs @@ -1,203 +1,203 @@ -using System.Reactive.Subjects; -using System.Reactive.Linq; -using System.Text.Json; -using Microsoft.Extensions.Logging.Abstractions; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Providers.Abstractions; -using Cocoar.Configuration.Rules; -using Cocoar.Configuration.Core.Tests.TestUtilities; - -namespace Cocoar.Configuration.Core.Tests.Integration; - -public static class MultiProviderTestModels -{ - public class AppConfig - { - public string Name { get; set; } = string.Empty; - public int Version { get; set; } - public DatabaseConfig Database { get; set; } = new(); - public FeatureFlags Features { get; set; } = new(); - } - - public class DatabaseConfig - { - public string ConnectionString { get; set; } = string.Empty; - public int Timeout { get; set; } - public bool EnableRetry { get; set; } - } - - public class FeatureFlags - { - public bool EnableNewUI { get; set; } - public bool EnableLogging { get; set; } - public string LogLevel { get; set; } = "Info"; - } - - public class ComplexConfig - { - public string AppName { get; set; } = string.Empty; - public DatabaseSection Database { get; set; } = new(); - public FeaturesSection Features { get; set; } = new(); - public LegacySettingsSection? LegacySettings { get; set; } - } - - public class DatabaseSection - { - public string Server { get; set; } = string.Empty; - public int Port { get; set; } - } - - public class FeaturesSection - { - public bool Alpha { get; set; } - public bool Beta { get; set; } - } - - public class LegacySettingsSection - { - public string Mode { get; set; } = string.Empty; - public int Timeout { get; set; } - } - - public class NestedConfig - { - public RootSection Root { get; set; } = new(); - } - - public class RootSection - { - public StaticSection Static { get; set; } = new(); - public DynamicSection? Dynamic { get; set; } - } - - public class StaticSection - { - public string Value { get; set; } = string.Empty; - } - - public class DynamicSection - { - public string Content { get; set; } = string.Empty; - } - - public class SelectMountConfig - { - public string DefaultValue { get; set; } = string.Empty; - public MountedSection? MountedSection { get; set; } - } - - public class MountedSection - { - public string Data { get; set; } = string.Empty; - } - - public class SimpleConfig - { - public string Value { get; set; } = string.Empty; - } - - public class MergeTestConfig - { - public string? A { get; set; } - public string? B { get; set; } - public string? C { get; set; } - } - - public class AppConfigWithArray - { - public string Name { get; set; } = string.Empty; - public string[] Environments { get; set; } = Array.Empty(); - } - - public class ScalarMergeConfig - { - public string Database { get; set; } = string.Empty; - } - - public class ArrayMergeConfig - { - public ArrayMergeSettings Settings { get; set; } = new(); - } - - public class ArrayMergeSettings - { - public string Primary { get; set; } = string.Empty; - public string Secondary { get; set; } = string.Empty; - } - - public class NullMergeConfig - { - public string Name { get; set; } = string.Empty; - public int Count { get; set; } - public bool Enabled { get; set; } - public double Score { get; set; } - } - - public class SnapshotConfig - { - public string Name { get; set; } = string.Empty; - public int Value { get; set; } - } - - public static bool JsonElementsEqual(JsonElement element1, JsonElement element2) - { - if (element1.ValueKind != element2.ValueKind) - { - return false; - } - - return element1.ValueKind switch - { - JsonValueKind.Object => CompareObjects(element1, element2), - JsonValueKind.Array => CompareArrays(element1, element2), - JsonValueKind.String => element1.GetString() == element2.GetString(), - JsonValueKind.Number => element1.GetRawText() == element2.GetRawText(), - JsonValueKind.True or JsonValueKind.False => element1.GetBoolean() == element2.GetBoolean(), - JsonValueKind.Null => true, - _ => false - }; - } - - private static bool CompareObjects(JsonElement obj1, JsonElement obj2) - { - var props1 = obj1.EnumerateObject().ToDictionary(p => p.Name, p => p.Value); - var props2 = obj2.EnumerateObject().ToDictionary(p => p.Name, p => p.Value); - - if (props1.Count != props2.Count) - { - return false; - } - - foreach (var kvp in props1) - { - if (!props2.TryGetValue(kvp.Key, out var value2) || !JsonElementsEqual(kvp.Value, value2)) - { - return false; - } - } - - return true; - } - - private static bool CompareArrays(JsonElement arr1, JsonElement arr2) - { - var items1 = arr1.EnumerateArray().ToArray(); - var items2 = arr2.EnumerateArray().ToArray(); - - if (items1.Length != items2.Length) - { - return false; - } - - for (var i = 0; i < items1.Length; i++) - { - if (!JsonElementsEqual(items1[i], items2[i])) - { - return false; - } - } - - return true; - } -} +using System.Reactive.Subjects; +using System.Reactive.Linq; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Core.Tests.TestUtilities; + +namespace Cocoar.Configuration.Core.Tests.Integration; + +public static class MultiProviderTestModels +{ + public class AppConfig + { + public string Name { get; set; } = string.Empty; + public int Version { get; set; } + public DatabaseConfig Database { get; set; } = new(); + public FeatureFlags Features { get; set; } = new(); + } + + public class DatabaseConfig + { + public string ConnectionString { get; set; } = string.Empty; + public int Timeout { get; set; } + public bool EnableRetry { get; set; } + } + + public class FeatureFlags + { + public bool EnableNewUI { get; set; } + public bool EnableLogging { get; set; } + public string LogLevel { get; set; } = "Info"; + } + + public class ComplexConfig + { + public string AppName { get; set; } = string.Empty; + public DatabaseSection Database { get; set; } = new(); + public FeaturesSection Features { get; set; } = new(); + public LegacySettingsSection? LegacySettings { get; set; } + } + + public class DatabaseSection + { + public string Server { get; set; } = string.Empty; + public int Port { get; set; } + } + + public class FeaturesSection + { + public bool Alpha { get; set; } + public bool Beta { get; set; } + } + + public class LegacySettingsSection + { + public string Mode { get; set; } = string.Empty; + public int Timeout { get; set; } + } + + public class NestedConfig + { + public RootSection Root { get; set; } = new(); + } + + public class RootSection + { + public StaticSection Static { get; set; } = new(); + public DynamicSection? Dynamic { get; set; } + } + + public class StaticSection + { + public string Value { get; set; } = string.Empty; + } + + public class DynamicSection + { + public string Content { get; set; } = string.Empty; + } + + public class SelectMountConfig + { + public string DefaultValue { get; set; } = string.Empty; + public MountedSection? MountedSection { get; set; } + } + + public class MountedSection + { + public string Data { get; set; } = string.Empty; + } + + public class SimpleConfig + { + public string Value { get; set; } = string.Empty; + } + + public class MergeTestConfig + { + public string? A { get; set; } + public string? B { get; set; } + public string? C { get; set; } + } + + public class AppConfigWithArray + { + public string Name { get; set; } = string.Empty; + public string[] Environments { get; set; } = Array.Empty(); + } + + public class ScalarMergeConfig + { + public string Database { get; set; } = string.Empty; + } + + public class ArrayMergeConfig + { + public ArrayMergeSettings Settings { get; set; } = new(); + } + + public class ArrayMergeSettings + { + public string Primary { get; set; } = string.Empty; + public string Secondary { get; set; } = string.Empty; + } + + public class NullMergeConfig + { + public string Name { get; set; } = string.Empty; + public int Count { get; set; } + public bool Enabled { get; set; } + public double Score { get; set; } + } + + public class SnapshotConfig + { + public string Name { get; set; } = string.Empty; + public int Value { get; set; } + } + + public static bool JsonElementsEqual(JsonElement element1, JsonElement element2) + { + if (element1.ValueKind != element2.ValueKind) + { + return false; + } + + return element1.ValueKind switch + { + JsonValueKind.Object => CompareObjects(element1, element2), + JsonValueKind.Array => CompareArrays(element1, element2), + JsonValueKind.String => element1.GetString() == element2.GetString(), + JsonValueKind.Number => element1.GetRawText() == element2.GetRawText(), + JsonValueKind.True or JsonValueKind.False => element1.GetBoolean() == element2.GetBoolean(), + JsonValueKind.Null => true, + _ => false + }; + } + + private static bool CompareObjects(JsonElement obj1, JsonElement obj2) + { + var props1 = obj1.EnumerateObject().ToDictionary(p => p.Name, p => p.Value); + var props2 = obj2.EnumerateObject().ToDictionary(p => p.Name, p => p.Value); + + if (props1.Count != props2.Count) + { + return false; + } + + foreach (var kvp in props1) + { + if (!props2.TryGetValue(kvp.Key, out var value2) || !JsonElementsEqual(kvp.Value, value2)) + { + return false; + } + } + + return true; + } + + private static bool CompareArrays(JsonElement arr1, JsonElement arr2) + { + var items1 = arr1.EnumerateArray().ToArray(); + var items2 = arr2.EnumerateArray().ToArray(); + + if (items1.Length != items2.Length) + { + return false; + } + + for (var i = 0; i < items1.Length; i++) + { + if (!JsonElementsEqual(items1[i], items2[i])) + { + return false; + } + } + + return true; + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerDeserializationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerDeserializationTests.cs index b342965..cd313ca 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerDeserializationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerDeserializationTests.cs @@ -1,221 +1,221 @@ -using Cocoar.Configuration.Core.Tests.TestUtilities; -using Microsoft.Extensions.Logging; - -namespace Cocoar.Configuration.Core.Tests.Managers; - -public class ConfigManagerDeserializationTests : IDisposable -{ - private readonly List _disposables = new(); - private readonly TestLogger _logger = new(); - - public void Dispose() - { - foreach (var disposable in _disposables) - { - try - { - disposable.Dispose(); - } - catch - { - // Ignore disposal errors in tests - } - } - _disposables.Clear(); - } - - private void TrackForDisposal(IDisposable disposable) - { - _disposables.Add(disposable); - } - - public class ConfigWithRequired - { - public required string Name { get; set; } - public int Value { get; set; } - } - - public class ConfigWithInt - { - public int Count { get; set; } - } - - public class SimpleConfig - { - public string Name { get; set; } = string.Empty; - public int Value { get; set; } - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "Deserialization")] - [Trait("Priority", "High")] - public void Initialize_MissingRequiredProperty_ThrowsDeserializationException() - { - // Arrange: JSON is missing the required "Name" property - var json = """{"Value": 42}"""; - - // Act & Assert: With Master Backplane, deserialization failures at startup throw - var exception = Assert.Throws( - () => - { - var configManager = ConfigManager.Create(c => c.UseConfiguration( - r => [r.For().FromStaticJson(json)]).UseLogger(_logger)); - TrackForDisposal(configManager); - }); - - Assert.Single(exception.Failures); - Assert.Equal(typeof(ConfigWithRequired), exception.Failures[0].ConfigType); - Assert.Contains("Name", exception.Failures[0].Message); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "Deserialization")] - [Trait("Priority", "High")] - public void Initialize_MissingRequiredProperty_ExceptionContainsJsonPreview() - { - // Arrange: JSON is missing the required "Name" property - var json = """{"Value": 42}"""; - - // Act & Assert - var exception = Assert.Throws( - () => - { - var configManager = ConfigManager.Create(c => c.UseConfiguration( - r => [r.For().FromStaticJson(json)]).UseLogger(_logger)); - TrackForDisposal(configManager); - }); - - // The exception should include a JSON preview with property names (not values, for secret safety) - Assert.NotNull(exception.Failures[0].JsonPreview); - Assert.Contains("Value", exception.Failures[0].JsonPreview); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "Deserialization")] - [Trait("Priority", "High")] - public void GetConfig_ValidJson_ReturnsInstanceWithNoErrorLog() - { - // Arrange: JSON has all required properties - var json = """{"Name": "test", "Value": 42}"""; - - var configManager = ConfigManager.Create(c => c.UseConfiguration( - r => [r.For().FromStaticJson(json)]).UseLogger(_logger)); - TrackForDisposal(configManager); - - // Act - var result = configManager.GetConfig(); - - // Assert - Assert.NotNull(result); - Assert.Equal("test", result.Name); - Assert.Equal(42, result.Value); - Assert.False(_logger.HasLogEntry(LogLevel.Error, "deserialize"), - "No error should be logged for successful deserialization"); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "Deserialization")] - [Trait("Priority", "Medium")] - public void Initialize_TypeMismatch_ThrowsDeserializationException() - { - // Arrange: JSON has a string where an int is expected - var json = """{"Count": "not-a-number"}"""; - - // Act & Assert: With Master Backplane, deserialization failures at startup throw - var exception = Assert.Throws( - () => - { - var configManager = ConfigManager.Create(c => c.UseConfiguration( - r => [r.For().FromStaticJson(json)]).UseLogger(_logger)); - TrackForDisposal(configManager); - }); - - Assert.Single(exception.Failures); - Assert.Equal(typeof(ConfigWithInt), exception.Failures[0].ConfigType); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "Deserialization")] - [Trait("Priority", "Medium")] - public void Initialize_MultipleFailures_ThrowsExceptionWithAllFailures() - { - // Arrange: Both configs will fail - var json1 = """{"Value": 123}"""; // Missing Name - var json2 = """{"Count": "not-a-number"}"""; // Type mismatch - - // Act & Assert - var exception = Assert.Throws( - () => - { - var configManager = ConfigManager.Create(c => c.UseConfiguration( - r => [ - r.For().FromStaticJson(json1), - r.For().FromStaticJson(json2) - ]).UseLogger(_logger)); - TrackForDisposal(configManager); - }); - - Assert.Equal(2, exception.Failures.Count); - Assert.Contains(exception.Failures, f => f.ConfigType == typeof(ConfigWithRequired)); - Assert.Contains(exception.Failures, f => f.ConfigType == typeof(ConfigWithInt)); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "Deserialization")] - [Trait("Priority", "Low")] - public void GetConfig_SimpleConfig_WorksWithDefaultValues() - { - // Arrange: Simple config without required properties - var json = """{"Name": "test"}"""; - - var configManager = ConfigManager.Create(c => c.UseConfiguration( - r => [r.For().FromStaticJson(json)]).UseLogger(_logger)); - TrackForDisposal(configManager); - - // Act - var result = configManager.GetConfig(); - - // Assert - Assert.NotNull(result); - Assert.Equal("test", result.Name); - Assert.Equal(0, result.Value); // Default value - Assert.False(_logger.HasLogEntry(LogLevel.Error, "deserialize"), - "No error should be logged for successful deserialization"); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "Deserialization")] - [Trait("Priority", "High")] - public void GetConfig_AfterSuccessfulInit_ReturnsCachedInstance() - { - // Arrange: Valid JSON - var json = """{"Name": "test", "Value": 42}"""; - - var configManager = ConfigManager.Create(c => c.UseConfiguration( - r => [r.For().FromStaticJson(json)]).UseLogger(_logger)); - TrackForDisposal(configManager); - - // Act: Get config multiple times - var result1 = configManager.GetConfig(); - var result2 = configManager.GetConfig(); - - // Assert: Same instance returned (no re-deserialization) - Assert.NotNull(result1); - Assert.NotNull(result2); - Assert.Same(result1, result2); - } -} +using Cocoar.Configuration.Core.Tests.TestUtilities; +using Microsoft.Extensions.Logging; + +namespace Cocoar.Configuration.Core.Tests.Managers; + +public class ConfigManagerDeserializationTests : IDisposable +{ + private readonly List _disposables = new(); + private readonly TestLogger _logger = new(); + + public void Dispose() + { + foreach (var disposable in _disposables) + { + try + { + disposable.Dispose(); + } + catch + { + // Ignore disposal errors in tests + } + } + _disposables.Clear(); + } + + private void TrackForDisposal(IDisposable disposable) + { + _disposables.Add(disposable); + } + + public class ConfigWithRequired + { + public required string Name { get; set; } + public int Value { get; set; } + } + + public class ConfigWithInt + { + public int Count { get; set; } + } + + public class SimpleConfig + { + public string Name { get; set; } = string.Empty; + public int Value { get; set; } + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "Deserialization")] + [Trait("Priority", "High")] + public void Initialize_MissingRequiredProperty_ThrowsDeserializationException() + { + // Arrange: JSON is missing the required "Name" property + var json = """{"Value": 42}"""; + + // Act & Assert: With Master Backplane, deserialization failures at startup throw + var exception = Assert.Throws( + () => + { + var configManager = ConfigManager.Create(c => c.UseConfiguration( + r => [r.For().FromStaticJson(json)]).UseLogger(_logger)); + TrackForDisposal(configManager); + }); + + Assert.Single(exception.Failures); + Assert.Equal(typeof(ConfigWithRequired), exception.Failures[0].ConfigType); + Assert.Contains("Name", exception.Failures[0].Message); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "Deserialization")] + [Trait("Priority", "High")] + public void Initialize_MissingRequiredProperty_ExceptionContainsJsonPreview() + { + // Arrange: JSON is missing the required "Name" property + var json = """{"Value": 42}"""; + + // Act & Assert + var exception = Assert.Throws( + () => + { + var configManager = ConfigManager.Create(c => c.UseConfiguration( + r => [r.For().FromStaticJson(json)]).UseLogger(_logger)); + TrackForDisposal(configManager); + }); + + // The exception should include a JSON preview with property names (not values, for secret safety) + Assert.NotNull(exception.Failures[0].JsonPreview); + Assert.Contains("Value", exception.Failures[0].JsonPreview); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "Deserialization")] + [Trait("Priority", "High")] + public void GetConfig_ValidJson_ReturnsInstanceWithNoErrorLog() + { + // Arrange: JSON has all required properties + var json = """{"Name": "test", "Value": 42}"""; + + var configManager = ConfigManager.Create(c => c.UseConfiguration( + r => [r.For().FromStaticJson(json)]).UseLogger(_logger)); + TrackForDisposal(configManager); + + // Act + var result = configManager.GetConfig(); + + // Assert + Assert.NotNull(result); + Assert.Equal("test", result.Name); + Assert.Equal(42, result.Value); + Assert.False(_logger.HasLogEntry(LogLevel.Error, "deserialize"), + "No error should be logged for successful deserialization"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "Deserialization")] + [Trait("Priority", "Medium")] + public void Initialize_TypeMismatch_ThrowsDeserializationException() + { + // Arrange: JSON has a string where an int is expected + var json = """{"Count": "not-a-number"}"""; + + // Act & Assert: With Master Backplane, deserialization failures at startup throw + var exception = Assert.Throws( + () => + { + var configManager = ConfigManager.Create(c => c.UseConfiguration( + r => [r.For().FromStaticJson(json)]).UseLogger(_logger)); + TrackForDisposal(configManager); + }); + + Assert.Single(exception.Failures); + Assert.Equal(typeof(ConfigWithInt), exception.Failures[0].ConfigType); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "Deserialization")] + [Trait("Priority", "Medium")] + public void Initialize_MultipleFailures_ThrowsExceptionWithAllFailures() + { + // Arrange: Both configs will fail + var json1 = """{"Value": 123}"""; // Missing Name + var json2 = """{"Count": "not-a-number"}"""; // Type mismatch + + // Act & Assert + var exception = Assert.Throws( + () => + { + var configManager = ConfigManager.Create(c => c.UseConfiguration( + r => [ + r.For().FromStaticJson(json1), + r.For().FromStaticJson(json2) + ]).UseLogger(_logger)); + TrackForDisposal(configManager); + }); + + Assert.Equal(2, exception.Failures.Count); + Assert.Contains(exception.Failures, f => f.ConfigType == typeof(ConfigWithRequired)); + Assert.Contains(exception.Failures, f => f.ConfigType == typeof(ConfigWithInt)); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "Deserialization")] + [Trait("Priority", "Low")] + public void GetConfig_SimpleConfig_WorksWithDefaultValues() + { + // Arrange: Simple config without required properties + var json = """{"Name": "test"}"""; + + var configManager = ConfigManager.Create(c => c.UseConfiguration( + r => [r.For().FromStaticJson(json)]).UseLogger(_logger)); + TrackForDisposal(configManager); + + // Act + var result = configManager.GetConfig(); + + // Assert + Assert.NotNull(result); + Assert.Equal("test", result.Name); + Assert.Equal(0, result.Value); // Default value + Assert.False(_logger.HasLogEntry(LogLevel.Error, "deserialize"), + "No error should be logged for successful deserialization"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "Deserialization")] + [Trait("Priority", "High")] + public void GetConfig_AfterSuccessfulInit_ReturnsCachedInstance() + { + // Arrange: Valid JSON + var json = """{"Name": "test", "Value": 42}"""; + + var configManager = ConfigManager.Create(c => c.UseConfiguration( + r => [r.For().FromStaticJson(json)]).UseLogger(_logger)); + TrackForDisposal(configManager); + + // Act: Get config multiple times + var result1 = configManager.GetConfig(); + var result2 = configManager.GetConfig(); + + // Assert: Same instance returned (no re-deserialization) + Assert.NotNull(result1); + Assert.NotNull(result2); + Assert.Same(result1, result2); + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerErrorHandlingTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerErrorHandlingTests.cs index 05ce038..a7dcbe4 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerErrorHandlingTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerErrorHandlingTests.cs @@ -1,255 +1,255 @@ -using System.Reactive.Subjects; -using Cocoar.Configuration.Core.Tests.Helpers; -using Cocoar.Configuration.Core.Tests.TestUtilities; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Cocoar.Configuration.Core.Tests.Managers; - -public class ConfigManagerErrorHandlingTests : IDisposable -{ - private readonly List _disposables = new(); - - public void Dispose() - { - foreach (var disposable in _disposables) - { - try - { - disposable.Dispose(); - } - catch - { - // Ignore disposal errors in tests - } - } - _disposables.Clear(); - } - - private void TrackForDisposal(IDisposable disposable) - { - _disposables.Add(disposable); - } - - public class TestConfig - { - public string Name { get; set; } = string.Empty; - public int Value { get; set; } - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "ErrorHandling")] - [Trait("Priority", "High")] - public void ConfigManager_RequiredRuleFails_ThrowsInvalidOperationException() - { - - var rules = new List - { - ConfigRule.Create( - FailableProviderOptions.QueryControlled("""{"Name": "Test"}"""), - FailableProviderQuery.Failure, // This will cause the provider to fail - typeof(TestConfig), - new(Required: true)) - }; - - var exception = Assert.Throws(() => - { - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - }); - - // Verify exception message contains provider information - Assert.Contains("Required rule failed", exception.Message); - Assert.Contains("FailableProvider", exception.Message); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "ErrorHandling")] - [Trait("Priority", "High")] - public void ConfigManager_OptionalRuleFails_ContinuesProcessing() - { - - var successData = """{"Name": "Success", "Value": 42}"""; - - var rules = new List - { - // First rule: Optional and will fail - should be skipped - ConfigRule.Create( - FailableProviderOptions.QueryControlled("""{"Name": "Fail"}"""), - FailableProviderQuery.Failure, - typeof(TestConfig), - new(Required: false)), // Optional rule - - // Second rule: Will succeed - should be used - ConfigRule.Create( - FailableProviderOptions.QueryControlled(successData), - FailableProviderQuery.Success, - typeof(TestConfig), - new(Required: false)) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - var config = configManager.GetConfig(); - - - Assert.NotNull(config); - Assert.Equal("Success", config.Name); - Assert.Equal(42, config.Value); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "ErrorHandling")] - [Trait("Priority", "Medium")] - public void ConfigManager_RequiredSucceedsOptionalFails_UsesRequiredRule() - { - - var requiredData = """{"Name": "Required", "Value": 100}"""; - - var rules = new List - { - // First rule: Required and will succeed - should be used - ConfigRule.Create( - FailableProviderOptions.QueryControlled(requiredData), - FailableProviderQuery.Success, - typeof(TestConfig), - new(Required: true)), // Required rule - - // Second rule: Optional and will fail - should be skipped - ConfigRule.Create( - FailableProviderOptions.QueryControlled("""{"Name": "OptionalFail"}"""), - FailableProviderQuery.Failure, - typeof(TestConfig), - new(Required: false)) // Optional rule - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - var config = configManager.GetConfig(); - - - Assert.NotNull(config); - Assert.Equal("Required", config.Name); - Assert.Equal(100, config.Value); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "ErrorHandling")] - [Trait("Priority", "Medium")] - public void ConfigManager_MultipleOptionalRulesFail_SkipsAllAndContinues() - { - - var successData = """{"Name": "OnlySuccess", "Value": 999}"""; - - var rules = new List - { - // Multiple optional rules that will fail - ConfigRule.Create( - FailableProviderOptions.QueryControlled("""{"Name": "Fail1"}"""), - new(true, "First failure"), - typeof(TestConfig), - new(Required: false)), - - ConfigRule.Create( - FailableProviderOptions.QueryControlled("""{"Name": "Fail2"}"""), - new(true, "Second failure"), - typeof(TestConfig), - new(Required: false)), - - // One successful rule at the end - ConfigRule.Create( - FailableProviderOptions.QueryControlled(successData), - FailableProviderQuery.Success, - typeof(TestConfig), - new(Required: false)) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - var config = configManager.GetConfig(); - - - Assert.NotNull(config); - Assert.Equal("OnlySuccess", config.Name); - Assert.Equal(999, config.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "ErrorHandling")] - public async Task RuntimeRecompute_RequiredRuleFails_SubscriberNeverSeesPartialState() - { - var validJson = """{"Name": "Initial", "Value": 1}"""; - var invalidJson = "NOT VALID JSON {{{"; - - var subject = new BehaviorSubject(validJson); - TrackForDisposal(subject); - - var rules = new List - { - TestRules.ObservableString(subject, required: true) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(50)); - TrackForDisposal(configManager); - - var reactiveConfig = configManager.GetReactiveConfig(); - - // Collect all values the subscriber ever observes - var observedValues = new List(); - var subscription = reactiveConfig.Subscribe(v => observedValues.Add(v)); - TrackForDisposal(subscription); - - // Wait for initial emission - await ActiveWaitHelpers.WaitUntilAsync( - () => observedValues.Count > 0 && observedValues.Last().Name == "Initial", - description: "initial configuration to emit"); - - var initialConfig = reactiveConfig.CurrentValue; - Assert.NotNull(initialConfig); - Assert.Equal("Initial", initialConfig.Name); - Assert.Equal(1, initialConfig.Value); - - var countBeforeBadPush = observedValues.Count; - - // Push invalid JSON — this should cause the required rule to fail during recompute, - // triggering a rollback that preserves the last good configuration - subject.OnNext(invalidJson); - - // Wait for the recompute cycle to complete (debounce + processing) - await ActiveWaitHelpers.WaitUntilAsync( - () => true, - timeout: TimeSpan.FromMilliseconds(500), - description: "recompute cycle to settle after invalid JSON push"); - - // The reactive config should still hold the last known good value - var currentAfterFailure = reactiveConfig.CurrentValue; - Assert.NotNull(currentAfterFailure); - Assert.Equal("Initial", currentAfterFailure.Name); - Assert.Equal(1, currentAfterFailure.Value); - - // The subscriber should never have seen a partial or invalid state — - // every observed value should have a valid Name - Assert.All(observedValues, v => - { - Assert.NotNull(v); - Assert.Equal("Initial", v.Name); - }); - - // Push a valid update to prove the system is still functional after the failure - var recoveryJson = """{"Name": "Recovered", "Value": 99}"""; - subject.OnNext(recoveryJson); - - await ActiveWaitHelpers.WaitUntilAsync( - () => reactiveConfig.CurrentValue.Name == "Recovered", - description: "configuration to update to Recovered after recovery"); - - var recoveredConfig = reactiveConfig.CurrentValue; - Assert.Equal("Recovered", recoveredConfig.Name); - Assert.Equal(99, recoveredConfig.Value); - } -} +using System.Reactive.Subjects; +using Cocoar.Configuration.Core.Tests.Helpers; +using Cocoar.Configuration.Core.Tests.TestUtilities; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cocoar.Configuration.Core.Tests.Managers; + +public class ConfigManagerErrorHandlingTests : IDisposable +{ + private readonly List _disposables = new(); + + public void Dispose() + { + foreach (var disposable in _disposables) + { + try + { + disposable.Dispose(); + } + catch + { + // Ignore disposal errors in tests + } + } + _disposables.Clear(); + } + + private void TrackForDisposal(IDisposable disposable) + { + _disposables.Add(disposable); + } + + public class TestConfig + { + public string Name { get; set; } = string.Empty; + public int Value { get; set; } + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "ErrorHandling")] + [Trait("Priority", "High")] + public void ConfigManager_RequiredRuleFails_ThrowsInvalidOperationException() + { + + var rules = new List + { + ConfigRule.Create( + FailableProviderOptions.QueryControlled("""{"Name": "Test"}"""), + FailableProviderQuery.Failure, // This will cause the provider to fail + typeof(TestConfig), + new(Required: true)) + }; + + var exception = Assert.Throws(() => + { + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + }); + + // Verify exception message contains provider information + Assert.Contains("Required rule failed", exception.Message); + Assert.Contains("FailableProvider", exception.Message); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "ErrorHandling")] + [Trait("Priority", "High")] + public void ConfigManager_OptionalRuleFails_ContinuesProcessing() + { + + var successData = """{"Name": "Success", "Value": 42}"""; + + var rules = new List + { + // First rule: Optional and will fail - should be skipped + ConfigRule.Create( + FailableProviderOptions.QueryControlled("""{"Name": "Fail"}"""), + FailableProviderQuery.Failure, + typeof(TestConfig), + new(Required: false)), // Optional rule + + // Second rule: Will succeed - should be used + ConfigRule.Create( + FailableProviderOptions.QueryControlled(successData), + FailableProviderQuery.Success, + typeof(TestConfig), + new(Required: false)) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + var config = configManager.GetConfig(); + + + Assert.NotNull(config); + Assert.Equal("Success", config.Name); + Assert.Equal(42, config.Value); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "ErrorHandling")] + [Trait("Priority", "Medium")] + public void ConfigManager_RequiredSucceedsOptionalFails_UsesRequiredRule() + { + + var requiredData = """{"Name": "Required", "Value": 100}"""; + + var rules = new List + { + // First rule: Required and will succeed - should be used + ConfigRule.Create( + FailableProviderOptions.QueryControlled(requiredData), + FailableProviderQuery.Success, + typeof(TestConfig), + new(Required: true)), // Required rule + + // Second rule: Optional and will fail - should be skipped + ConfigRule.Create( + FailableProviderOptions.QueryControlled("""{"Name": "OptionalFail"}"""), + FailableProviderQuery.Failure, + typeof(TestConfig), + new(Required: false)) // Optional rule + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + var config = configManager.GetConfig(); + + + Assert.NotNull(config); + Assert.Equal("Required", config.Name); + Assert.Equal(100, config.Value); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "ErrorHandling")] + [Trait("Priority", "Medium")] + public void ConfigManager_MultipleOptionalRulesFail_SkipsAllAndContinues() + { + + var successData = """{"Name": "OnlySuccess", "Value": 999}"""; + + var rules = new List + { + // Multiple optional rules that will fail + ConfigRule.Create( + FailableProviderOptions.QueryControlled("""{"Name": "Fail1"}"""), + new(true, "First failure"), + typeof(TestConfig), + new(Required: false)), + + ConfigRule.Create( + FailableProviderOptions.QueryControlled("""{"Name": "Fail2"}"""), + new(true, "Second failure"), + typeof(TestConfig), + new(Required: false)), + + // One successful rule at the end + ConfigRule.Create( + FailableProviderOptions.QueryControlled(successData), + FailableProviderQuery.Success, + typeof(TestConfig), + new(Required: false)) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + var config = configManager.GetConfig(); + + + Assert.NotNull(config); + Assert.Equal("OnlySuccess", config.Name); + Assert.Equal(999, config.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "ErrorHandling")] + public async Task RuntimeRecompute_RequiredRuleFails_SubscriberNeverSeesPartialState() + { + var validJson = """{"Name": "Initial", "Value": 1}"""; + var invalidJson = "NOT VALID JSON {{{"; + + var subject = new BehaviorSubject(validJson); + TrackForDisposal(subject); + + var rules = new List + { + TestRules.ObservableString(subject, required: true) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(50)); + TrackForDisposal(configManager); + + var reactiveConfig = configManager.GetReactiveConfig(); + + // Collect all values the subscriber ever observes + var observedValues = new List(); + var subscription = reactiveConfig.Subscribe(v => observedValues.Add(v)); + TrackForDisposal(subscription); + + // Wait for initial emission + await ActiveWaitHelpers.WaitUntilAsync( + () => observedValues.Count > 0 && observedValues.Last().Name == "Initial", + description: "initial configuration to emit"); + + var initialConfig = reactiveConfig.CurrentValue; + Assert.NotNull(initialConfig); + Assert.Equal("Initial", initialConfig.Name); + Assert.Equal(1, initialConfig.Value); + + var countBeforeBadPush = observedValues.Count; + + // Push invalid JSON — this should cause the required rule to fail during recompute, + // triggering a rollback that preserves the last good configuration + subject.OnNext(invalidJson); + + // Wait for the recompute cycle to complete (debounce + processing) + await ActiveWaitHelpers.WaitUntilAsync( + () => true, + timeout: TimeSpan.FromMilliseconds(500), + description: "recompute cycle to settle after invalid JSON push"); + + // The reactive config should still hold the last known good value + var currentAfterFailure = reactiveConfig.CurrentValue; + Assert.NotNull(currentAfterFailure); + Assert.Equal("Initial", currentAfterFailure.Name); + Assert.Equal(1, currentAfterFailure.Value); + + // The subscriber should never have seen a partial or invalid state — + // every observed value should have a valid Name + Assert.All(observedValues, v => + { + Assert.NotNull(v); + Assert.Equal("Initial", v.Name); + }); + + // Push a valid update to prove the system is still functional after the failure + var recoveryJson = """{"Name": "Recovered", "Value": 99}"""; + subject.OnNext(recoveryJson); + + await ActiveWaitHelpers.WaitUntilAsync( + () => reactiveConfig.CurrentValue.Name == "Recovered", + description: "configuration to update to Recovered after recovery"); + + var recoveredConfig = reactiveConfig.CurrentValue; + Assert.Equal("Recovered", recoveredConfig.Name); + Assert.Equal(99, recoveredConfig.Value); + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerIsolationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerIsolationTests.cs index 0f95672..ef3bd0a 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerIsolationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerIsolationTests.cs @@ -1,704 +1,704 @@ -using System.Text.Json; -using Cocoar.Configuration.Core.Tests.TestUtilities; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Rules; -using Cocoar.Configuration.Core.Tests.Helpers; - -namespace Cocoar.Configuration.Core.Tests.Managers; - -/// -/// Bulletproof isolation tests for ConfigManager. -/// Tests the core orchestration, debounce logic, and recompute mechanisms using our proven bulletproof providers. -/// These tests validate that ConfigManager can reliably handle complex scenarios with multiple rapid changes. -/// -[Trait("Provider", "ConfigManager")] -public class ConfigManagerIsolationTests : IDisposable -{ - private readonly List _disposables = new(); - - public void Dispose() - { - foreach (var disposable in _disposables) - { - try - { - disposable.Dispose(); - } - catch - { - // Ignore disposal errors in tests - } - } - _disposables.Clear(); - } - - private void TrackForDisposal(IDisposable disposable) - { - _disposables.Add(disposable); - } - - // Helper to create static rules using fluent API - private static ConfigRule CreateStaticRule(T config) where T : class - { - var rulesBuilder = new RulesBuilder(); - return rulesBuilder.For().FromStaticJson(JsonSerializer.Serialize(config)).Required(); - } - - // Test configuration classes - public class DatabaseConfig - { - public string ConnectionString { get; set; } = ""; - public int Timeout { get; set; } - public bool EnableRetry { get; set; } - } - - public class ApiConfig - { - public string BaseUrl { get; set; } = ""; - public string ApiKey { get; set; } = ""; - public int MaxRetries { get; set; } - } - - #region Basic Functionality Tests - - [Fact] - [Trait("Type", "Unit")] - public void ConfigManager_Create_ShouldReturnInitializedManager() - { - var testConfig = new DatabaseConfig { ConnectionString = "test", Timeout = 30 }; - var rules = new List - { - CreateStaticRule(testConfig) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - - // Verify initialization by checking if configs are accessible - var config = configManager.GetConfig(); - Assert.NotNull(config); - Assert.Equal("test", config.ConnectionString); - Assert.Equal(30, config.Timeout); - } - - [Fact] - [Trait("Type", "Unit")] - public void ConfigManager_Create_CanBeCalledMultipleTimes_IndependentInstances() - { - var testConfig = new DatabaseConfig { ConnectionString = "test", Timeout = 30 }; - var rules = new List - { - CreateStaticRule(testConfig) - }; - - var configManager1 = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager1); - - var configManager2 = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager2); - - Assert.NotSame(configManager1, configManager2); - - // Both should have accessible config - var config1 = configManager1.GetConfig(); - var config2 = configManager2.GetConfig(); - Assert.NotNull(config1); - Assert.NotNull(config2); - Assert.Equal("test", config1.ConnectionString); - Assert.Equal("test", config2.ConnectionString); - } - - [Fact] - [Trait("Type", "Unit")] - public void GetConfig_WithValidConfiguration_ShouldReturnTypedObject() - { - var expectedConfig = new DatabaseConfig - { - ConnectionString = "Server=test;Database=app", - Timeout = 60, - EnableRetry = true - }; - - var rules = new List - { - CreateStaticRule(expectedConfig) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - - var result = configManager.GetConfig(); - - Assert.NotNull(result); - Assert.Equal(expectedConfig.ConnectionString, result.ConnectionString); - Assert.Equal(expectedConfig.Timeout, result.Timeout); - Assert.Equal(expectedConfig.EnableRetry, result.EnableRetry); - } - - [Fact] - [Trait("Type", "Unit")] - public void GetConfig_WithNonExistentType_ShouldThrow() - { - // With Master Backplane architecture, GetConfig throws when no rule is registered - var testConfig = new DatabaseConfig { ConnectionString = "test" }; - var rules = new List - { - CreateStaticRule(testConfig) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - - var exception = Assert.Throws(() => - configManager.GetConfig()); // Different type not configured - - Assert.Contains("ApiConfig", exception.Message); - Assert.Contains("No configuration rule is registered", exception.Message); - } - - [Fact] - [Trait("Type", "Unit")] - public void TryGetConfig_WithValidConfiguration_ShouldReturnTrueAndValue() - { - var expectedConfig = new DatabaseConfig { ConnectionString = "test", Timeout = 45 }; - var rules = new List - { - CreateStaticRule(expectedConfig) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - - var success = configManager.TryGetConfig(out var result); - - Assert.True(success); - Assert.NotNull(result); - Assert.Equal(expectedConfig.ConnectionString, result.ConnectionString); - Assert.Equal(expectedConfig.Timeout, result.Timeout); - } - - [Fact] - [Trait("Type", "Unit")] - public void TryGetConfig_WithNonExistentType_ShouldReturnFalseAndNull() - { - var testConfig = new DatabaseConfig(); - var rules = new List - { - CreateStaticRule(testConfig) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - - var success = configManager.TryGetConfig(out var result); - - Assert.False(success); - Assert.Null(result); - } - - [Fact] - [Trait("Type", "Unit")] - public void GetRequiredConfig_WithValidConfiguration_ShouldReturnValue() - { - var expectedConfig = new DatabaseConfig { ConnectionString = "required-test", Timeout = 90 }; - var rules = new List - { - CreateStaticRule(expectedConfig) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - - var result = configManager.GetRequiredConfig(); - - Assert.NotNull(result); - Assert.Equal(expectedConfig.ConnectionString, result.ConnectionString); - Assert.Equal(expectedConfig.Timeout, result.Timeout); - } - - [Fact] - [Trait("Type", "Unit")] - public void GetRequiredConfig_WithNonExistentType_ShouldThrowInvalidOperationException() - { - // GetRequiredConfig now delegates to GetConfig, which throws when no rule exists - var testConfig = new DatabaseConfig(); - var rules = new List - { - CreateStaticRule(testConfig) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - -#pragma warning disable CS0618 // Type or member is obsolete - var exception = Assert.Throws(() => - configManager.GetRequiredConfig()); -#pragma warning restore CS0618 - - Assert.Contains("ApiConfig", exception.Message); - Assert.Contains("No configuration rule is registered", exception.Message); - } - - #endregion - - #region Multiple Rules and Priority Tests - - [Fact] - [Trait("Type", "Unit")] - public void ConfigManager_WithMultipleRules_ShouldRespectRuleOrder() - { - var rules = new List - { - // First rule - lower priority - CreateStaticRule(new DatabaseConfig - { - ConnectionString = "first-rule", - Timeout = 30 - }), - // Second rule - higher priority (should win) - CreateStaticRule(new DatabaseConfig - { - ConnectionString = "second-rule", - Timeout = 60 - }) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - - var config = configManager.GetConfig(); - - Assert.NotNull(config); - Assert.Equal("second-rule", config.ConnectionString); // Second rule should win - Assert.Equal(60, config.Timeout); - } - - [Fact] - [Trait("Type", "Unit")] - public void ConfigManager_WithMultipleConfigTypes_ShouldHandleBothCorrectly() - { - var dbConfig = new DatabaseConfig { ConnectionString = "db-connection", Timeout = 45 }; - var apiConfig = new ApiConfig { BaseUrl = "https://api.test", ApiKey = "secret", MaxRetries = 3 }; - - var rules = new List - { - CreateStaticRule(dbConfig), - CreateStaticRule(apiConfig) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - - var dbResult = configManager.GetConfig(); - var apiResult = configManager.GetConfig(); - - Assert.NotNull(dbResult); - Assert.Equal(dbConfig.ConnectionString, dbResult.ConnectionString); - Assert.Equal(dbConfig.Timeout, dbResult.Timeout); - - Assert.NotNull(apiResult); - Assert.Equal(apiConfig.BaseUrl, apiResult.BaseUrl); - Assert.Equal(apiConfig.ApiKey, apiResult.ApiKey); - Assert.Equal(apiConfig.MaxRetries, apiResult.MaxRetries); - } - - #endregion - - #region Debounce and Recompute Logic Tests - - [Fact] - [Trait("Type", "Unit")] - public async Task ConfigManager_WithObservableProvider_ShouldHandleRecomputation() - { - - var initialConfig = new DatabaseConfig { ConnectionString = "initial", Timeout = 30 }; - var updatedConfig = new DatabaseConfig { ConnectionString = "updated", Timeout = 60 }; - - var observable = new System.Reactive.Subjects.BehaviorSubject(initialConfig); - var rules = new List - { - // Create rule using ObservableProvider with the actual config type - ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(observable), - _ => ObservableProviderQuery.Default, - typeof(DatabaseConfig), - new()) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(50)); - TrackForDisposal(configManager); - TrackForDisposal(observable); - - // Verify initial state - var initialResult = configManager.GetConfig(); - Assert.NotNull(initialResult); - Assert.Equal("initial", initialResult.ConnectionString); - - - observable.OnNext(updatedConfig); - - // Wait for debounce and recomputation using active wait pattern - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config?.ConnectionString == "updated"; - }, - timeout: TimeSpan.FromSeconds(2)); - - - var finalResult = configManager.GetConfig(); - Assert.NotNull(finalResult); - Assert.Equal("updated", finalResult.ConnectionString); - Assert.Equal(60, finalResult.Timeout); - } - - [Fact] - [Trait("Type", "Performance")] - public async Task ConfigManager_DebounceLogic_ShouldCoalesceRapidChanges() - { - - var configs = new[] - { - new DatabaseConfig { ConnectionString = "change1", Timeout = 10 }, - new DatabaseConfig { ConnectionString = "change2", Timeout = 20 }, - new DatabaseConfig { ConnectionString = "change3", Timeout = 30 }, - new DatabaseConfig { ConnectionString = "final", Timeout = 40 } - }; - - var observable = new System.Reactive.Subjects.BehaviorSubject(configs[0]); - var rules = new List - { - ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(observable), - _ => ObservableProviderQuery.Default, - typeof(DatabaseConfig), - new()) - }; - - // Use longer debounce to test coalescing - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(100)); - TrackForDisposal(configManager); - TrackForDisposal(observable); - - - for (var i = 1; i < configs.Length; i++) - { - observable.OnNext(configs[i]); - await Task.Delay(10); // Small delay but less than debounce period - } - - // Wait for final recomputation using active wait - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config?.ConnectionString == "final"; - }, - timeout: TimeSpan.FromSeconds(2)); - - - var result = configManager.GetConfig(); - Assert.NotNull(result); - Assert.Equal("final", result.ConnectionString); - Assert.Equal(40, result.Timeout); - } - - [Fact] - [Trait("Type", "Unit")] - public async Task ConfigManager_CancellationLogic_ShouldCancelPreviousRecompute() - { - - var initialConfig = new DatabaseConfig { ConnectionString = "initial", Timeout = 10 }; - var observable = new System.Reactive.Subjects.BehaviorSubject(initialConfig); - - var rules = new List - { - ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(observable), - _ => ObservableProviderQuery.Default, - typeof(DatabaseConfig), - new()) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(200)); - TrackForDisposal(configManager); - TrackForDisposal(observable); - - - observable.OnNext(new() { ConnectionString = "change1", Timeout = 20 }); - - // Immediately trigger another change (should cancel the first recompute) - observable.OnNext(new() { ConnectionString = "change2", Timeout = 30 }); - - // Wait for final result using active wait - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config?.ConnectionString == "change2"; - }, - timeout: TimeSpan.FromSeconds(2)); - - - var result = configManager.GetConfig(); - Assert.NotNull(result); - Assert.Equal("change2", result.ConnectionString); - Assert.Equal(30, result.Timeout); - } - - [Fact] - [Trait("Type", "Unit")] - public async Task ConfigManager_MultipleRulesRecompute_ShouldRespectRuleOrder() - { - - var rule1Config = new DatabaseConfig { ConnectionString = "rule1-initial", Timeout = 10 }; - var rule2Config = new DatabaseConfig { ConnectionString = "rule2-initial", Timeout = 20 }; - - var observable1 = new System.Reactive.Subjects.BehaviorSubject(rule1Config); - var observable2 = new System.Reactive.Subjects.BehaviorSubject(rule2Config); - - var rules = new List - { - // First rule - lower priority - ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(observable1), - _ => ObservableProviderQuery.Default, - typeof(DatabaseConfig), - new()), - // Second rule - higher priority (should win) - ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(observable2), - _ => ObservableProviderQuery.Default, - typeof(DatabaseConfig), - new()) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(50)); - TrackForDisposal(configManager); - TrackForDisposal(observable1); - TrackForDisposal(observable2); - - // Initial state - rule2 should win (based on "last wins" rule order) - var initialResult = configManager.GetConfig(); - Assert.NotNull(initialResult); - Assert.Equal("rule2-initial", initialResult.ConnectionString); - - - observable1.OnNext(new() { ConnectionString = "rule1-updated", Timeout = 15 }); - - // Allow time for recomputation - await Task.Delay(50); - - var afterRule1Update = configManager.GetConfig(); - Assert.NotNull(afterRule1Update); - Assert.Equal("rule2-initial", afterRule1Update.ConnectionString); // rule2 still wins - - - observable2.OnNext(new() { ConnectionString = "rule2-updated", Timeout = 25 }); - - // Wait for rule2 update to complete - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config?.ConnectionString == "rule2-updated"; - }, - timeout: TimeSpan.FromSeconds(2)); - - - var finalResult = configManager.GetConfig(); - Assert.NotNull(finalResult); - Assert.Equal("rule2-updated", finalResult.ConnectionString); - Assert.Equal(25, finalResult.Timeout); - } - - #endregion - - #region Stress Tests for Debounce/Cancel Logic - - [Fact] - [Trait("Type", "Stress")] - public async Task ConfigManager_MassiveConcurrentRecomputes_ShouldHandleDebounceCorrectly() - { - - var observable = new System.Reactive.Subjects.BehaviorSubject( - new() { ConnectionString = "initial", Timeout = 0 }); - - var rules = new List - { - ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(observable), - _ => ObservableProviderQuery.Default, - typeof(DatabaseConfig), - new()) - }; - - // Short debounce to test rapid cancellation - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(30)); - TrackForDisposal(configManager); - TrackForDisposal(observable); - - - const int totalChanges = 100; // 100 rapid changes to stress-test debounce/cancel - var changeTask = Task.Run(async () => - { - for (var i = 1; i <= totalChanges; i++) - { - observable.OnNext(new() - { - ConnectionString = $"change-{i}", - Timeout = i - }); - await Task.Delay(2); // Very rapid - 2ms between changes - } - }); - - await changeTask; - - // Wait for debouncing to complete - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config?.ConnectionString == $"change-{totalChanges}"; - }, - timeout: TimeSpan.FromSeconds(3)); - - - var finalConfig = configManager.GetConfig(); - Assert.NotNull(finalConfig); - Assert.Equal($"change-{totalChanges}", finalConfig.ConnectionString); - Assert.Equal(totalChanges, finalConfig.Timeout); - } - - [Fact] - [Trait("Type", "Stress")] - public async Task ConfigManager_RapidDebounceStorm_ShouldCoalesceCorrectly() - { - - var observable = new System.Reactive.Subjects.BehaviorSubject( - new() { ConnectionString = "initial", Timeout = 0 }); - - var rules = new List - { - ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(observable), - _ => ObservableProviderQuery.Default, - typeof(DatabaseConfig), - new()) - }; - - // Longer debounce to test massive coalescing - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(200)); - TrackForDisposal(configManager); - TrackForDisposal(observable); - - - const int totalChanges = 200; - var fireTask = Task.Run(async () => - { - for (var i = 1; i <= totalChanges; i++) - { - observable.OnNext(new() - { - ConnectionString = $"change-{i}", - Timeout = i - }); - await Task.Delay(1); // 1ms between changes = 200ms total (within debounce window) - } - }); - - await fireTask; - - // Wait for final debounced result - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config?.ConnectionString == $"change-{totalChanges}"; - }, - timeout: TimeSpan.FromSeconds(3)); - - - var result = configManager.GetConfig(); - Assert.NotNull(result); - Assert.Equal($"change-{totalChanges}", result.ConnectionString); - Assert.Equal(totalChanges, result.Timeout); - } - - [Fact] - [Trait("Type", "Stress")] - public async Task ConfigManager_ConcurrentMultipleRules_ShouldMaintainRuleOrder() - { - - var observables = new List>(); - var rules = new List(); - - const int numRules = 20; // 20 competing rules - - for (var i = 0; i < numRules; i++) - { - var initialConfig = new DatabaseConfig - { - ConnectionString = $"rule-{i}-initial", - Timeout = i - }; - var observable = new System.Reactive.Subjects.BehaviorSubject(initialConfig); - observables.Add(observable); - - rules.Add(ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(observable), - _ => ObservableProviderQuery.Default, - typeof(DatabaseConfig), - new())); - } - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(50)); - TrackForDisposal(configManager); - foreach (var obs in observables) - { - TrackForDisposal(obs); - } - - // Initial state - last rule should win - var initialResult = configManager.GetConfig(); - await ActiveWaitHelpers.WaitUntilAsync( - () => configManager.GetConfig()?.ConnectionString == $"rule-{numRules - 1}-initial", - timeout: TimeSpan.FromSeconds(3), - description: "initial last rule resolution"); - initialResult = configManager.GetConfig(); - Assert.NotNull(initialResult); - Assert.Equal($"rule-{numRules - 1}-initial", initialResult.ConnectionString); - - - var updateTasks = observables.Select((obs, index) => - Task.Run(async () => - { - for (var change = 1; change <= 10; change++) - { - obs.OnNext(new() - { - ConnectionString = $"rule-{index}-change-{change}", - Timeout = index * 100 + change - }); - await Task.Delay(10); // Concurrent but spaced updates - } - }) - ).ToArray(); - - await Task.WhenAll(updateTasks); - - // Wait for stabilization - last rule should still win - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config?.ConnectionString?.StartsWith($"rule-{numRules - 1}-change-10") == true; - }, - timeout: TimeSpan.FromSeconds(8), - description: "final last rule winning after all 10 changes" ); - - - var finalConfig = configManager.GetConfig(); - Assert.NotNull(finalConfig); - Assert.StartsWith($"rule-{numRules - 1}-change-", finalConfig.ConnectionString); - Assert.True(finalConfig.Timeout >= (numRules - 1) * 100, $"Expected timeout >= {(numRules - 1) * 100}, got {finalConfig.Timeout}"); - } - - #endregion -} +using System.Text.Json; +using Cocoar.Configuration.Core.Tests.TestUtilities; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Core.Tests.Helpers; + +namespace Cocoar.Configuration.Core.Tests.Managers; + +/// +/// Bulletproof isolation tests for ConfigManager. +/// Tests the core orchestration, debounce logic, and recompute mechanisms using our proven bulletproof providers. +/// These tests validate that ConfigManager can reliably handle complex scenarios with multiple rapid changes. +/// +[Trait("Provider", "ConfigManager")] +public class ConfigManagerIsolationTests : IDisposable +{ + private readonly List _disposables = new(); + + public void Dispose() + { + foreach (var disposable in _disposables) + { + try + { + disposable.Dispose(); + } + catch + { + // Ignore disposal errors in tests + } + } + _disposables.Clear(); + } + + private void TrackForDisposal(IDisposable disposable) + { + _disposables.Add(disposable); + } + + // Helper to create static rules using fluent API + private static ConfigRule CreateStaticRule(T config) where T : class + { + var rulesBuilder = new RulesBuilder(); + return rulesBuilder.For().FromStaticJson(JsonSerializer.Serialize(config)).Required(); + } + + // Test configuration classes + public class DatabaseConfig + { + public string ConnectionString { get; set; } = ""; + public int Timeout { get; set; } + public bool EnableRetry { get; set; } + } + + public class ApiConfig + { + public string BaseUrl { get; set; } = ""; + public string ApiKey { get; set; } = ""; + public int MaxRetries { get; set; } + } + + #region Basic Functionality Tests + + [Fact] + [Trait("Type", "Unit")] + public void ConfigManager_Create_ShouldReturnInitializedManager() + { + var testConfig = new DatabaseConfig { ConnectionString = "test", Timeout = 30 }; + var rules = new List + { + CreateStaticRule(testConfig) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + + // Verify initialization by checking if configs are accessible + var config = configManager.GetConfig(); + Assert.NotNull(config); + Assert.Equal("test", config.ConnectionString); + Assert.Equal(30, config.Timeout); + } + + [Fact] + [Trait("Type", "Unit")] + public void ConfigManager_Create_CanBeCalledMultipleTimes_IndependentInstances() + { + var testConfig = new DatabaseConfig { ConnectionString = "test", Timeout = 30 }; + var rules = new List + { + CreateStaticRule(testConfig) + }; + + var configManager1 = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager1); + + var configManager2 = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager2); + + Assert.NotSame(configManager1, configManager2); + + // Both should have accessible config + var config1 = configManager1.GetConfig(); + var config2 = configManager2.GetConfig(); + Assert.NotNull(config1); + Assert.NotNull(config2); + Assert.Equal("test", config1.ConnectionString); + Assert.Equal("test", config2.ConnectionString); + } + + [Fact] + [Trait("Type", "Unit")] + public void GetConfig_WithValidConfiguration_ShouldReturnTypedObject() + { + var expectedConfig = new DatabaseConfig + { + ConnectionString = "Server=test;Database=app", + Timeout = 60, + EnableRetry = true + }; + + var rules = new List + { + CreateStaticRule(expectedConfig) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + + var result = configManager.GetConfig(); + + Assert.NotNull(result); + Assert.Equal(expectedConfig.ConnectionString, result.ConnectionString); + Assert.Equal(expectedConfig.Timeout, result.Timeout); + Assert.Equal(expectedConfig.EnableRetry, result.EnableRetry); + } + + [Fact] + [Trait("Type", "Unit")] + public void GetConfig_WithNonExistentType_ShouldThrow() + { + // With Master Backplane architecture, GetConfig throws when no rule is registered + var testConfig = new DatabaseConfig { ConnectionString = "test" }; + var rules = new List + { + CreateStaticRule(testConfig) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + + var exception = Assert.Throws(() => + configManager.GetConfig()); // Different type not configured + + Assert.Contains("ApiConfig", exception.Message); + Assert.Contains("No configuration rule is registered", exception.Message); + } + + [Fact] + [Trait("Type", "Unit")] + public void TryGetConfig_WithValidConfiguration_ShouldReturnTrueAndValue() + { + var expectedConfig = new DatabaseConfig { ConnectionString = "test", Timeout = 45 }; + var rules = new List + { + CreateStaticRule(expectedConfig) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + + var success = configManager.TryGetConfig(out var result); + + Assert.True(success); + Assert.NotNull(result); + Assert.Equal(expectedConfig.ConnectionString, result.ConnectionString); + Assert.Equal(expectedConfig.Timeout, result.Timeout); + } + + [Fact] + [Trait("Type", "Unit")] + public void TryGetConfig_WithNonExistentType_ShouldReturnFalseAndNull() + { + var testConfig = new DatabaseConfig(); + var rules = new List + { + CreateStaticRule(testConfig) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + + var success = configManager.TryGetConfig(out var result); + + Assert.False(success); + Assert.Null(result); + } + + [Fact] + [Trait("Type", "Unit")] + public void GetRequiredConfig_WithValidConfiguration_ShouldReturnValue() + { + var expectedConfig = new DatabaseConfig { ConnectionString = "required-test", Timeout = 90 }; + var rules = new List + { + CreateStaticRule(expectedConfig) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + + var result = configManager.GetRequiredConfig(); + + Assert.NotNull(result); + Assert.Equal(expectedConfig.ConnectionString, result.ConnectionString); + Assert.Equal(expectedConfig.Timeout, result.Timeout); + } + + [Fact] + [Trait("Type", "Unit")] + public void GetRequiredConfig_WithNonExistentType_ShouldThrowInvalidOperationException() + { + // GetRequiredConfig now delegates to GetConfig, which throws when no rule exists + var testConfig = new DatabaseConfig(); + var rules = new List + { + CreateStaticRule(testConfig) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + +#pragma warning disable CS0618 // Type or member is obsolete + var exception = Assert.Throws(() => + configManager.GetRequiredConfig()); +#pragma warning restore CS0618 + + Assert.Contains("ApiConfig", exception.Message); + Assert.Contains("No configuration rule is registered", exception.Message); + } + + #endregion + + #region Multiple Rules and Priority Tests + + [Fact] + [Trait("Type", "Unit")] + public void ConfigManager_WithMultipleRules_ShouldRespectRuleOrder() + { + var rules = new List + { + // First rule - lower priority + CreateStaticRule(new DatabaseConfig + { + ConnectionString = "first-rule", + Timeout = 30 + }), + // Second rule - higher priority (should win) + CreateStaticRule(new DatabaseConfig + { + ConnectionString = "second-rule", + Timeout = 60 + }) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + + var config = configManager.GetConfig(); + + Assert.NotNull(config); + Assert.Equal("second-rule", config.ConnectionString); // Second rule should win + Assert.Equal(60, config.Timeout); + } + + [Fact] + [Trait("Type", "Unit")] + public void ConfigManager_WithMultipleConfigTypes_ShouldHandleBothCorrectly() + { + var dbConfig = new DatabaseConfig { ConnectionString = "db-connection", Timeout = 45 }; + var apiConfig = new ApiConfig { BaseUrl = "https://api.test", ApiKey = "secret", MaxRetries = 3 }; + + var rules = new List + { + CreateStaticRule(dbConfig), + CreateStaticRule(apiConfig) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + + var dbResult = configManager.GetConfig(); + var apiResult = configManager.GetConfig(); + + Assert.NotNull(dbResult); + Assert.Equal(dbConfig.ConnectionString, dbResult.ConnectionString); + Assert.Equal(dbConfig.Timeout, dbResult.Timeout); + + Assert.NotNull(apiResult); + Assert.Equal(apiConfig.BaseUrl, apiResult.BaseUrl); + Assert.Equal(apiConfig.ApiKey, apiResult.ApiKey); + Assert.Equal(apiConfig.MaxRetries, apiResult.MaxRetries); + } + + #endregion + + #region Debounce and Recompute Logic Tests + + [Fact] + [Trait("Type", "Unit")] + public async Task ConfigManager_WithObservableProvider_ShouldHandleRecomputation() + { + + var initialConfig = new DatabaseConfig { ConnectionString = "initial", Timeout = 30 }; + var updatedConfig = new DatabaseConfig { ConnectionString = "updated", Timeout = 60 }; + + var observable = new System.Reactive.Subjects.BehaviorSubject(initialConfig); + var rules = new List + { + // Create rule using ObservableProvider with the actual config type + ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(observable), + _ => ObservableProviderQuery.Default, + typeof(DatabaseConfig), + new()) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(50)); + TrackForDisposal(configManager); + TrackForDisposal(observable); + + // Verify initial state + var initialResult = configManager.GetConfig(); + Assert.NotNull(initialResult); + Assert.Equal("initial", initialResult.ConnectionString); + + + observable.OnNext(updatedConfig); + + // Wait for debounce and recomputation using active wait pattern + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config?.ConnectionString == "updated"; + }, + timeout: TimeSpan.FromSeconds(2)); + + + var finalResult = configManager.GetConfig(); + Assert.NotNull(finalResult); + Assert.Equal("updated", finalResult.ConnectionString); + Assert.Equal(60, finalResult.Timeout); + } + + [Fact] + [Trait("Type", "Performance")] + public async Task ConfigManager_DebounceLogic_ShouldCoalesceRapidChanges() + { + + var configs = new[] + { + new DatabaseConfig { ConnectionString = "change1", Timeout = 10 }, + new DatabaseConfig { ConnectionString = "change2", Timeout = 20 }, + new DatabaseConfig { ConnectionString = "change3", Timeout = 30 }, + new DatabaseConfig { ConnectionString = "final", Timeout = 40 } + }; + + var observable = new System.Reactive.Subjects.BehaviorSubject(configs[0]); + var rules = new List + { + ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(observable), + _ => ObservableProviderQuery.Default, + typeof(DatabaseConfig), + new()) + }; + + // Use longer debounce to test coalescing + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(100)); + TrackForDisposal(configManager); + TrackForDisposal(observable); + + + for (var i = 1; i < configs.Length; i++) + { + observable.OnNext(configs[i]); + await Task.Delay(10); // Small delay but less than debounce period + } + + // Wait for final recomputation using active wait + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config?.ConnectionString == "final"; + }, + timeout: TimeSpan.FromSeconds(2)); + + + var result = configManager.GetConfig(); + Assert.NotNull(result); + Assert.Equal("final", result.ConnectionString); + Assert.Equal(40, result.Timeout); + } + + [Fact] + [Trait("Type", "Unit")] + public async Task ConfigManager_CancellationLogic_ShouldCancelPreviousRecompute() + { + + var initialConfig = new DatabaseConfig { ConnectionString = "initial", Timeout = 10 }; + var observable = new System.Reactive.Subjects.BehaviorSubject(initialConfig); + + var rules = new List + { + ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(observable), + _ => ObservableProviderQuery.Default, + typeof(DatabaseConfig), + new()) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(200)); + TrackForDisposal(configManager); + TrackForDisposal(observable); + + + observable.OnNext(new() { ConnectionString = "change1", Timeout = 20 }); + + // Immediately trigger another change (should cancel the first recompute) + observable.OnNext(new() { ConnectionString = "change2", Timeout = 30 }); + + // Wait for final result using active wait + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config?.ConnectionString == "change2"; + }, + timeout: TimeSpan.FromSeconds(2)); + + + var result = configManager.GetConfig(); + Assert.NotNull(result); + Assert.Equal("change2", result.ConnectionString); + Assert.Equal(30, result.Timeout); + } + + [Fact] + [Trait("Type", "Unit")] + public async Task ConfigManager_MultipleRulesRecompute_ShouldRespectRuleOrder() + { + + var rule1Config = new DatabaseConfig { ConnectionString = "rule1-initial", Timeout = 10 }; + var rule2Config = new DatabaseConfig { ConnectionString = "rule2-initial", Timeout = 20 }; + + var observable1 = new System.Reactive.Subjects.BehaviorSubject(rule1Config); + var observable2 = new System.Reactive.Subjects.BehaviorSubject(rule2Config); + + var rules = new List + { + // First rule - lower priority + ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(observable1), + _ => ObservableProviderQuery.Default, + typeof(DatabaseConfig), + new()), + // Second rule - higher priority (should win) + ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(observable2), + _ => ObservableProviderQuery.Default, + typeof(DatabaseConfig), + new()) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(50)); + TrackForDisposal(configManager); + TrackForDisposal(observable1); + TrackForDisposal(observable2); + + // Initial state - rule2 should win (based on "last wins" rule order) + var initialResult = configManager.GetConfig(); + Assert.NotNull(initialResult); + Assert.Equal("rule2-initial", initialResult.ConnectionString); + + + observable1.OnNext(new() { ConnectionString = "rule1-updated", Timeout = 15 }); + + // Allow time for recomputation + await Task.Delay(50); + + var afterRule1Update = configManager.GetConfig(); + Assert.NotNull(afterRule1Update); + Assert.Equal("rule2-initial", afterRule1Update.ConnectionString); // rule2 still wins + + + observable2.OnNext(new() { ConnectionString = "rule2-updated", Timeout = 25 }); + + // Wait for rule2 update to complete + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config?.ConnectionString == "rule2-updated"; + }, + timeout: TimeSpan.FromSeconds(2)); + + + var finalResult = configManager.GetConfig(); + Assert.NotNull(finalResult); + Assert.Equal("rule2-updated", finalResult.ConnectionString); + Assert.Equal(25, finalResult.Timeout); + } + + #endregion + + #region Stress Tests for Debounce/Cancel Logic + + [Fact] + [Trait("Type", "Stress")] + public async Task ConfigManager_MassiveConcurrentRecomputes_ShouldHandleDebounceCorrectly() + { + + var observable = new System.Reactive.Subjects.BehaviorSubject( + new() { ConnectionString = "initial", Timeout = 0 }); + + var rules = new List + { + ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(observable), + _ => ObservableProviderQuery.Default, + typeof(DatabaseConfig), + new()) + }; + + // Short debounce to test rapid cancellation + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(30)); + TrackForDisposal(configManager); + TrackForDisposal(observable); + + + const int totalChanges = 100; // 100 rapid changes to stress-test debounce/cancel + var changeTask = Task.Run(async () => + { + for (var i = 1; i <= totalChanges; i++) + { + observable.OnNext(new() + { + ConnectionString = $"change-{i}", + Timeout = i + }); + await Task.Delay(2); // Very rapid - 2ms between changes + } + }); + + await changeTask; + + // Wait for debouncing to complete + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config?.ConnectionString == $"change-{totalChanges}"; + }, + timeout: TimeSpan.FromSeconds(3)); + + + var finalConfig = configManager.GetConfig(); + Assert.NotNull(finalConfig); + Assert.Equal($"change-{totalChanges}", finalConfig.ConnectionString); + Assert.Equal(totalChanges, finalConfig.Timeout); + } + + [Fact] + [Trait("Type", "Stress")] + public async Task ConfigManager_RapidDebounceStorm_ShouldCoalesceCorrectly() + { + + var observable = new System.Reactive.Subjects.BehaviorSubject( + new() { ConnectionString = "initial", Timeout = 0 }); + + var rules = new List + { + ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(observable), + _ => ObservableProviderQuery.Default, + typeof(DatabaseConfig), + new()) + }; + + // Longer debounce to test massive coalescing + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(200)); + TrackForDisposal(configManager); + TrackForDisposal(observable); + + + const int totalChanges = 200; + var fireTask = Task.Run(async () => + { + for (var i = 1; i <= totalChanges; i++) + { + observable.OnNext(new() + { + ConnectionString = $"change-{i}", + Timeout = i + }); + await Task.Delay(1); // 1ms between changes = 200ms total (within debounce window) + } + }); + + await fireTask; + + // Wait for final debounced result + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config?.ConnectionString == $"change-{totalChanges}"; + }, + timeout: TimeSpan.FromSeconds(3)); + + + var result = configManager.GetConfig(); + Assert.NotNull(result); + Assert.Equal($"change-{totalChanges}", result.ConnectionString); + Assert.Equal(totalChanges, result.Timeout); + } + + [Fact] + [Trait("Type", "Stress")] + public async Task ConfigManager_ConcurrentMultipleRules_ShouldMaintainRuleOrder() + { + + var observables = new List>(); + var rules = new List(); + + const int numRules = 20; // 20 competing rules + + for (var i = 0; i < numRules; i++) + { + var initialConfig = new DatabaseConfig + { + ConnectionString = $"rule-{i}-initial", + Timeout = i + }; + var observable = new System.Reactive.Subjects.BehaviorSubject(initialConfig); + observables.Add(observable); + + rules.Add(ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(observable), + _ => ObservableProviderQuery.Default, + typeof(DatabaseConfig), + new())); + } + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(50)); + TrackForDisposal(configManager); + foreach (var obs in observables) + { + TrackForDisposal(obs); + } + + // Initial state - last rule should win + var initialResult = configManager.GetConfig(); + await ActiveWaitHelpers.WaitUntilAsync( + () => configManager.GetConfig()?.ConnectionString == $"rule-{numRules - 1}-initial", + timeout: TimeSpan.FromSeconds(3), + description: "initial last rule resolution"); + initialResult = configManager.GetConfig(); + Assert.NotNull(initialResult); + Assert.Equal($"rule-{numRules - 1}-initial", initialResult.ConnectionString); + + + var updateTasks = observables.Select((obs, index) => + Task.Run(async () => + { + for (var change = 1; change <= 10; change++) + { + obs.OnNext(new() + { + ConnectionString = $"rule-{index}-change-{change}", + Timeout = index * 100 + change + }); + await Task.Delay(10); // Concurrent but spaced updates + } + }) + ).ToArray(); + + await Task.WhenAll(updateTasks); + + // Wait for stabilization - last rule should still win + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config?.ConnectionString?.StartsWith($"rule-{numRules - 1}-change-10") == true; + }, + timeout: TimeSpan.FromSeconds(8), + description: "final last rule winning after all 10 changes" ); + + + var finalConfig = configManager.GetConfig(); + Assert.NotNull(finalConfig); + Assert.StartsWith($"rule-{numRules - 1}-change-", finalConfig.ConnectionString); + Assert.True(finalConfig.Timeout >= (numRules - 1) * 100, $"Expected timeout >= {(numRules - 1) * 100}, got {finalConfig.Timeout}"); + } + + #endregion +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerJsonCorruptionTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerJsonCorruptionTests.cs index a9c3292..a6a5abb 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerJsonCorruptionTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerJsonCorruptionTests.cs @@ -1,208 +1,208 @@ -using Microsoft.Extensions.Logging.Abstractions; -using System.Text.Json; -using System.Reactive.Linq; -using Cocoar.Configuration.Core.Tests.TestUtilities; -using Cocoar.Configuration.Providers.Abstractions; - -using Cocoar.Configuration.Core.Tests.Helpers; - -namespace Cocoar.Configuration.Core.Tests.Managers; -public class ConfigManagerJsonCorruptionTests : IDisposable -{ - private readonly List _disposables = new(); - - public void Dispose() - { - foreach (var disposable in _disposables) - { - try - { - disposable.Dispose(); - } - catch - { - // Ignore disposal errors in tests - } - } - _disposables.Clear(); - } - - private void TrackForDisposal(IDisposable disposable) - { - _disposables.Add(disposable); - } - - public class TestConfig - { - public string Name { get; set; } = string.Empty; - public int Value { get; set; } - } - - /// - /// Test provider that can return corrupted JSON to simulate file corruption scenarios. - /// - private class JsonCorruptionProvider : ConfigurationProvider - { - public JsonCorruptionProvider(JsonCorruptionProviderOptions options) : base(options) - { - } - - public override Task FetchConfigurationBytesAsync(JsonCorruptionProviderQuery query, CancellationToken ct = default) - { - if (query.ReturnCorruptJson) - { - // This will cause JsonDocument.Parse to throw JsonException - var corruptJson = ProviderOptions.CorruptJsonString; - var document = JsonDocument.Parse(corruptJson); // This will throw! - var _bytes = JsonSerializer.SerializeToUtf8Bytes(document.RootElement); - return Task.FromResult(_bytes); - } - - var bytes = JsonSerializer.SerializeToUtf8Bytes(ProviderOptions.ValidJsonData); - - return Task.FromResult(bytes); - } - - public override IObservable ChangesAsBytes(JsonCorruptionProviderQuery query) => Observable.Empty(); - } - - private class JsonCorruptionProviderOptions : IProviderConfiguration - { - public JsonElement ValidJsonData { get; } - public string CorruptJsonString { get; } - - public JsonCorruptionProviderOptions(string validJson, string corruptJson) - { - using var document = JsonDocument.Parse(validJson); - ValidJsonData = document.RootElement.Clone(); - CorruptJsonString = corruptJson; - } - - public string? GenerateProviderKey() => null; - } - - private class JsonCorruptionProviderQuery : IProviderQuery - { - public bool ReturnCorruptJson { get; } - - public JsonCorruptionProviderQuery(bool returnCorruptJson = false) - { - ReturnCorruptJson = returnCorruptJson; - } - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "ErrorHandling")] - [Trait("Priority", "High")] - public void ConfigManager_RequiredRuleWithCorruptJson_ThrowsJsonException() - { - - var corruptJsonString = """{"Name": "Test", "Value": invalid_number}"""; // Invalid JSON - var options = new JsonCorruptionProviderOptions( - validJson: """{"Name": "Good", "Value": 100}""", - corruptJson: corruptJsonString); - - var rules = new List - { - ConfigRule.Create( - options, - new(returnCorruptJson: true), // Will try to parse corrupt JSON - typeof(TestConfig), - new(Required: true)) - }; - - var exception = Assert.Throws(() => - { - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - }); - - // The inner exception should be a JSON-related exception from the malformed JSON - Assert.NotNull(exception.InnerException); - Assert.True(exception.InnerException is JsonException || - exception.InnerException.GetType().Name.Contains("JsonReader"), - $"Expected JSON-related exception, but got {exception.InnerException.GetType().Name}"); - Assert.Contains("JsonCorruptionProvider", exception.Message); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "ErrorHandling")] - [Trait("Priority", "High")] - public void ConfigManager_OptionalRuleWithCorruptJson_SkipsCorruptRuleAndContinues() - { - - var corruptJsonString = """{"Name": "Test", "broken": }"""; // Invalid JSON syntax - var validJsonString = """{"Name": "ValidRule", "Value": 200}"""; - - var rules = new List - { - // First rule: Optional with corrupt JSON - should be skipped - ConfigRule.Create( - new(validJsonString, corruptJsonString), - new JsonCorruptionProviderQuery(returnCorruptJson: true), - typeof(TestConfig), - new(Required: false)), // Optional - - // Second rule: Valid JSON - should be used - ConfigRule.Create( - new(validJsonString, corruptJsonString), - new JsonCorruptionProviderQuery(returnCorruptJson: false), // Good JSON - typeof(TestConfig), - new(Required: false)) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - var config = configManager.GetConfig(); - - - Assert.NotNull(config); - Assert.Equal("ValidRule", config.Name); - Assert.Equal(200, config.Value); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "ErrorHandling")] - [Trait("Priority", "Medium")] - public void ConfigManager_ProviderFailureVsJsonCorruption_BothHandledSimilarly() - { - - var rules = new List - { - // Rule 1: Provider-level failure (using FailableProvider) - ConfigRule.Create( - FailableProviderOptions.AlwaysFail("""{"Name": "WontWork", "Value": 1}"""), - FailableProviderQuery.Success, - typeof(TestConfig), - new(Required: false)), - - // Rule 2: JSON-level failure (using JsonCorruptionProvider) - ConfigRule.Create( - new( - validJson: """{"Name": "Good", "Value": 2}""", - corruptJson: """{"Name": "Bad", "Value": corrupt}"""), - new JsonCorruptionProviderQuery(returnCorruptJson: true), - typeof(TestConfig), - new(Required: false)), - - // Rule 3: Working rule - ConfigRule.Create( - FailableProviderOptions.AlwaysSucceed("""{"Name": "Success", "Value": 999}"""), - FailableProviderQuery.Success, - typeof(TestConfig), - new(Required: false)) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - var config = configManager.GetConfig(); - - - Assert.NotNull(config); - Assert.Equal("Success", config.Name); - Assert.Equal(999, config.Value); - } +using Microsoft.Extensions.Logging.Abstractions; +using System.Text.Json; +using System.Reactive.Linq; +using Cocoar.Configuration.Core.Tests.TestUtilities; +using Cocoar.Configuration.Providers.Abstractions; + +using Cocoar.Configuration.Core.Tests.Helpers; + +namespace Cocoar.Configuration.Core.Tests.Managers; +public class ConfigManagerJsonCorruptionTests : IDisposable +{ + private readonly List _disposables = new(); + + public void Dispose() + { + foreach (var disposable in _disposables) + { + try + { + disposable.Dispose(); + } + catch + { + // Ignore disposal errors in tests + } + } + _disposables.Clear(); + } + + private void TrackForDisposal(IDisposable disposable) + { + _disposables.Add(disposable); + } + + public class TestConfig + { + public string Name { get; set; } = string.Empty; + public int Value { get; set; } + } + + /// + /// Test provider that can return corrupted JSON to simulate file corruption scenarios. + /// + private class JsonCorruptionProvider : ConfigurationProvider + { + public JsonCorruptionProvider(JsonCorruptionProviderOptions options) : base(options) + { + } + + public override Task FetchConfigurationBytesAsync(JsonCorruptionProviderQuery query, CancellationToken ct = default) + { + if (query.ReturnCorruptJson) + { + // This will cause JsonDocument.Parse to throw JsonException + var corruptJson = ProviderOptions.CorruptJsonString; + var document = JsonDocument.Parse(corruptJson); // This will throw! + var _bytes = JsonSerializer.SerializeToUtf8Bytes(document.RootElement); + return Task.FromResult(_bytes); + } + + var bytes = JsonSerializer.SerializeToUtf8Bytes(ProviderOptions.ValidJsonData); + + return Task.FromResult(bytes); + } + + public override IObservable ChangesAsBytes(JsonCorruptionProviderQuery query) => Observable.Empty(); + } + + private class JsonCorruptionProviderOptions : IProviderConfiguration + { + public JsonElement ValidJsonData { get; } + public string CorruptJsonString { get; } + + public JsonCorruptionProviderOptions(string validJson, string corruptJson) + { + using var document = JsonDocument.Parse(validJson); + ValidJsonData = document.RootElement.Clone(); + CorruptJsonString = corruptJson; + } + + public string? GenerateProviderKey() => null; + } + + private class JsonCorruptionProviderQuery : IProviderQuery + { + public bool ReturnCorruptJson { get; } + + public JsonCorruptionProviderQuery(bool returnCorruptJson = false) + { + ReturnCorruptJson = returnCorruptJson; + } + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "ErrorHandling")] + [Trait("Priority", "High")] + public void ConfigManager_RequiredRuleWithCorruptJson_ThrowsJsonException() + { + + var corruptJsonString = """{"Name": "Test", "Value": invalid_number}"""; // Invalid JSON + var options = new JsonCorruptionProviderOptions( + validJson: """{"Name": "Good", "Value": 100}""", + corruptJson: corruptJsonString); + + var rules = new List + { + ConfigRule.Create( + options, + new(returnCorruptJson: true), // Will try to parse corrupt JSON + typeof(TestConfig), + new(Required: true)) + }; + + var exception = Assert.Throws(() => + { + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + }); + + // The inner exception should be a JSON-related exception from the malformed JSON + Assert.NotNull(exception.InnerException); + Assert.True(exception.InnerException is JsonException || + exception.InnerException.GetType().Name.Contains("JsonReader"), + $"Expected JSON-related exception, but got {exception.InnerException.GetType().Name}"); + Assert.Contains("JsonCorruptionProvider", exception.Message); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "ErrorHandling")] + [Trait("Priority", "High")] + public void ConfigManager_OptionalRuleWithCorruptJson_SkipsCorruptRuleAndContinues() + { + + var corruptJsonString = """{"Name": "Test", "broken": }"""; // Invalid JSON syntax + var validJsonString = """{"Name": "ValidRule", "Value": 200}"""; + + var rules = new List + { + // First rule: Optional with corrupt JSON - should be skipped + ConfigRule.Create( + new(validJsonString, corruptJsonString), + new JsonCorruptionProviderQuery(returnCorruptJson: true), + typeof(TestConfig), + new(Required: false)), // Optional + + // Second rule: Valid JSON - should be used + ConfigRule.Create( + new(validJsonString, corruptJsonString), + new JsonCorruptionProviderQuery(returnCorruptJson: false), // Good JSON + typeof(TestConfig), + new(Required: false)) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + var config = configManager.GetConfig(); + + + Assert.NotNull(config); + Assert.Equal("ValidRule", config.Name); + Assert.Equal(200, config.Value); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "ErrorHandling")] + [Trait("Priority", "Medium")] + public void ConfigManager_ProviderFailureVsJsonCorruption_BothHandledSimilarly() + { + + var rules = new List + { + // Rule 1: Provider-level failure (using FailableProvider) + ConfigRule.Create( + FailableProviderOptions.AlwaysFail("""{"Name": "WontWork", "Value": 1}"""), + FailableProviderQuery.Success, + typeof(TestConfig), + new(Required: false)), + + // Rule 2: JSON-level failure (using JsonCorruptionProvider) + ConfigRule.Create( + new( + validJson: """{"Name": "Good", "Value": 2}""", + corruptJson: """{"Name": "Bad", "Value": corrupt}"""), + new JsonCorruptionProviderQuery(returnCorruptJson: true), + typeof(TestConfig), + new(Required: false)), + + // Rule 3: Working rule + ConfigRule.Create( + FailableProviderOptions.AlwaysSucceed("""{"Name": "Success", "Value": 999}"""), + FailableProviderQuery.Success, + typeof(TestConfig), + new(Required: false)) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + var config = configManager.GetConfig(); + + + Assert.NotNull(config); + Assert.Equal("Success", config.Name); + Assert.Equal(999, config.Value); + } } \ No newline at end of file diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerRuntimeErrorTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerRuntimeErrorTests.cs index 830bfec..22367ad 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerRuntimeErrorTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerRuntimeErrorTests.cs @@ -1,156 +1,156 @@ -using Microsoft.Extensions.Logging.Abstractions; -using Cocoar.Configuration.Core.Tests.TestUtilities; - -using Cocoar.Configuration.Core.Tests.Helpers; - -namespace Cocoar.Configuration.Core.Tests.Managers; -public class ConfigManagerRuntimeErrorTests : IDisposable -{ - private readonly List _disposables = new(); - - public void Dispose() - { - foreach (var disposable in _disposables) - { - try - { - disposable.Dispose(); - } - catch - { - // Ignore disposal errors in tests - } - } - _disposables.Clear(); - } - - private void TrackForDisposal(IDisposable disposable) - { - _disposables.Add(disposable); - } - - public class TestConfig - { - public string Name { get; set; } = string.Empty; - public int Value { get; set; } - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "ErrorHandling")] - [Trait("Priority", "High")] - public void ConfigManager_RequiredRuleAlwaysFails_ThrowsDuringInitialization() - { - - var options = FailableProviderOptions.AlwaysFail( - json: """{"Name": "WontWork", "Value": 999}"""); - - var rules = new List - { - ConfigRule.Create( - options, - FailableProviderQuery.Success, - typeof(TestConfig), - new(Required: true)) // Required rule - }; - - - var exception = Assert.Throws(() => - { - ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - }); - - Assert.Contains("Required rule failed for FailableProvider", exception.Message); - // The inner exception should contain our specific failure message - Assert.NotNull(exception.InnerException); - Assert.Contains("FailableProvider configured to fail", exception.InnerException.Message); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "ErrorHandling")] - [Trait("Priority", "High")] - public void ConfigManager_RuntimeFailureSimulation_InitialSuccessThenFailure() - { - - var successOptions = FailableProviderOptions.AlwaysSucceed( - json: """{"Name": "InitialGood", "Value": 100}"""); - - var rules = new List - { - ConfigRule.Create( - successOptions, - FailableProviderQuery.Success, - typeof(TestConfig), - new(Required: true)) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); - TrackForDisposal(configManager); - var initialConfig = configManager.GetConfig(); - - - Assert.NotNull(initialConfig); - Assert.Equal("InitialGood", initialConfig.Name); - Assert.Equal(100, initialConfig.Value); - - - // (In real scenarios, this would happen during recompute due to file corruption) - var failingOptions = FailableProviderOptions.AlwaysFail( - json: """{"Name": "WontWork", "Value": 999}"""); - - var failingRules = new List - { - ConfigRule.Create( - failingOptions, - FailableProviderQuery.Success, - typeof(TestConfig), - new(Required: true)) - }; - - - var exception = Assert.Throws(() => - { - ConfigManager.Create(c => c.UseConfiguration(failingRules).UseLogger(NullLogger.Instance)); - }); - - - Assert.Contains("Required rule failed for FailableProvider", exception.Message); - Assert.NotNull(exception.InnerException); - Assert.Contains("FailableProvider configured to fail", exception.InnerException.Message); - - // The original configManager should still have the good config - var stillGoodConfig = configManager.GetConfig(); - Assert.NotNull(stillGoodConfig); - Assert.Equal("InitialGood", stillGoodConfig.Name); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ConfigManager")] - [Trait("Feature", "ErrorHandling")] - [Trait("Priority", "Medium")] - public async Task ConfigManager_FailAfterNCalls_BehavesAsExpected() - { - - var options = FailableProviderOptions.FailAfterNCalls( - json: """{"Name": "ProgressiveFailure", "Value": 200}""", - callsBeforeFailure: 1); - - // Create a provider instance directly to test the call counting - var provider = new FailableProvider(options); - var query = FailableProviderQuery.Success; - - - var firstResult = await provider.FetchConfigurationBytesAsync(query); - Assert.Equal("ProgressiveFailure", firstResult.ToJsonElement().GetProperty("Name").GetString()); - Assert.Equal(200, firstResult.ToJsonElement().GetProperty("Value").GetInt32()); - - - var exception = await Assert.ThrowsAsync(async () => - await provider.FetchConfigurationBytesAsync(query)); - - Assert.Contains("FailableProvider configured to fail", exception.Message); - Assert.Contains("AfterNCalls", exception.Message); - Assert.Contains("Call: 2", exception.Message); - } +using Microsoft.Extensions.Logging.Abstractions; +using Cocoar.Configuration.Core.Tests.TestUtilities; + +using Cocoar.Configuration.Core.Tests.Helpers; + +namespace Cocoar.Configuration.Core.Tests.Managers; +public class ConfigManagerRuntimeErrorTests : IDisposable +{ + private readonly List _disposables = new(); + + public void Dispose() + { + foreach (var disposable in _disposables) + { + try + { + disposable.Dispose(); + } + catch + { + // Ignore disposal errors in tests + } + } + _disposables.Clear(); + } + + private void TrackForDisposal(IDisposable disposable) + { + _disposables.Add(disposable); + } + + public class TestConfig + { + public string Name { get; set; } = string.Empty; + public int Value { get; set; } + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "ErrorHandling")] + [Trait("Priority", "High")] + public void ConfigManager_RequiredRuleAlwaysFails_ThrowsDuringInitialization() + { + + var options = FailableProviderOptions.AlwaysFail( + json: """{"Name": "WontWork", "Value": 999}"""); + + var rules = new List + { + ConfigRule.Create( + options, + FailableProviderQuery.Success, + typeof(TestConfig), + new(Required: true)) // Required rule + }; + + + var exception = Assert.Throws(() => + { + ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + }); + + Assert.Contains("Required rule failed for FailableProvider", exception.Message); + // The inner exception should contain our specific failure message + Assert.NotNull(exception.InnerException); + Assert.Contains("FailableProvider configured to fail", exception.InnerException.Message); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "ErrorHandling")] + [Trait("Priority", "High")] + public void ConfigManager_RuntimeFailureSimulation_InitialSuccessThenFailure() + { + + var successOptions = FailableProviderOptions.AlwaysSucceed( + json: """{"Name": "InitialGood", "Value": 100}"""); + + var rules = new List + { + ConfigRule.Create( + successOptions, + FailableProviderQuery.Success, + typeof(TestConfig), + new(Required: true)) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); + TrackForDisposal(configManager); + var initialConfig = configManager.GetConfig(); + + + Assert.NotNull(initialConfig); + Assert.Equal("InitialGood", initialConfig.Name); + Assert.Equal(100, initialConfig.Value); + + + // (In real scenarios, this would happen during recompute due to file corruption) + var failingOptions = FailableProviderOptions.AlwaysFail( + json: """{"Name": "WontWork", "Value": 999}"""); + + var failingRules = new List + { + ConfigRule.Create( + failingOptions, + FailableProviderQuery.Success, + typeof(TestConfig), + new(Required: true)) + }; + + + var exception = Assert.Throws(() => + { + ConfigManager.Create(c => c.UseConfiguration(failingRules).UseLogger(NullLogger.Instance)); + }); + + + Assert.Contains("Required rule failed for FailableProvider", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.Contains("FailableProvider configured to fail", exception.InnerException.Message); + + // The original configManager should still have the good config + var stillGoodConfig = configManager.GetConfig(); + Assert.NotNull(stillGoodConfig); + Assert.Equal("InitialGood", stillGoodConfig.Name); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ConfigManager")] + [Trait("Feature", "ErrorHandling")] + [Trait("Priority", "Medium")] + public async Task ConfigManager_FailAfterNCalls_BehavesAsExpected() + { + + var options = FailableProviderOptions.FailAfterNCalls( + json: """{"Name": "ProgressiveFailure", "Value": 200}""", + callsBeforeFailure: 1); + + // Create a provider instance directly to test the call counting + var provider = new FailableProvider(options); + var query = FailableProviderQuery.Success; + + + var firstResult = await provider.FetchConfigurationBytesAsync(query); + Assert.Equal("ProgressiveFailure", firstResult.ToJsonElement().GetProperty("Name").GetString()); + Assert.Equal(200, firstResult.ToJsonElement().GetProperty("Value").GetInt32()); + + + var exception = await Assert.ThrowsAsync(async () => + await provider.FetchConfigurationBytesAsync(query)); + + Assert.Contains("FailableProvider configured to fail", exception.Message); + Assert.Contains("AfterNCalls", exception.Message); + Assert.Contains("Call: 2", exception.Message); + } } \ No newline at end of file diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Providers/ObservableProviderIsolationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Providers/ObservableProviderIsolationTests.cs index ee2f91f..d1efeb7 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Providers/ObservableProviderIsolationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Providers/ObservableProviderIsolationTests.cs @@ -1,1255 +1,1255 @@ -using System.Reactive.Subjects; -using System.Text.Json; -using Cocoar.Configuration.Core.Tests.TestUtilities; -using System.Diagnostics; -using System.Collections.Concurrent; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; - -using Cocoar.Configuration.Core.Tests.Helpers; - -namespace Cocoar.Configuration.Core.Tests.Providers; - -/// -/// Isolation tests for ObservableProvider - testing only deterministic observable provider functionality -/// without any I/O dependencies. These tests validate reactive behavior, subscription management, -/// error handling, and performance characteristics using controlled observables. -/// -public class ObservableProviderIsolationTests -{ - #region Test Configuration Classes - - public record TestConfig(string Name, int Value, bool Enabled); - public record ComplexConfig(string Title, List Tags, DateTime Timestamp); - public record EmptyConfig(); - public record DynamicConfig(int Id, string Status, double Score); - - #endregion - - #region Basic Functionality Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task FetchConfigurationAsync_WithBehaviorSubject_ReturnsCurrentValue() - { - - var testData = new TestConfig("ObservableTest", 123, true); - var subject = new BehaviorSubject(testData); - - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - - var result = await provider.FetchConfigurationBytesAsync(query); - - - Assert.Equal("ObservableTest", result.ToJsonElement().GetProperty("Name").GetString()); - Assert.Equal(123, result.ToJsonElement().GetProperty("Value").GetInt32()); - Assert.True(result.ToJsonElement().GetProperty("Enabled").GetBoolean()); - - subject.Dispose(); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task FetchConfigurationAsync_WithComplexObject_SerializesCorrectly() - { - - var testData = new ComplexConfig( - "Complex Test Configuration", - new() { "tag1", "tag2", "production" }, - new(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc)); - - var subject = new BehaviorSubject(testData); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - - var result = await provider.FetchConfigurationBytesAsync(query); - - - Assert.Equal("Complex Test Configuration", result.ToJsonElement().GetProperty("Title").GetString()); - - var tags = result.ToJsonElement().GetProperty("Tags"); - Assert.Equal(3, tags.GetArrayLength()); - Assert.Equal("tag1", tags[0].GetString()); - Assert.Equal("tag2", tags[1].GetString()); - Assert.Equal("production", tags[2].GetString()); - - // Verify timestamp serialization - var timestamp = result.ToJsonElement().GetProperty("Timestamp").GetDateTime(); - Assert.Equal(2025, timestamp.Year); - Assert.Equal(1, timestamp.Month); - Assert.Equal(15, timestamp.Day); - - subject.Dispose(); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task FetchConfigurationAsync_WithEmptyConfig_HandlesCorrectly() - { - - var testData = new EmptyConfig(); - var subject = new BehaviorSubject(testData); - - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - - var result = await provider.FetchConfigurationBytesAsync(query); - - - Assert.Equal(JsonValueKind.Object, result.ToJsonElement().ValueKind); - // EmptyConfig should serialize to empty JSON object - - subject.Dispose(); - } - - #endregion - - #region Observable/Reactive Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_WhenSubjectEmits_PropagatesChanges() - { - - var initialData = new TestConfig("Initial", 1, false); - var subject = new BehaviorSubject(initialData); - - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - - - var updatedData1 = new TestConfig("Updated1", 2, true); - var updatedData2 = new TestConfig("Updated2", 3, false); - - subject.OnNext(updatedData1); - subject.OnNext(updatedData2); - - // Wait for emissions to propagate - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count >= 3, // Initial + 2 updates - timeout: TimeSpan.FromSeconds(5), - description: "observable change emissions"); - - - Assert.True(emissions.Count >= 3); - - // Verify initial emission (BehaviorSubject emits current value on subscription) - Assert.Equal("Initial", emissions[0].GetProperty("Name").GetString()); - Assert.Equal(1, emissions[0].GetProperty("Value").GetInt32()); - Assert.False(emissions[0].GetProperty("Enabled").GetBoolean()); - - // Verify first change - Assert.Equal("Updated1", emissions[1].GetProperty("Name").GetString()); - Assert.Equal(2, emissions[1].GetProperty("Value").GetInt32()); - Assert.True(emissions[1].GetProperty("Enabled").GetBoolean()); - - // Verify second change - Assert.Equal("Updated2", emissions[2].GetProperty("Name").GetString()); - Assert.Equal(3, emissions[2].GetProperty("Value").GetInt32()); - Assert.False(emissions[2].GetProperty("Enabled").GetBoolean()); - - subscription.Dispose(); - subject.Dispose(); - } - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_WithRapidEmissions_HandlesAllChanges() - { - - var subject = new BehaviorSubject(new("Initial", 0, false)); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - - - const int changeCount = 50; - for (var i = 1; i <= changeCount; i++) - { - var data = new TestConfig($"Change{i}", i, i % 2 == 0); - subject.OnNext(data); - } - - // Wait for all emissions using active waiting - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count >= changeCount + 1, // +1 for initial value from BehaviorSubject - timeout: TimeSpan.FromSeconds(10), - description: "all rapid emissions"); - - - Assert.Equal(changeCount + 1, emissions.Count); - - // Verify initial emission - Assert.Equal("Initial", emissions[0].GetProperty("Name").GetString()); - Assert.Equal(0, emissions[0].GetProperty("Value").GetInt32()); - - // Verify a few key emissions (offset by 1 due to initial emission) - Assert.Equal("Change1", emissions[1].GetProperty("Name").GetString()); - Assert.Equal(1, emissions[1].GetProperty("Value").GetInt32()); - - Assert.Equal("Change25", emissions[25].GetProperty("Name").GetString()); // 25 + 1 offset - Assert.Equal(25, emissions[25].GetProperty("Value").GetInt32()); - - Assert.Equal("Change50", emissions[50].GetProperty("Name").GetString()); // 50 + 1 offset - Assert.Equal(50, emissions[50].GetProperty("Value").GetInt32()); - - subscription.Dispose(); - subject.Dispose(); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_MultipleSubscriptions_AllReceiveEmissions() - { - - var subject = new BehaviorSubject(new("Initial", 0, false)); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - const int subscriberCount = 5; - var allEmissions = new List[subscriberCount]; - var subscriptions = new IDisposable[subscriberCount]; - - // Create multiple subscriptions - for (var i = 0; i < subscriberCount; i++) - { - allEmissions[i] = new(); - var emissions = allEmissions[i]; // Capture for closure - subscriptions[i] = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - } - - - subject.OnNext(new("First", 1, true)); - subject.OnNext(new("Second", 2, false)); - subject.OnNext(new("Third", 3, true)); - - // Wait for all subscriptions to receive emissions - await ActiveWaitHelpers.WaitUntilAsync( - () => allEmissions.All(list => list.Count >= 4), // Initial + 3 updates - timeout: TimeSpan.FromSeconds(10), - description: "all subscribers to receive emissions"); - - - for (var i = 0; i < subscriberCount; i++) - { - Assert.Equal(4, allEmissions[i].Count); // Initial + 3 updates - - Assert.Equal("Initial", allEmissions[i][0].GetProperty("Name").GetString()); - Assert.Equal("First", allEmissions[i][1].GetProperty("Name").GetString()); - Assert.Equal("Second", allEmissions[i][2].GetProperty("Name").GetString()); - Assert.Equal("Third", allEmissions[i][3].GetProperty("Name").GetString()); - - subscriptions[i].Dispose(); - } - - subject.Dispose(); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_WhenSourceCompletes_CompletesCorrectly() - { - - var subject = new Subject(); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - var emissions = new List(); - var completed = false; - var subscription = provider.ChangesAsBytes(query).Subscribe( - e => emissions.Add(e.ToJsonElement()), - _ => { }, // OnError - () => completed = true); // OnCompleted - - - subject.OnNext(new("Test", 1, true)); - subject.OnNext(new("Final", 2, false)); - subject.OnCompleted(); - - // Wait for completion - await ActiveWaitHelpers.WaitUntilAsync( - () => completed, - timeout: TimeSpan.FromSeconds(5), - description: "Changes observable completion"); - - - Assert.Equal(2, emissions.Count); - Assert.True(completed); - - subscription.Dispose(); - subject.Dispose(); - } - - #endregion - - #region Error Handling Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_WhenSourceErrors_PropagatesError() - { - - var subject = new Subject(); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - var emissions = new List(); - Exception? caughtException = null; - var subscription = provider.ChangesAsBytes(query).Subscribe( - e => emissions.Add(e.ToJsonElement()), - ex => caughtException = ex, - () => { }); - - - subject.OnNext(new("BeforeError", 1, true)); - var testException = new InvalidOperationException("Test error from source"); - subject.OnError(testException); - - // Wait for error propagation - await ActiveWaitHelpers.WaitUntilAsync( - () => caughtException != null, - timeout: TimeSpan.FromSeconds(5), - description: "error propagation"); - - - Assert.Equal(1, emissions.Count); - Assert.NotNull(caughtException); - Assert.Equal("Test error from source", caughtException.Message); - - subscription.Dispose(); - subject.Dispose(); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task FetchConfigurationAsync_WithNonSerializableObject_HandlesGracefully() - { - // Note: In practice, most objects can be serialized to JSON by System.Text.Json - // This test validates the behavior with objects that might cause serialization issues - - - var circularRef = new CircularReferenceTest(); - circularRef.Self = circularRef; // This can cause serialization issues - - // For this test, we'll use a regular serializable object since System.Text.Json - // handles most cases gracefully, but we want to ensure the provider works correctly - var testData = new TestConfig("Serializable", 999, true); - var subject = new BehaviorSubject(testData); - - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - - var result = await provider.FetchConfigurationBytesAsync(query); - Assert.NotEqual(default, result); - - subject.Dispose(); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_WithNullEmission_HandlesGracefully() - { - - var subject = new Subject(); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - var emissions = new List(); - Exception? error = null; - var subscription = provider.ChangesAsBytes(query).Subscribe( - e => emissions.Add(e.ToJsonElement()), - ex => error = ex); - - - subject.OnNext(new("Valid", 1, true)); - subject.OnNext(null); - subject.OnNext(new("ValidAgain", 2, false)); - - // Wait for all emissions - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count >= 3, - timeout: TimeSpan.FromSeconds(5), - description: "emissions including null"); - - - Assert.Null(error); - Assert.Equal(3, emissions.Count); - - // First emission should be valid - Assert.Equal("Valid", emissions[0].GetProperty("Name").GetString()); - - // Second emission should represent null (as JSON null) - Assert.Equal(JsonValueKind.Null, emissions[1].ValueKind); - - // Third emission should be valid again - Assert.Equal("ValidAgain", emissions[2].GetProperty("Name").GetString()); - - subscription.Dispose(); - subject.Dispose(); - } - - #endregion - - #region Performance Tests - [Fact] - [Trait("Type", "Performance")] - [Trait("Provider", "ObservableProvider")] - public async Task FetchConfigurationAsync_SingleRead_PerformanceUnder10ms() - { - - var testData = new TestConfig("Performance", 12345, true); - var subject = new BehaviorSubject(testData); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - // Warm up - await provider.FetchConfigurationBytesAsync(query); - - - var stopwatch = Stopwatch.StartNew(); - var result = await provider.FetchConfigurationBytesAsync(query); - stopwatch.Stop(); - - - Assert.NotEqual(default, result); - Assert.True(stopwatch.ElapsedMilliseconds < 10, - $"ObservableProvider read took {stopwatch.ElapsedMilliseconds}ms, expected < 10ms"); - - subject.Dispose(); - } - [Fact] - [Trait("Type", "Performance")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_EmissionLatency_Under50ms() - { - - var subject = new BehaviorSubject(new("Initial", 0, false)); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - var emissionTimes = new List(); - var stopwatch = Stopwatch.StartNew(); - - var subscription = provider.ChangesAsBytes(query).Subscribe(_ => - { - emissionTimes.Add(stopwatch.Elapsed); - }); - - - var emissionStart = stopwatch.Elapsed; - subject.OnNext(new("Timed", 1, true)); - - // Wait for emission - await ActiveWaitHelpers.WaitUntilAsync( - () => emissionTimes.Count > 0, - timeout: TimeSpan.FromSeconds(5), - description: "timed emission"); - - - var latency = emissionTimes[0] - emissionStart; - // Allow more latency on slower CI runners (especially ARM64 macOS) - Assert.True(latency.TotalMilliseconds < 200, - $"Emission latency was {latency.TotalMilliseconds}ms, expected < 200ms"); - - subscription.Dispose(); - subject.Dispose(); - } - - #endregion - - #region Subscription Management Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_DisposedSubscription_StopsReceivingEmissions() - { - - var subject = new BehaviorSubject(new("Initial", 0, false)); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - - - subject.OnNext(new("BeforeDispose", 1, true)); - - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count >= 2, // Initial + BeforeDispose - timeout: TimeSpan.FromSeconds(2), - description: "first emission"); - - subscription.Dispose(); - var emissionsAfterDispose = emissions.Count; - - subject.OnNext(new("AfterDispose", 2, false)); - subject.OnNext(new("StillAfterDispose", 3, true)); - - // Give time for potential emissions (should not happen) - await Task.Delay(200); - - - Assert.Equal(emissionsAfterDispose, emissions.Count); - Assert.Equal("Initial", emissions[0].GetProperty("Name").GetString()); // First emission is initial value - Assert.Equal("BeforeDispose", emissions[1].GetProperty("Name").GetString()); // Second emission is BeforeDispose - - subject.Dispose(); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_MultipleSubscribeDisposeCycles_HandlesCorrectly() - { - - var subject = new BehaviorSubject(new("Initial", 0, false)); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - - for (var cycle = 1; cycle <= 5; cycle++) - { - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - - subject.OnNext(new($"Cycle{cycle}", cycle, cycle % 2 == 0)); - - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count >= 2, // Current value + new emission - timeout: TimeSpan.FromSeconds(2), - description: $"cycle {cycle} emission"); - - Assert.Equal(2, emissions.Count); // Current value + cycle update - // BehaviorSubject emits current value on subscription - which changes each cycle - var expectedCurrentValue = cycle == 1 ? "Initial" : $"Cycle{cycle - 1}"; - Assert.Equal(expectedCurrentValue, emissions[0].GetProperty("Name").GetString()); - Assert.Equal($"Cycle{cycle}", emissions[1].GetProperty("Name").GetString()); // Then our update - Assert.Equal(cycle, emissions[1].GetProperty("Value").GetInt32()); - - subscription.Dispose(); - } - - subject.Dispose(); - } - - #endregion - - #region Concurrency Tests - [Fact] - [Trait("Type", "Concurrency")] - [Trait("Provider", "ObservableProvider")] - public async Task FetchConfigurationAsync_ConcurrentAccess_NoRaceConditions() - { - - var subject = new BehaviorSubject(new("Concurrent", 777, true)); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - const int threadCount = 10; - const int operationsPerThread = 50; - var exceptions = new List(); - var allResults = new List[threadCount]; - - - var tasks = Enumerable.Range(0, threadCount).Select(async threadId => - { - allResults[threadId] = new(); - try - { - for (var i = 0; i < operationsPerThread; i++) - { - var result = await provider.FetchConfigurationBytesAsync(query); - allResults[threadId].Add(result.ToJsonElement()); - } - } - catch (Exception ex) - { - lock (exceptions) - { - exceptions.Add(ex); - } - } - }).ToArray(); - - await Task.WhenAll(tasks); - - - Assert.Empty(exceptions); - - for (var i = 0; i < threadCount; i++) - { - Assert.Equal(operationsPerThread, allResults[i].Count); - foreach (var result in allResults[i]) - { - Assert.Equal("Concurrent", result.GetProperty("Name").GetString()); - Assert.Equal(777, result.GetProperty("Value").GetInt32()); - Assert.True(result.GetProperty("Enabled").GetBoolean()); - } - } - - subject.Dispose(); - } - [Fact] - [Trait("Type", "Concurrency")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_ConcurrentSubscriptions_HandlesSafely() - { - - var subject = new BehaviorSubject(new("Initial", 0, false)); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - const int subscriberCount = 20; - var allEmissions = new List[subscriberCount]; - var subscriptions = new IDisposable[subscriberCount]; - var exceptions = new List(); - - - var subscriptionTasks = Enumerable.Range(0, subscriberCount).Select(async subscriberId => - { - try - { - allEmissions[subscriberId] = new(); - var emissions = allEmissions[subscriberId]; - subscriptions[subscriberId] = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - - // Small delay to vary timing - await Task.Delay(subscriberId * 2); - } - catch (Exception ex) - { - lock (exceptions) - { - exceptions.Add(ex); - } - } - }).ToArray(); - - await Task.WhenAll(subscriptionTasks); - - // Emit some data - subject.OnNext(new("Concurrent1", 1, true)); - subject.OnNext(new("Concurrent2", 2, false)); - - // Wait for all subscriptions to receive emissions - await ActiveWaitHelpers.WaitUntilAsync( - () => allEmissions.All(list => list != null && list.Count >= 3), // Initial + 2 concurrent updates - timeout: TimeSpan.FromSeconds(10), - description: "all concurrent subscriptions"); - - - Assert.Empty(exceptions); - - for (var i = 0; i < subscriberCount; i++) - { - Assert.NotNull(allEmissions[i]); - Assert.True(allEmissions[i].Count >= 3); // Initial + 2 updates - Assert.Equal("Initial", allEmissions[i][0].GetProperty("Name").GetString()); // BehaviorSubject initial - Assert.Equal("Concurrent1", allEmissions[i][1].GetProperty("Name").GetString()); - Assert.Equal("Concurrent2", allEmissions[i][2].GetProperty("Name").GetString()); - - subscriptions[i].Dispose(); - } - - subject.Dispose(); - } - - #endregion - - #region Advanced Stress Tests - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_MassiveConcurrentSubscriptions_NoRaceConditions() - { - - var subject = new BehaviorSubject(new("Initial", 0, false)); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - const int subscriberCount = 100; - var allEmissions = new ConcurrentBag>(); - var subscriptions = new ConcurrentBag(); - var exceptions = new ConcurrentBag(); - - - var subscriptionTasks = Enumerable.Range(0, subscriberCount).Select(async subscriberId => - { - try - { - await Task.Delay(subscriberId % 10); // Stagger subscription timing slightly - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - - allEmissions.Add(emissions); - subscriptions.Add(subscription); - } - catch (Exception ex) - { - exceptions.Add(ex); - } - }).ToArray(); - - await Task.WhenAll(subscriptionTasks); - - // Emit data after all subscriptions are established - subject.OnNext(new("StressTest1", 1, true)); - subject.OnNext(new("StressTest2", 2, false)); - - // Wait for all emissions to propagate - await ActiveWaitHelpers.WaitUntilAsync( - () => allEmissions.All(list => list.Count >= 3), // Initial + 2 updates - timeout: TimeSpan.FromSeconds(10), - description: "all massive concurrent subscriptions"); - - - Assert.Empty(exceptions); - Assert.Equal(subscriberCount, allEmissions.Count); - - // Verify all subscribers received the same data - foreach (var emissions in allEmissions) - { - Assert.True(emissions.Count >= 3); - Assert.Equal("Initial", emissions[0].GetProperty("Name").GetString()); - Assert.Equal("StressTest1", emissions[1].GetProperty("Name").GetString()); - Assert.Equal("StressTest2", emissions[2].GetProperty("Name").GetString()); - } - - // Cleanup - foreach (var subscription in subscriptions) - { - subscription.Dispose(); - } - subject.Dispose(); - } - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_MixedConcurrentOperations_ThreadSafe() - { - - var subject = new BehaviorSubject(new("Initial", 0, false)); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - var allEmissions = new ConcurrentBag>(); - var activeSubscriptions = new ConcurrentBag(); - var exceptions = new ConcurrentBag(); - - - var tasks = new List(); - - // Task 1: Continuous subscription creation and disposal - tasks.Add(Task.Run(async () => - { - try - { - for (var i = 0; i < 50; i++) - { - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - allEmissions.Add(emissions); - activeSubscriptions.Add(subscription); - - await Task.Delay(10); // Small delay to allow emissions - - subscription.Dispose(); - } - } - catch (Exception ex) { exceptions.Add(ex); } - })); - - // Task 2: Rapid data emissions - tasks.Add(Task.Run(async () => - { - try - { - for (var i = 1; i <= 100; i++) - { - subject.OnNext(new($"Emission{i}", i, i % 2 == 0)); - if (i % 10 == 0) - { - await Task.Delay(1); // Occasional tiny pause - } - } - } - catch (Exception ex) { exceptions.Add(ex); } - })); - - // Task 3: Long-lived subscriptions - tasks.Add(Task.Run(async () => - { - try - { - for (var i = 0; i < 20; i++) - { - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - allEmissions.Add(emissions); - activeSubscriptions.Add(subscription); - await Task.Delay(50); // Keep these alive longer - } - } - catch (Exception ex) { exceptions.Add(ex); } - })); - - // Wait for all concurrent operations to complete - await Task.WhenAll(tasks); - - // Give time for final emissions to propagate - await Task.Delay(100); - - - Assert.Empty(exceptions); - Assert.True(allEmissions.Count > 0); - - // Verify that at least some emissions were captured (exact count is non-deterministic due to timing) - var nonEmptyEmissionLists = allEmissions.Where(list => list.Count > 0).ToArray(); - Assert.True(nonEmptyEmissionLists.Length > 0, "At least some subscriptions should have captured emissions"); - - // Cleanup remaining subscriptions - foreach (var subscription in activeSubscriptions) - { - subscription.Dispose(); - } - subject.Dispose(); - } - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_HeavyLoadScenario_RemainsStable() - { - - var subject = new BehaviorSubject(new("Initial", 0, false)); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - const int subscriberCount = 10; - const int emissionCount = 1000; - - var allEmissions = new List[subscriberCount]; - var subscriptions = new IDisposable[subscriberCount]; - var exceptions = new ConcurrentBag(); - - // Create long-lived subscribers - for (var i = 0; i < subscriberCount; i++) - { - var subscriberId = i; - try - { - allEmissions[subscriberId] = new(); - subscriptions[subscriberId] = provider.ChangesAsBytes(query).Subscribe( - emission => allEmissions[subscriberId].Add(emission.ToJsonElement()), - ex => exceptions.Add(ex)); - } - catch (Exception ex) - { - exceptions.Add(ex); - } - } - - - var stopwatch = Stopwatch.StartNew(); - - for (var i = 1; i <= emissionCount; i++) - { - subject.OnNext(new($"Load{i}", i, i % 2 == 0)); - - // Occasional micro-pause to allow processing - if (i % 100 == 0) - { - await Task.Delay(1); - } - } - - // Wait for all emissions to be processed - await ActiveWaitHelpers.WaitUntilAsync( - () => allEmissions.All(list => list != null && list.Count >= emissionCount + 1), // +1 for initial - timeout: TimeSpan.FromSeconds(30), - description: "heavy load processing"); - - stopwatch.Stop(); - - - Assert.Empty(exceptions); - - // Verify all subscribers received all data - for (var i = 0; i < subscriberCount; i++) - { - Assert.NotNull(allEmissions[i]); - Assert.Equal(emissionCount + 1, allEmissions[i].Count); // +1 for initial emission - - // Verify first and last emissions - Assert.Equal("Initial", allEmissions[i][0].GetProperty("Name").GetString()); - Assert.Equal($"Load{emissionCount}", allEmissions[i][^1].GetProperty("Name").GetString()); - } - - // Performance check - should handle 1000 emissions reasonably fast - Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10), - $"Heavy load processing took {stopwatch.Elapsed.TotalSeconds:F2} seconds, expected < 10 seconds"); - - // Cleanup - for (var i = 0; i < subscriberCount; i++) - { - subscriptions[i]?.Dispose(); - } - subject.Dispose(); - } - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_RapidSubscribeUnsubscribeCycles_NoMemoryLeaks() - { - - var subject = new BehaviorSubject(new("Initial", 0, false)); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - const int cycleCount = 500; - var exceptions = new ConcurrentBag(); - var totalEmissionsReceived = 0; - - - for (var cycle = 1; cycle <= cycleCount; cycle++) - { - try - { - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - - // Emit one piece of data - subject.OnNext(new($"Cycle{cycle}", cycle, cycle % 2 == 0)); - - // Brief wait for emission - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count >= 2, // Initial + cycle emission - timeout: TimeSpan.FromMilliseconds(100), - description: $"cycle {cycle} emissions"); - - totalEmissionsReceived += emissions.Count; - - // Immediately dispose - subscription.Dispose(); - - // Occasional GC to detect memory issues - if (cycle % 100 == 0) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - await Task.Delay(1); - } - } - catch (Exception ex) - { - exceptions.Add(ex); - } - } - - - Assert.Empty(exceptions); - Assert.True(totalEmissionsReceived > 0, "Should have received some emissions during the test"); - - // Final GC to ensure no memory leaks - GC.Collect(); - GC.WaitForPendingFinalizers(); - - subject.Dispose(); - } - - #endregion - - #region JSON String Convenience Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task FetchConfigurationAsync_WithJsonStringOverload_WorksCorrectly() - { - - var jsonString = """{"Name":"TestApp","Version":"1.0.0","EnableLogging":true}"""; - - var options = new ObservableProviderOptions(new BehaviorSubject(jsonString)); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - - var result = await provider.FetchConfigurationBytesAsync(query); - - - Assert.True(result.ToJsonElement().TryGetProperty("Name", out var nameProperty)); - Assert.Equal("TestApp", nameProperty.GetString()); - Assert.True(result.ToJsonElement().TryGetProperty("Version", out var versionProperty)); - Assert.Equal("1.0.0", versionProperty.GetString()); - Assert.True(result.ToJsonElement().TryGetProperty("EnableLogging", out var loggingProperty)); - Assert.True(loggingProperty.GetBoolean()); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task Changes_WithJsonStringObservable_PropagatesUpdates() - { - - var initialJson = """{"Name":"InitialApp","Version":"1.0.0"}"""; - var updatedJson = """{"Name":"UpdatedApp","Version":"2.0.0"}"""; - - var subject = new BehaviorSubject(initialJson); - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - var changes = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => changes.Add(e.ToJsonElement())); - - - await Task.Delay(10); // Let initial value emit - subject.OnNext(updatedJson); - await Task.Delay(10); // Let update emit - - - Assert.Equal(2, changes.Count); - - // Initial value - Assert.True(changes[0].TryGetProperty("Name", out var initialName)); - Assert.Equal("InitialApp", initialName.GetString()); - - // Updated value - Assert.True(changes[1].TryGetProperty("Name", out var updatedName)); - Assert.Equal("UpdatedApp", updatedName.GetString()); - - subscription.Dispose(); - subject.Dispose(); - } - - /// - /// Demonstrates the new fluent API: TestRules.Observable(jsonString).For<T>() - /// This makes test writing much simpler! - /// - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public void FluentAPI_Observable_SimplifiesTestWriting() - { - - var jsonString = """{"Name":"TestApp","Value":42,"Enabled":true}"""; - - var rules = new List - { - TestRules.ObservableString(System.Reactive.Linq.Observable.Return(jsonString)) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); - - - var config = configManager.GetConfig(); - - - Assert.NotNull(config); - Assert.Equal("TestApp", config.Name); - Assert.Equal(42, config.Value); - Assert.True(config.Enabled); - } - - #endregion - - #region Subscriber Safety Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task Reactive_Subscription_Exception_DoesNotTerminateOtherSubscribers() - { - - var testData1 = new TestConfig("Initial", 1, true); - var testData2 = new TestConfig("Updated", 2, false); - var subject = new BehaviorSubject(testData1); - - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - // Create multiple subscribers - one will throw exceptions, others should be unaffected - var goodSubscriber1Values = new List(); - var goodSubscriber2Values = new List(); - var exceptionCount = 0; - - var changes = provider.ChangesAsBytes(query); - - // Good subscriber 1 - var subscription1 = changes.Subscribe( - config => goodSubscriber1Values.Add(config.ToJsonElement()), - ex => { /* Should not be called */ }); - - // Bad subscriber that throws exceptions - var subscription2 = changes.Subscribe( - config => - { - exceptionCount++; - throw new InvalidOperationException($"Test exception {exceptionCount}"); - }, - ex => { /* Exception handled */ }); - - // Good subscriber 2 - var subscription3 = changes.Subscribe( - config => goodSubscriber2Values.Add(config.ToJsonElement()), - ex => { /* Should not be called */ }); - - // Wait for initial values - await ActiveWaitHelpers.WaitUntilAsync( - () => goodSubscriber1Values.Count > 0 && goodSubscriber2Values.Count > 0, - timeout: TimeSpan.FromSeconds(2), - description: "initial emissions to good subscribers"); - - var initialCount1 = goodSubscriber1Values.Count; - var initialCount2 = goodSubscriber2Values.Count; - - - subject.OnNext(testData2); - - // Wait for propagation - await ActiveWaitHelpers.WaitUntilAsync( - () => goodSubscriber1Values.Count > initialCount1 && goodSubscriber2Values.Count > initialCount2, - timeout: TimeSpan.FromSeconds(2), - description: "updated emissions to good subscribers despite bad subscriber exception"); - - - Assert.True(goodSubscriber1Values.Count > initialCount1, "Good subscriber 1 should receive new value"); - Assert.True(goodSubscriber2Values.Count > initialCount2, "Good subscriber 2 should receive new value"); - - // Verify latest values are correct - var latest1 = goodSubscriber1Values.Last(); - var latest2 = goodSubscriber2Values.Last(); - - Assert.Equal("Updated", latest1.GetProperty("Name").GetString()); - Assert.Equal("Updated", latest2.GetProperty("Name").GetString()); - Assert.Equal(2, latest1.GetProperty("Value").GetInt32()); - Assert.Equal(2, latest2.GetProperty("Value").GetInt32()); - - // Cleanup - subscription1.Dispose(); - subscription2.Dispose(); - subscription3.Dispose(); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "ObservableProvider")] - public async Task Reactive_OneSubscriberDispose_DoesNotAffectOthers() - { - - var testData1 = new TestConfig("Initial", 1, true); - var testData2 = new TestConfig("Updated", 2, false); - var testData3 = new TestConfig("Final", 3, true); - var subject = new BehaviorSubject(testData1); - - var options = new ObservableProviderOptions(subject); - var provider = new ObservableProvider(options); - var query = ObservableProviderQuery.Default; - - // Create multiple subscribers - var subscriber1Values = new List(); - var subscriber2Values = new List(); - var subscriber3Values = new List(); - - var changes = provider.ChangesAsBytes(query); - - var subscription1 = changes.Subscribe(config => subscriber1Values.Add(config.ToJsonElement())); - var subscription2 = changes.Subscribe(config => subscriber2Values.Add(config.ToJsonElement())); - var subscription3 = changes.Subscribe(config => subscriber3Values.Add(config.ToJsonElement())); - - // Wait for initial values - await ActiveWaitHelpers.WaitUntilAsync( - () => subscriber1Values.Count > 0 && subscriber2Values.Count > 0 && subscriber3Values.Count > 0, - timeout: TimeSpan.FromSeconds(2), - description: "initial emissions to all subscribers"); - - // All subscribers should have initial value - Assert.True(subscriber1Values.Count > 0, "Subscriber 1 should have initial value"); - Assert.True(subscriber2Values.Count > 0, "Subscriber 2 should have initial value"); - Assert.True(subscriber3Values.Count > 0, "Subscriber 3 should have initial value"); - - - subject.OnNext(testData2); - - // Wait for second emission - await ActiveWaitHelpers.WaitUntilAsync( - () => subscriber1Values.Count >= 2 && subscriber2Values.Count >= 2 && subscriber3Values.Count >= 2, - timeout: TimeSpan.FromSeconds(2), - description: "second emissions to all subscribers"); - - var count1BeforeDispose = subscriber1Values.Count; - var count2BeforeDispose = subscriber2Values.Count; - var count3BeforeDispose = subscriber3Values.Count; - - - subscription2.Dispose(); - - - subject.OnNext(testData3); - - // Wait for third emission - await ActiveWaitHelpers.WaitUntilAsync( - () => subscriber1Values.Count > count1BeforeDispose && subscriber3Values.Count > count3BeforeDispose, - timeout: TimeSpan.FromSeconds(2), - description: "third emissions to remaining subscribers"); - - - Assert.True(subscriber1Values.Count > count1BeforeDispose, - "Subscriber 1 should continue receiving updates after subscriber 2 disposal"); - Assert.True(subscriber3Values.Count > count3BeforeDispose, - "Subscriber 3 should continue receiving updates after subscriber 2 disposal"); - - // Subscriber 2 should stop receiving updates after disposal - Assert.Equal(count2BeforeDispose, subscriber2Values.Count); - - // Verify latest values for active subscribers - var latest1 = subscriber1Values.Last(); - var latest3 = subscriber3Values.Last(); - - Assert.Equal("Final", latest1.GetProperty("Name").GetString()); - Assert.Equal("Final", latest3.GetProperty("Name").GetString()); - Assert.Equal(3, latest1.GetProperty("Value").GetInt32()); - Assert.Equal(3, latest3.GetProperty("Value").GetInt32()); - - // Cleanup - subscription1.Dispose(); - subscription3.Dispose(); - } - - #endregion - - #region Helper Classes - - private class CircularReferenceTest - { - public CircularReferenceTest? Self { get; set; } - public string Name { get; set; } = "Test"; - } - - #endregion +using System.Reactive.Subjects; +using System.Text.Json; +using Cocoar.Configuration.Core.Tests.TestUtilities; +using System.Diagnostics; +using System.Collections.Concurrent; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; + +using Cocoar.Configuration.Core.Tests.Helpers; + +namespace Cocoar.Configuration.Core.Tests.Providers; + +/// +/// Isolation tests for ObservableProvider - testing only deterministic observable provider functionality +/// without any I/O dependencies. These tests validate reactive behavior, subscription management, +/// error handling, and performance characteristics using controlled observables. +/// +public class ObservableProviderIsolationTests +{ + #region Test Configuration Classes + + public record TestConfig(string Name, int Value, bool Enabled); + public record ComplexConfig(string Title, List Tags, DateTime Timestamp); + public record EmptyConfig(); + public record DynamicConfig(int Id, string Status, double Score); + + #endregion + + #region Basic Functionality Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task FetchConfigurationAsync_WithBehaviorSubject_ReturnsCurrentValue() + { + + var testData = new TestConfig("ObservableTest", 123, true); + var subject = new BehaviorSubject(testData); + + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + + var result = await provider.FetchConfigurationBytesAsync(query); + + + Assert.Equal("ObservableTest", result.ToJsonElement().GetProperty("Name").GetString()); + Assert.Equal(123, result.ToJsonElement().GetProperty("Value").GetInt32()); + Assert.True(result.ToJsonElement().GetProperty("Enabled").GetBoolean()); + + subject.Dispose(); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task FetchConfigurationAsync_WithComplexObject_SerializesCorrectly() + { + + var testData = new ComplexConfig( + "Complex Test Configuration", + new() { "tag1", "tag2", "production" }, + new(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc)); + + var subject = new BehaviorSubject(testData); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + + var result = await provider.FetchConfigurationBytesAsync(query); + + + Assert.Equal("Complex Test Configuration", result.ToJsonElement().GetProperty("Title").GetString()); + + var tags = result.ToJsonElement().GetProperty("Tags"); + Assert.Equal(3, tags.GetArrayLength()); + Assert.Equal("tag1", tags[0].GetString()); + Assert.Equal("tag2", tags[1].GetString()); + Assert.Equal("production", tags[2].GetString()); + + // Verify timestamp serialization + var timestamp = result.ToJsonElement().GetProperty("Timestamp").GetDateTime(); + Assert.Equal(2025, timestamp.Year); + Assert.Equal(1, timestamp.Month); + Assert.Equal(15, timestamp.Day); + + subject.Dispose(); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task FetchConfigurationAsync_WithEmptyConfig_HandlesCorrectly() + { + + var testData = new EmptyConfig(); + var subject = new BehaviorSubject(testData); + + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + + var result = await provider.FetchConfigurationBytesAsync(query); + + + Assert.Equal(JsonValueKind.Object, result.ToJsonElement().ValueKind); + // EmptyConfig should serialize to empty JSON object + + subject.Dispose(); + } + + #endregion + + #region Observable/Reactive Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_WhenSubjectEmits_PropagatesChanges() + { + + var initialData = new TestConfig("Initial", 1, false); + var subject = new BehaviorSubject(initialData); + + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + + + var updatedData1 = new TestConfig("Updated1", 2, true); + var updatedData2 = new TestConfig("Updated2", 3, false); + + subject.OnNext(updatedData1); + subject.OnNext(updatedData2); + + // Wait for emissions to propagate + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count >= 3, // Initial + 2 updates + timeout: TimeSpan.FromSeconds(5), + description: "observable change emissions"); + + + Assert.True(emissions.Count >= 3); + + // Verify initial emission (BehaviorSubject emits current value on subscription) + Assert.Equal("Initial", emissions[0].GetProperty("Name").GetString()); + Assert.Equal(1, emissions[0].GetProperty("Value").GetInt32()); + Assert.False(emissions[0].GetProperty("Enabled").GetBoolean()); + + // Verify first change + Assert.Equal("Updated1", emissions[1].GetProperty("Name").GetString()); + Assert.Equal(2, emissions[1].GetProperty("Value").GetInt32()); + Assert.True(emissions[1].GetProperty("Enabled").GetBoolean()); + + // Verify second change + Assert.Equal("Updated2", emissions[2].GetProperty("Name").GetString()); + Assert.Equal(3, emissions[2].GetProperty("Value").GetInt32()); + Assert.False(emissions[2].GetProperty("Enabled").GetBoolean()); + + subscription.Dispose(); + subject.Dispose(); + } + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_WithRapidEmissions_HandlesAllChanges() + { + + var subject = new BehaviorSubject(new("Initial", 0, false)); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + + + const int changeCount = 50; + for (var i = 1; i <= changeCount; i++) + { + var data = new TestConfig($"Change{i}", i, i % 2 == 0); + subject.OnNext(data); + } + + // Wait for all emissions using active waiting + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count >= changeCount + 1, // +1 for initial value from BehaviorSubject + timeout: TimeSpan.FromSeconds(10), + description: "all rapid emissions"); + + + Assert.Equal(changeCount + 1, emissions.Count); + + // Verify initial emission + Assert.Equal("Initial", emissions[0].GetProperty("Name").GetString()); + Assert.Equal(0, emissions[0].GetProperty("Value").GetInt32()); + + // Verify a few key emissions (offset by 1 due to initial emission) + Assert.Equal("Change1", emissions[1].GetProperty("Name").GetString()); + Assert.Equal(1, emissions[1].GetProperty("Value").GetInt32()); + + Assert.Equal("Change25", emissions[25].GetProperty("Name").GetString()); // 25 + 1 offset + Assert.Equal(25, emissions[25].GetProperty("Value").GetInt32()); + + Assert.Equal("Change50", emissions[50].GetProperty("Name").GetString()); // 50 + 1 offset + Assert.Equal(50, emissions[50].GetProperty("Value").GetInt32()); + + subscription.Dispose(); + subject.Dispose(); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_MultipleSubscriptions_AllReceiveEmissions() + { + + var subject = new BehaviorSubject(new("Initial", 0, false)); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + const int subscriberCount = 5; + var allEmissions = new List[subscriberCount]; + var subscriptions = new IDisposable[subscriberCount]; + + // Create multiple subscriptions + for (var i = 0; i < subscriberCount; i++) + { + allEmissions[i] = new(); + var emissions = allEmissions[i]; // Capture for closure + subscriptions[i] = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + } + + + subject.OnNext(new("First", 1, true)); + subject.OnNext(new("Second", 2, false)); + subject.OnNext(new("Third", 3, true)); + + // Wait for all subscriptions to receive emissions + await ActiveWaitHelpers.WaitUntilAsync( + () => allEmissions.All(list => list.Count >= 4), // Initial + 3 updates + timeout: TimeSpan.FromSeconds(10), + description: "all subscribers to receive emissions"); + + + for (var i = 0; i < subscriberCount; i++) + { + Assert.Equal(4, allEmissions[i].Count); // Initial + 3 updates + + Assert.Equal("Initial", allEmissions[i][0].GetProperty("Name").GetString()); + Assert.Equal("First", allEmissions[i][1].GetProperty("Name").GetString()); + Assert.Equal("Second", allEmissions[i][2].GetProperty("Name").GetString()); + Assert.Equal("Third", allEmissions[i][3].GetProperty("Name").GetString()); + + subscriptions[i].Dispose(); + } + + subject.Dispose(); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_WhenSourceCompletes_CompletesCorrectly() + { + + var subject = new Subject(); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + var emissions = new List(); + var completed = false; + var subscription = provider.ChangesAsBytes(query).Subscribe( + e => emissions.Add(e.ToJsonElement()), + _ => { }, // OnError + () => completed = true); // OnCompleted + + + subject.OnNext(new("Test", 1, true)); + subject.OnNext(new("Final", 2, false)); + subject.OnCompleted(); + + // Wait for completion + await ActiveWaitHelpers.WaitUntilAsync( + () => completed, + timeout: TimeSpan.FromSeconds(5), + description: "Changes observable completion"); + + + Assert.Equal(2, emissions.Count); + Assert.True(completed); + + subscription.Dispose(); + subject.Dispose(); + } + + #endregion + + #region Error Handling Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_WhenSourceErrors_PropagatesError() + { + + var subject = new Subject(); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + var emissions = new List(); + Exception? caughtException = null; + var subscription = provider.ChangesAsBytes(query).Subscribe( + e => emissions.Add(e.ToJsonElement()), + ex => caughtException = ex, + () => { }); + + + subject.OnNext(new("BeforeError", 1, true)); + var testException = new InvalidOperationException("Test error from source"); + subject.OnError(testException); + + // Wait for error propagation + await ActiveWaitHelpers.WaitUntilAsync( + () => caughtException != null, + timeout: TimeSpan.FromSeconds(5), + description: "error propagation"); + + + Assert.Equal(1, emissions.Count); + Assert.NotNull(caughtException); + Assert.Equal("Test error from source", caughtException.Message); + + subscription.Dispose(); + subject.Dispose(); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task FetchConfigurationAsync_WithNonSerializableObject_HandlesGracefully() + { + // Note: In practice, most objects can be serialized to JSON by System.Text.Json + // This test validates the behavior with objects that might cause serialization issues + + + var circularRef = new CircularReferenceTest(); + circularRef.Self = circularRef; // This can cause serialization issues + + // For this test, we'll use a regular serializable object since System.Text.Json + // handles most cases gracefully, but we want to ensure the provider works correctly + var testData = new TestConfig("Serializable", 999, true); + var subject = new BehaviorSubject(testData); + + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + + var result = await provider.FetchConfigurationBytesAsync(query); + Assert.NotEqual(default, result); + + subject.Dispose(); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_WithNullEmission_HandlesGracefully() + { + + var subject = new Subject(); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + var emissions = new List(); + Exception? error = null; + var subscription = provider.ChangesAsBytes(query).Subscribe( + e => emissions.Add(e.ToJsonElement()), + ex => error = ex); + + + subject.OnNext(new("Valid", 1, true)); + subject.OnNext(null); + subject.OnNext(new("ValidAgain", 2, false)); + + // Wait for all emissions + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count >= 3, + timeout: TimeSpan.FromSeconds(5), + description: "emissions including null"); + + + Assert.Null(error); + Assert.Equal(3, emissions.Count); + + // First emission should be valid + Assert.Equal("Valid", emissions[0].GetProperty("Name").GetString()); + + // Second emission should represent null (as JSON null) + Assert.Equal(JsonValueKind.Null, emissions[1].ValueKind); + + // Third emission should be valid again + Assert.Equal("ValidAgain", emissions[2].GetProperty("Name").GetString()); + + subscription.Dispose(); + subject.Dispose(); + } + + #endregion + + #region Performance Tests + [Fact] + [Trait("Type", "Performance")] + [Trait("Provider", "ObservableProvider")] + public async Task FetchConfigurationAsync_SingleRead_PerformanceUnder10ms() + { + + var testData = new TestConfig("Performance", 12345, true); + var subject = new BehaviorSubject(testData); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + // Warm up + await provider.FetchConfigurationBytesAsync(query); + + + var stopwatch = Stopwatch.StartNew(); + var result = await provider.FetchConfigurationBytesAsync(query); + stopwatch.Stop(); + + + Assert.NotEqual(default, result); + Assert.True(stopwatch.ElapsedMilliseconds < 10, + $"ObservableProvider read took {stopwatch.ElapsedMilliseconds}ms, expected < 10ms"); + + subject.Dispose(); + } + [Fact] + [Trait("Type", "Performance")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_EmissionLatency_Under50ms() + { + + var subject = new BehaviorSubject(new("Initial", 0, false)); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + var emissionTimes = new List(); + var stopwatch = Stopwatch.StartNew(); + + var subscription = provider.ChangesAsBytes(query).Subscribe(_ => + { + emissionTimes.Add(stopwatch.Elapsed); + }); + + + var emissionStart = stopwatch.Elapsed; + subject.OnNext(new("Timed", 1, true)); + + // Wait for emission + await ActiveWaitHelpers.WaitUntilAsync( + () => emissionTimes.Count > 0, + timeout: TimeSpan.FromSeconds(5), + description: "timed emission"); + + + var latency = emissionTimes[0] - emissionStart; + // Allow more latency on slower CI runners (especially ARM64 macOS) + Assert.True(latency.TotalMilliseconds < 200, + $"Emission latency was {latency.TotalMilliseconds}ms, expected < 200ms"); + + subscription.Dispose(); + subject.Dispose(); + } + + #endregion + + #region Subscription Management Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_DisposedSubscription_StopsReceivingEmissions() + { + + var subject = new BehaviorSubject(new("Initial", 0, false)); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + + + subject.OnNext(new("BeforeDispose", 1, true)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count >= 2, // Initial + BeforeDispose + timeout: TimeSpan.FromSeconds(2), + description: "first emission"); + + subscription.Dispose(); + var emissionsAfterDispose = emissions.Count; + + subject.OnNext(new("AfterDispose", 2, false)); + subject.OnNext(new("StillAfterDispose", 3, true)); + + // Give time for potential emissions (should not happen) + await Task.Delay(200); + + + Assert.Equal(emissionsAfterDispose, emissions.Count); + Assert.Equal("Initial", emissions[0].GetProperty("Name").GetString()); // First emission is initial value + Assert.Equal("BeforeDispose", emissions[1].GetProperty("Name").GetString()); // Second emission is BeforeDispose + + subject.Dispose(); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_MultipleSubscribeDisposeCycles_HandlesCorrectly() + { + + var subject = new BehaviorSubject(new("Initial", 0, false)); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + + for (var cycle = 1; cycle <= 5; cycle++) + { + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + + subject.OnNext(new($"Cycle{cycle}", cycle, cycle % 2 == 0)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count >= 2, // Current value + new emission + timeout: TimeSpan.FromSeconds(2), + description: $"cycle {cycle} emission"); + + Assert.Equal(2, emissions.Count); // Current value + cycle update + // BehaviorSubject emits current value on subscription - which changes each cycle + var expectedCurrentValue = cycle == 1 ? "Initial" : $"Cycle{cycle - 1}"; + Assert.Equal(expectedCurrentValue, emissions[0].GetProperty("Name").GetString()); + Assert.Equal($"Cycle{cycle}", emissions[1].GetProperty("Name").GetString()); // Then our update + Assert.Equal(cycle, emissions[1].GetProperty("Value").GetInt32()); + + subscription.Dispose(); + } + + subject.Dispose(); + } + + #endregion + + #region Concurrency Tests + [Fact] + [Trait("Type", "Concurrency")] + [Trait("Provider", "ObservableProvider")] + public async Task FetchConfigurationAsync_ConcurrentAccess_NoRaceConditions() + { + + var subject = new BehaviorSubject(new("Concurrent", 777, true)); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + const int threadCount = 10; + const int operationsPerThread = 50; + var exceptions = new List(); + var allResults = new List[threadCount]; + + + var tasks = Enumerable.Range(0, threadCount).Select(async threadId => + { + allResults[threadId] = new(); + try + { + for (var i = 0; i < operationsPerThread; i++) + { + var result = await provider.FetchConfigurationBytesAsync(query); + allResults[threadId].Add(result.ToJsonElement()); + } + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + }).ToArray(); + + await Task.WhenAll(tasks); + + + Assert.Empty(exceptions); + + for (var i = 0; i < threadCount; i++) + { + Assert.Equal(operationsPerThread, allResults[i].Count); + foreach (var result in allResults[i]) + { + Assert.Equal("Concurrent", result.GetProperty("Name").GetString()); + Assert.Equal(777, result.GetProperty("Value").GetInt32()); + Assert.True(result.GetProperty("Enabled").GetBoolean()); + } + } + + subject.Dispose(); + } + [Fact] + [Trait("Type", "Concurrency")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_ConcurrentSubscriptions_HandlesSafely() + { + + var subject = new BehaviorSubject(new("Initial", 0, false)); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + const int subscriberCount = 20; + var allEmissions = new List[subscriberCount]; + var subscriptions = new IDisposable[subscriberCount]; + var exceptions = new List(); + + + var subscriptionTasks = Enumerable.Range(0, subscriberCount).Select(async subscriberId => + { + try + { + allEmissions[subscriberId] = new(); + var emissions = allEmissions[subscriberId]; + subscriptions[subscriberId] = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + + // Small delay to vary timing + await Task.Delay(subscriberId * 2); + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + }).ToArray(); + + await Task.WhenAll(subscriptionTasks); + + // Emit some data + subject.OnNext(new("Concurrent1", 1, true)); + subject.OnNext(new("Concurrent2", 2, false)); + + // Wait for all subscriptions to receive emissions + await ActiveWaitHelpers.WaitUntilAsync( + () => allEmissions.All(list => list != null && list.Count >= 3), // Initial + 2 concurrent updates + timeout: TimeSpan.FromSeconds(10), + description: "all concurrent subscriptions"); + + + Assert.Empty(exceptions); + + for (var i = 0; i < subscriberCount; i++) + { + Assert.NotNull(allEmissions[i]); + Assert.True(allEmissions[i].Count >= 3); // Initial + 2 updates + Assert.Equal("Initial", allEmissions[i][0].GetProperty("Name").GetString()); // BehaviorSubject initial + Assert.Equal("Concurrent1", allEmissions[i][1].GetProperty("Name").GetString()); + Assert.Equal("Concurrent2", allEmissions[i][2].GetProperty("Name").GetString()); + + subscriptions[i].Dispose(); + } + + subject.Dispose(); + } + + #endregion + + #region Advanced Stress Tests + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_MassiveConcurrentSubscriptions_NoRaceConditions() + { + + var subject = new BehaviorSubject(new("Initial", 0, false)); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + const int subscriberCount = 100; + var allEmissions = new ConcurrentBag>(); + var subscriptions = new ConcurrentBag(); + var exceptions = new ConcurrentBag(); + + + var subscriptionTasks = Enumerable.Range(0, subscriberCount).Select(async subscriberId => + { + try + { + await Task.Delay(subscriberId % 10); // Stagger subscription timing slightly + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + + allEmissions.Add(emissions); + subscriptions.Add(subscription); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + }).ToArray(); + + await Task.WhenAll(subscriptionTasks); + + // Emit data after all subscriptions are established + subject.OnNext(new("StressTest1", 1, true)); + subject.OnNext(new("StressTest2", 2, false)); + + // Wait for all emissions to propagate + await ActiveWaitHelpers.WaitUntilAsync( + () => allEmissions.All(list => list.Count >= 3), // Initial + 2 updates + timeout: TimeSpan.FromSeconds(10), + description: "all massive concurrent subscriptions"); + + + Assert.Empty(exceptions); + Assert.Equal(subscriberCount, allEmissions.Count); + + // Verify all subscribers received the same data + foreach (var emissions in allEmissions) + { + Assert.True(emissions.Count >= 3); + Assert.Equal("Initial", emissions[0].GetProperty("Name").GetString()); + Assert.Equal("StressTest1", emissions[1].GetProperty("Name").GetString()); + Assert.Equal("StressTest2", emissions[2].GetProperty("Name").GetString()); + } + + // Cleanup + foreach (var subscription in subscriptions) + { + subscription.Dispose(); + } + subject.Dispose(); + } + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_MixedConcurrentOperations_ThreadSafe() + { + + var subject = new BehaviorSubject(new("Initial", 0, false)); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + var allEmissions = new ConcurrentBag>(); + var activeSubscriptions = new ConcurrentBag(); + var exceptions = new ConcurrentBag(); + + + var tasks = new List(); + + // Task 1: Continuous subscription creation and disposal + tasks.Add(Task.Run(async () => + { + try + { + for (var i = 0; i < 50; i++) + { + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + allEmissions.Add(emissions); + activeSubscriptions.Add(subscription); + + await Task.Delay(10); // Small delay to allow emissions + + subscription.Dispose(); + } + } + catch (Exception ex) { exceptions.Add(ex); } + })); + + // Task 2: Rapid data emissions + tasks.Add(Task.Run(async () => + { + try + { + for (var i = 1; i <= 100; i++) + { + subject.OnNext(new($"Emission{i}", i, i % 2 == 0)); + if (i % 10 == 0) + { + await Task.Delay(1); // Occasional tiny pause + } + } + } + catch (Exception ex) { exceptions.Add(ex); } + })); + + // Task 3: Long-lived subscriptions + tasks.Add(Task.Run(async () => + { + try + { + for (var i = 0; i < 20; i++) + { + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + allEmissions.Add(emissions); + activeSubscriptions.Add(subscription); + await Task.Delay(50); // Keep these alive longer + } + } + catch (Exception ex) { exceptions.Add(ex); } + })); + + // Wait for all concurrent operations to complete + await Task.WhenAll(tasks); + + // Give time for final emissions to propagate + await Task.Delay(100); + + + Assert.Empty(exceptions); + Assert.True(allEmissions.Count > 0); + + // Verify that at least some emissions were captured (exact count is non-deterministic due to timing) + var nonEmptyEmissionLists = allEmissions.Where(list => list.Count > 0).ToArray(); + Assert.True(nonEmptyEmissionLists.Length > 0, "At least some subscriptions should have captured emissions"); + + // Cleanup remaining subscriptions + foreach (var subscription in activeSubscriptions) + { + subscription.Dispose(); + } + subject.Dispose(); + } + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_HeavyLoadScenario_RemainsStable() + { + + var subject = new BehaviorSubject(new("Initial", 0, false)); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + const int subscriberCount = 10; + const int emissionCount = 1000; + + var allEmissions = new List[subscriberCount]; + var subscriptions = new IDisposable[subscriberCount]; + var exceptions = new ConcurrentBag(); + + // Create long-lived subscribers + for (var i = 0; i < subscriberCount; i++) + { + var subscriberId = i; + try + { + allEmissions[subscriberId] = new(); + subscriptions[subscriberId] = provider.ChangesAsBytes(query).Subscribe( + emission => allEmissions[subscriberId].Add(emission.ToJsonElement()), + ex => exceptions.Add(ex)); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + + var stopwatch = Stopwatch.StartNew(); + + for (var i = 1; i <= emissionCount; i++) + { + subject.OnNext(new($"Load{i}", i, i % 2 == 0)); + + // Occasional micro-pause to allow processing + if (i % 100 == 0) + { + await Task.Delay(1); + } + } + + // Wait for all emissions to be processed + await ActiveWaitHelpers.WaitUntilAsync( + () => allEmissions.All(list => list != null && list.Count >= emissionCount + 1), // +1 for initial + timeout: TimeSpan.FromSeconds(30), + description: "heavy load processing"); + + stopwatch.Stop(); + + + Assert.Empty(exceptions); + + // Verify all subscribers received all data + for (var i = 0; i < subscriberCount; i++) + { + Assert.NotNull(allEmissions[i]); + Assert.Equal(emissionCount + 1, allEmissions[i].Count); // +1 for initial emission + + // Verify first and last emissions + Assert.Equal("Initial", allEmissions[i][0].GetProperty("Name").GetString()); + Assert.Equal($"Load{emissionCount}", allEmissions[i][^1].GetProperty("Name").GetString()); + } + + // Performance check - should handle 1000 emissions reasonably fast + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10), + $"Heavy load processing took {stopwatch.Elapsed.TotalSeconds:F2} seconds, expected < 10 seconds"); + + // Cleanup + for (var i = 0; i < subscriberCount; i++) + { + subscriptions[i]?.Dispose(); + } + subject.Dispose(); + } + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_RapidSubscribeUnsubscribeCycles_NoMemoryLeaks() + { + + var subject = new BehaviorSubject(new("Initial", 0, false)); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + const int cycleCount = 500; + var exceptions = new ConcurrentBag(); + var totalEmissionsReceived = 0; + + + for (var cycle = 1; cycle <= cycleCount; cycle++) + { + try + { + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + + // Emit one piece of data + subject.OnNext(new($"Cycle{cycle}", cycle, cycle % 2 == 0)); + + // Brief wait for emission + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count >= 2, // Initial + cycle emission + timeout: TimeSpan.FromMilliseconds(100), + description: $"cycle {cycle} emissions"); + + totalEmissionsReceived += emissions.Count; + + // Immediately dispose + subscription.Dispose(); + + // Occasional GC to detect memory issues + if (cycle % 100 == 0) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + await Task.Delay(1); + } + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + + Assert.Empty(exceptions); + Assert.True(totalEmissionsReceived > 0, "Should have received some emissions during the test"); + + // Final GC to ensure no memory leaks + GC.Collect(); + GC.WaitForPendingFinalizers(); + + subject.Dispose(); + } + + #endregion + + #region JSON String Convenience Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task FetchConfigurationAsync_WithJsonStringOverload_WorksCorrectly() + { + + var jsonString = """{"Name":"TestApp","Version":"1.0.0","EnableLogging":true}"""; + + var options = new ObservableProviderOptions(new BehaviorSubject(jsonString)); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + + var result = await provider.FetchConfigurationBytesAsync(query); + + + Assert.True(result.ToJsonElement().TryGetProperty("Name", out var nameProperty)); + Assert.Equal("TestApp", nameProperty.GetString()); + Assert.True(result.ToJsonElement().TryGetProperty("Version", out var versionProperty)); + Assert.Equal("1.0.0", versionProperty.GetString()); + Assert.True(result.ToJsonElement().TryGetProperty("EnableLogging", out var loggingProperty)); + Assert.True(loggingProperty.GetBoolean()); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Changes_WithJsonStringObservable_PropagatesUpdates() + { + + var initialJson = """{"Name":"InitialApp","Version":"1.0.0"}"""; + var updatedJson = """{"Name":"UpdatedApp","Version":"2.0.0"}"""; + + var subject = new BehaviorSubject(initialJson); + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + var changes = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => changes.Add(e.ToJsonElement())); + + + await Task.Delay(10); // Let initial value emit + subject.OnNext(updatedJson); + await Task.Delay(10); // Let update emit + + + Assert.Equal(2, changes.Count); + + // Initial value + Assert.True(changes[0].TryGetProperty("Name", out var initialName)); + Assert.Equal("InitialApp", initialName.GetString()); + + // Updated value + Assert.True(changes[1].TryGetProperty("Name", out var updatedName)); + Assert.Equal("UpdatedApp", updatedName.GetString()); + + subscription.Dispose(); + subject.Dispose(); + } + + /// + /// Demonstrates the new fluent API: TestRules.Observable(jsonString).For<T>() + /// This makes test writing much simpler! + /// + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public void FluentAPI_Observable_SimplifiesTestWriting() + { + + var jsonString = """{"Name":"TestApp","Value":42,"Enabled":true}"""; + + var rules = new List + { + TestRules.ObservableString(System.Reactive.Linq.Observable.Return(jsonString)) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules)); + + + var config = configManager.GetConfig(); + + + Assert.NotNull(config); + Assert.Equal("TestApp", config.Name); + Assert.Equal(42, config.Value); + Assert.True(config.Enabled); + } + + #endregion + + #region Subscriber Safety Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Reactive_Subscription_Exception_DoesNotTerminateOtherSubscribers() + { + + var testData1 = new TestConfig("Initial", 1, true); + var testData2 = new TestConfig("Updated", 2, false); + var subject = new BehaviorSubject(testData1); + + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + // Create multiple subscribers - one will throw exceptions, others should be unaffected + var goodSubscriber1Values = new List(); + var goodSubscriber2Values = new List(); + var exceptionCount = 0; + + var changes = provider.ChangesAsBytes(query); + + // Good subscriber 1 + var subscription1 = changes.Subscribe( + config => goodSubscriber1Values.Add(config.ToJsonElement()), + ex => { /* Should not be called */ }); + + // Bad subscriber that throws exceptions + var subscription2 = changes.Subscribe( + config => + { + exceptionCount++; + throw new InvalidOperationException($"Test exception {exceptionCount}"); + }, + ex => { /* Exception handled */ }); + + // Good subscriber 2 + var subscription3 = changes.Subscribe( + config => goodSubscriber2Values.Add(config.ToJsonElement()), + ex => { /* Should not be called */ }); + + // Wait for initial values + await ActiveWaitHelpers.WaitUntilAsync( + () => goodSubscriber1Values.Count > 0 && goodSubscriber2Values.Count > 0, + timeout: TimeSpan.FromSeconds(2), + description: "initial emissions to good subscribers"); + + var initialCount1 = goodSubscriber1Values.Count; + var initialCount2 = goodSubscriber2Values.Count; + + + subject.OnNext(testData2); + + // Wait for propagation + await ActiveWaitHelpers.WaitUntilAsync( + () => goodSubscriber1Values.Count > initialCount1 && goodSubscriber2Values.Count > initialCount2, + timeout: TimeSpan.FromSeconds(2), + description: "updated emissions to good subscribers despite bad subscriber exception"); + + + Assert.True(goodSubscriber1Values.Count > initialCount1, "Good subscriber 1 should receive new value"); + Assert.True(goodSubscriber2Values.Count > initialCount2, "Good subscriber 2 should receive new value"); + + // Verify latest values are correct + var latest1 = goodSubscriber1Values.Last(); + var latest2 = goodSubscriber2Values.Last(); + + Assert.Equal("Updated", latest1.GetProperty("Name").GetString()); + Assert.Equal("Updated", latest2.GetProperty("Name").GetString()); + Assert.Equal(2, latest1.GetProperty("Value").GetInt32()); + Assert.Equal(2, latest2.GetProperty("Value").GetInt32()); + + // Cleanup + subscription1.Dispose(); + subscription2.Dispose(); + subscription3.Dispose(); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Reactive_OneSubscriberDispose_DoesNotAffectOthers() + { + + var testData1 = new TestConfig("Initial", 1, true); + var testData2 = new TestConfig("Updated", 2, false); + var testData3 = new TestConfig("Final", 3, true); + var subject = new BehaviorSubject(testData1); + + var options = new ObservableProviderOptions(subject); + var provider = new ObservableProvider(options); + var query = ObservableProviderQuery.Default; + + // Create multiple subscribers + var subscriber1Values = new List(); + var subscriber2Values = new List(); + var subscriber3Values = new List(); + + var changes = provider.ChangesAsBytes(query); + + var subscription1 = changes.Subscribe(config => subscriber1Values.Add(config.ToJsonElement())); + var subscription2 = changes.Subscribe(config => subscriber2Values.Add(config.ToJsonElement())); + var subscription3 = changes.Subscribe(config => subscriber3Values.Add(config.ToJsonElement())); + + // Wait for initial values + await ActiveWaitHelpers.WaitUntilAsync( + () => subscriber1Values.Count > 0 && subscriber2Values.Count > 0 && subscriber3Values.Count > 0, + timeout: TimeSpan.FromSeconds(2), + description: "initial emissions to all subscribers"); + + // All subscribers should have initial value + Assert.True(subscriber1Values.Count > 0, "Subscriber 1 should have initial value"); + Assert.True(subscriber2Values.Count > 0, "Subscriber 2 should have initial value"); + Assert.True(subscriber3Values.Count > 0, "Subscriber 3 should have initial value"); + + + subject.OnNext(testData2); + + // Wait for second emission + await ActiveWaitHelpers.WaitUntilAsync( + () => subscriber1Values.Count >= 2 && subscriber2Values.Count >= 2 && subscriber3Values.Count >= 2, + timeout: TimeSpan.FromSeconds(2), + description: "second emissions to all subscribers"); + + var count1BeforeDispose = subscriber1Values.Count; + var count2BeforeDispose = subscriber2Values.Count; + var count3BeforeDispose = subscriber3Values.Count; + + + subscription2.Dispose(); + + + subject.OnNext(testData3); + + // Wait for third emission + await ActiveWaitHelpers.WaitUntilAsync( + () => subscriber1Values.Count > count1BeforeDispose && subscriber3Values.Count > count3BeforeDispose, + timeout: TimeSpan.FromSeconds(2), + description: "third emissions to remaining subscribers"); + + + Assert.True(subscriber1Values.Count > count1BeforeDispose, + "Subscriber 1 should continue receiving updates after subscriber 2 disposal"); + Assert.True(subscriber3Values.Count > count3BeforeDispose, + "Subscriber 3 should continue receiving updates after subscriber 2 disposal"); + + // Subscriber 2 should stop receiving updates after disposal + Assert.Equal(count2BeforeDispose, subscriber2Values.Count); + + // Verify latest values for active subscribers + var latest1 = subscriber1Values.Last(); + var latest3 = subscriber3Values.Last(); + + Assert.Equal("Final", latest1.GetProperty("Name").GetString()); + Assert.Equal("Final", latest3.GetProperty("Name").GetString()); + Assert.Equal(3, latest1.GetProperty("Value").GetInt32()); + Assert.Equal(3, latest3.GetProperty("Value").GetInt32()); + + // Cleanup + subscription1.Dispose(); + subscription3.Dispose(); + } + + #endregion + + #region Helper Classes + + private class CircularReferenceTest + { + public CircularReferenceTest? Self { get; set; } + public string Name { get; set; } = "Test"; + } + + #endregion } \ No newline at end of file diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Providers/StaticJsonProviderIsolationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Providers/StaticJsonProviderIsolationTests.cs index 4cb67a9..443465c 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Providers/StaticJsonProviderIsolationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Providers/StaticJsonProviderIsolationTests.cs @@ -1,596 +1,596 @@ -using System.Text.Json; -using Cocoar.Configuration.Core.Tests.TestUtilities; -using System.Diagnostics; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Core.Tests.Helpers; - -namespace Cocoar.Configuration.Core.Tests.Providers; - -/// -/// Isolation tests for StaticJsonProvider - testing only deterministic JSON provider functionality -/// without any I/O dependencies. These tests validate core provider behavior including -/// JSON string support, factory functions, error handling, and performance characteristics. -/// -public class StaticJsonProviderIsolationTests -{ - #region Test Configuration Classes - - public record TestConfig(string Name, int Value, bool Enabled); - public record ComplexConfig(string Title, TestNestedConfig Settings, DateTime Timestamp); - public record TestNestedConfig(int Priority, string[] Tags); - public record PerformanceConfig(int Id, string Data, double Score); - - #endregion - - #region Basic Functionality Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public async Task FetchConfigurationAsync_WithSimpleJson_ReturnsCorrectData() - { - - const string json = """{"Name": "TestApp", "Value": 42, "Enabled": true}"""; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - - var result = await provider.FetchConfigurationBytesAsync(query); - - - Assert.Equal("TestApp", result.ToJsonElement().GetProperty("Name").GetString()); - Assert.Equal(42, result.ToJsonElement().GetProperty("Value").GetInt32()); - Assert.True(result.ToJsonElement().GetProperty("Enabled").GetBoolean()); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public async Task FetchConfigurationAsync_WithComplexJson_ReturnsNestedData() - { - - const string json = """ - { - "Title": "Complex Configuration", - "Settings": { - "Priority": 10, - "Tags": ["production", "critical", "monitored"] - }, - "Timestamp": "2025-01-01T12:00:00Z" - } - """; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - - var result = await provider.FetchConfigurationBytesAsync(query); - - - Assert.Equal("Complex Configuration", result.ToJsonElement().GetProperty("Title").GetString()); - Assert.Equal(10, result.ToJsonElement().GetProperty("Settings").GetProperty("Priority").GetInt32()); - - var tags = result.ToJsonElement().GetProperty("Settings").GetProperty("Tags"); - Assert.Equal(3, tags.GetArrayLength()); - Assert.Equal("production", tags[0].GetString()); - Assert.Equal("critical", tags[1].GetString()); - Assert.Equal("monitored", tags[2].GetString()); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public async Task FetchConfigurationAsync_WithEmptyJson_ReturnsEmptyObject() - { - - const string json = "{}"; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - - var result = await provider.FetchConfigurationBytesAsync(query); - - - Assert.Equal(JsonValueKind.Object, result.ToJsonElement().ValueKind); - Assert.Empty(result.ToJsonElement().EnumerateObject()); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public async Task FetchConfigurationAsync_MultipleCalls_ReturnsIdenticalData() - { - - const string json = """{"Name": "Consistent", "Value": 100}"""; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - - var result1 = await provider.FetchConfigurationBytesAsync(query); - var result2 = await provider.FetchConfigurationBytesAsync(query); - var result3 = await provider.FetchConfigurationBytesAsync(query); - - - Assert.Equal(result1.ToJsonElement().GetProperty("Name").GetString(), result2.ToJsonElement().GetProperty("Name").GetString()); - Assert.Equal(result1.ToJsonElement().GetProperty("Name").GetString(), result3.ToJsonElement().GetProperty("Name").GetString()); - Assert.Equal(result1.ToJsonElement().GetProperty("Value").GetInt32(), result2.ToJsonElement().GetProperty("Value").GetInt32()); - Assert.Equal(result1.ToJsonElement().GetProperty("Value").GetInt32(), result3.ToJsonElement().GetProperty("Value").GetInt32()); - } - - #endregion - - #region Error Handling Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public void Constructor_WithMalformedJson_ThrowsJsonException() - { - - // JsonReaderException is a subclass of JsonException, so use ThrowsAny - Assert.ThrowsAny(() => - { - const string malformedJson = """{"Name": "Test", "Value":}"""; // Missing value - using var document = JsonDocument.Parse(malformedJson); - }); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public async Task FetchConfigurationAsync_WithNullValues_HandlesGracefully() - { - - const string json = """{"Name": null, "Value": 42, "OptionalField": null}"""; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - - var result = await provider.FetchConfigurationBytesAsync(query); - - - Assert.Equal(JsonValueKind.Null, result.ToJsonElement().GetProperty("Name").ValueKind); - Assert.Equal(42, result.ToJsonElement().GetProperty("Value").GetInt32()); - Assert.Equal(JsonValueKind.Null, result.ToJsonElement().GetProperty("OptionalField").ValueKind); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public async Task FetchConfigurationAsync_WithVariousDataTypes_HandlesCorrectly() - { - - const string json = """ - { - "stringValue": "hello", - "intValue": 42, - "floatValue": 3.14, - "boolValue": true, - "arrayValue": [1, 2, 3], - "objectValue": {"nested": "data"}, - "nullValue": null - } - """; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - - var result = await provider.FetchConfigurationBytesAsync(query); - - - Assert.Equal("hello", result.ToJsonElement().GetProperty("stringValue").GetString()); - Assert.Equal(42, result.ToJsonElement().GetProperty("intValue").GetInt32()); - Assert.Equal(3.14, result.ToJsonElement().GetProperty("floatValue").GetDouble(), 2); - Assert.True(result.ToJsonElement().GetProperty("boolValue").GetBoolean()); - Assert.Equal(3, result.ToJsonElement().GetProperty("arrayValue").GetArrayLength()); - Assert.Equal("data", result.ToJsonElement().GetProperty("objectValue").GetProperty("nested").GetString()); - Assert.Equal(JsonValueKind.Null, result.ToJsonElement().GetProperty("nullValue").ValueKind); - } - - #endregion - - #region Observable/Reactive Tests - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public async Task Changes_Always_ReturnsEmptyObservable() - { - - const string json = """{"Name": "Static", "Value": 1}"""; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - var emissions = new List(); - var completed = false; - - - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement()), - _ => { }, // OnError - () => completed = true); // OnCompleted - - // Use active waiting to ensure observable behavior is deterministic - await ActiveWaitHelpers.WaitUntilAsync( - () => completed, - timeout: TimeSpan.FromSeconds(5), - description: "Changes observable completion"); - - - Assert.Empty(emissions); - Assert.True(completed); - - subscription.Dispose(); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public async Task Changes_Observable_CompletesImmediately() - { - - const string json = """{"Test": "Value"}"""; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - - await ObservableTestHelpers.WaitForCompletionAsync( - provider.ChangesAsBytes(query), - timeout: TimeSpan.FromSeconds(1), - description: "StaticJsonProvider Changes completion"); - } - - #endregion - - #region Performance Tests - [Fact] - [Trait("Type", "Performance")] - [Trait("Provider", "StaticJsonProvider")] - public async Task FetchConfigurationAsync_SingleRead_PerformanceUnder1ms() - { - - const string json = """{"Name": "Performance", "Value": 123, "Enabled": true}"""; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - // Warm up - await provider.FetchConfigurationBytesAsync(query); - - - var stopwatch = Stopwatch.StartNew(); - var result = await provider.FetchConfigurationBytesAsync(query); - stopwatch.Stop(); - - - Assert.NotEqual(default, result); - Assert.True(stopwatch.ElapsedMilliseconds < 1, - $"StaticJsonProvider read took {stopwatch.ElapsedMilliseconds}ms, expected < 1ms"); - } - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "StaticJsonProvider")] - public async Task FetchConfigurationAsync_1000Reads_PerformanceUnder100ms() - { - - const string json = """{"Name": "Stress", "Value": 999, "Tags": ["perf", "test"]}"""; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - - var stopwatch = Stopwatch.StartNew(); - for (var i = 0; i < 1000; i++) - { - var result = await provider.FetchConfigurationBytesAsync(query); - Assert.NotEqual(default, result); // Minimal validation to ensure work is done - } - stopwatch.Stop(); - - - Assert.True(stopwatch.ElapsedMilliseconds < 100, - $"1000 StaticJsonProvider reads took {stopwatch.ElapsedMilliseconds}ms, expected < 100ms"); - } - - #endregion - - #region Concurrency Testing - [Fact] - [Trait("Type", "Concurrency")] - [Trait("Provider", "StaticJsonProvider")] - public async Task FetchConfigurationAsync_ConcurrentAccess_NoRaceConditions() - { - - const string json = """{"ThreadSafe": true, "Value": 42}"""; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - const int threadCount = 10; - const int operationsPerThread = 100; - var results = new List[threadCount]; - var exceptions = new List(); - - - var tasks = Enumerable.Range(0, threadCount).Select(async threadId => - { - results[threadId] = new(); - try - { - for (var i = 0; i < operationsPerThread; i++) - { - var result = await provider.FetchConfigurationBytesAsync(query); - results[threadId].Add(result.ToJsonElement()); - } - } - catch (Exception ex) - { - lock (exceptions) - { - exceptions.Add(ex); - } - } - }).ToArray(); - - await Task.WhenAll(tasks); - - - Assert.Empty(exceptions); - - - for (var i = 0; i < threadCount; i++) - { - Assert.Equal(operationsPerThread, results[i].Count); - - - foreach (var result in results[i]) - { - Assert.True(result.GetProperty("ThreadSafe").GetBoolean()); - Assert.Equal(42, result.GetProperty("Value").GetInt32()); - } - } - } - [Fact] - [Trait("Type", "Concurrency")] - [Trait("Provider", "StaticJsonProvider")] - public void Changes_ConcurrentSubscriptions_ConsistentBehavior() - { - - const string json = """{"Concurrent": "Test"}"""; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - const int subscriberCount = 20; - var completedCount = 0; - var emissionCounts = new int[subscriberCount]; - var subscriptions = new IDisposable[subscriberCount]; - - - var completedCountdown = new CountdownEvent(subscriberCount); - - for (var i = 0; i < subscriberCount; i++) - { - var subscriberId = i; // Capture for closure - subscriptions[i] = provider.ChangesAsBytes(query).Subscribe( - _ => Interlocked.Increment(ref emissionCounts[subscriberId]), - _ => { }, // OnError - () => - { - Interlocked.Increment(ref completedCount); - completedCountdown.Signal(); - }); - } - - // Wait for all observables to complete - var completed = completedCountdown.Wait(TimeSpan.FromSeconds(5)); - Assert.True(completed, "Not all Changes observables completed within timeout"); - - - Assert.Equal(subscriberCount, completedCount); - for (var i = 0; i < subscriberCount; i++) - { - Assert.Equal(0, emissionCounts[i]); - subscriptions[i].Dispose(); - } - - completedCountdown.Dispose(); - } - - #endregion - - #region Factory Function Tests (Rule Creation) - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public void CreateRule_WithJsonElement_CreatesValidRule() - { - const string json = """{"Name": "Test", "Value": 789, "Enabled": true}"""; - - // Verify rule can be used in ConfigManager - using var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(json) - ])); - var config = manager.GetConfig(); - - Assert.NotNull(config); - Assert.Equal("Test", config!.Name); - Assert.Equal(789, config.Value); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public void CreateRule_WithJsonString_CreatesValidRule() - { - const string json = """{"Name": "Works", "Value": 456, "Enabled": false}"""; - - // Verify rule works correctly - using var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(json) - ])); - var config = manager.GetConfig(); - - Assert.NotNull(config); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public void CreateRule_WithRequiredFlag_SetsRequiredCorrectly() - { - const string json = """{"Name": "Required", "Value": 123, "Enabled": true}"""; - - // Verify required flag is set by checking health service - using var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(json).Required() - ])); - // Simplified health API - just verify the health status is Healthy - Assert.Equal(Cocoar.Configuration.Health.HealthStatus.Healthy, manager.HealthStatus); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public void CreateRule_WithUseWhen_SetsConditionCorrectly() - { - const string json = """{"Name": "Conditional", "Value": 999, "Enabled": true}"""; - Func useWhen = (_) => Environment.GetEnvironmentVariable("TEST_ENV") == "true"; - - // Verify the rule can be created and used - using var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(json).When(useWhen) - ])); - - // Use TryGetConfig since the rule may be skipped depending on TEST_ENV - // GetConfig would throw if the condition is false and rule is skipped - var hasConfig = manager.TryGetConfig(out _); - // hasConfig will be true if TEST_ENV == "true", false otherwise - } - - #endregion - - #region Edge Cases and Boundary Tests - [Fact] - [Trait("Type", "Performance")] - [Trait("Provider", "StaticJsonProvider")] - public async Task FetchConfigurationAsync_LargeJson_HandlesEfficiently() - { - - var largeData = new Dictionary(); - for (var i = 0; i < 1000; i++) - { - largeData[$"Property{i}"] = $"Value{i}"; - largeData[$"Number{i}"] = i; - largeData[$"Boolean{i}"] = i % 2 == 0; - } - - var json = JsonSerializer.Serialize(largeData); - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - - var stopwatch = Stopwatch.StartNew(); - var result = await provider.FetchConfigurationBytesAsync(query); - stopwatch.Stop(); - - - Assert.NotEqual(default, result); - Assert.True(result.ToJsonElement().EnumerateObject().Count() >= 1000); - Assert.True(stopwatch.ElapsedMilliseconds < 10, - $"Large JSON fetch took {stopwatch.ElapsedMilliseconds}ms, expected < 10ms"); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public async Task FetchConfigurationAsync_DeeplyNested_ParsesCorrectly() - { - - const string json = """ - { - "Level1": { - "Level2": { - "Level3": { - "Level4": { - "Level5": { - "Level6": { - "Level7": { - "Level8": { - "Level9": { - "Level10": { - "DeepValue": "Found at level 10!", - "DeepNumber": 42 - } - } - } - } - } - } - } - } - } - } - } - """; - - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - - var result = await provider.FetchConfigurationBytesAsync(query); - - - var deepValue = result.ToJsonElement().GetProperty("Level1") - .GetProperty("Level2") - .GetProperty("Level3") - .GetProperty("Level4") - .GetProperty("Level5") - .GetProperty("Level6") - .GetProperty("Level7") - .GetProperty("Level8") - .GetProperty("Level9") - .GetProperty("Level10"); - - Assert.Equal("Found at level 10!", deepValue.GetProperty("DeepValue").GetString()); - Assert.Equal(42, deepValue.GetProperty("DeepNumber").GetInt32()); - } - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "StaticJsonProvider")] - public async Task FetchConfigurationAsync_WithCancellation_HandlesGracefully() - { - - const string json = """{"Cancellable": true}"""; - using var document = JsonDocument.Parse(json); - var options = new StaticJsonProviderOptions(document.RootElement.Clone()); - var provider = new StaticJsonProvider(options); - var query = new StaticJsonProviderQueryOptions(); - - using var cancellationTokenSource = new CancellationTokenSource(); - - - var result = await provider.FetchConfigurationBytesAsync(query, cancellationTokenSource.Token); - - - Assert.True(result.ToJsonElement().GetProperty("Cancellable").GetBoolean()); - - - cancellationTokenSource.Cancel(); - var result2 = await provider.FetchConfigurationBytesAsync(query, cancellationTokenSource.Token); - - - Assert.True(result2.ToJsonElement().GetProperty("Cancellable").GetBoolean()); - } - - #endregion +using System.Text.Json; +using Cocoar.Configuration.Core.Tests.TestUtilities; +using System.Diagnostics; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Core.Tests.Helpers; + +namespace Cocoar.Configuration.Core.Tests.Providers; + +/// +/// Isolation tests for StaticJsonProvider - testing only deterministic JSON provider functionality +/// without any I/O dependencies. These tests validate core provider behavior including +/// JSON string support, factory functions, error handling, and performance characteristics. +/// +public class StaticJsonProviderIsolationTests +{ + #region Test Configuration Classes + + public record TestConfig(string Name, int Value, bool Enabled); + public record ComplexConfig(string Title, TestNestedConfig Settings, DateTime Timestamp); + public record TestNestedConfig(int Priority, string[] Tags); + public record PerformanceConfig(int Id, string Data, double Score); + + #endregion + + #region Basic Functionality Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public async Task FetchConfigurationAsync_WithSimpleJson_ReturnsCorrectData() + { + + const string json = """{"Name": "TestApp", "Value": 42, "Enabled": true}"""; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + + var result = await provider.FetchConfigurationBytesAsync(query); + + + Assert.Equal("TestApp", result.ToJsonElement().GetProperty("Name").GetString()); + Assert.Equal(42, result.ToJsonElement().GetProperty("Value").GetInt32()); + Assert.True(result.ToJsonElement().GetProperty("Enabled").GetBoolean()); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public async Task FetchConfigurationAsync_WithComplexJson_ReturnsNestedData() + { + + const string json = """ + { + "Title": "Complex Configuration", + "Settings": { + "Priority": 10, + "Tags": ["production", "critical", "monitored"] + }, + "Timestamp": "2025-01-01T12:00:00Z" + } + """; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + + var result = await provider.FetchConfigurationBytesAsync(query); + + + Assert.Equal("Complex Configuration", result.ToJsonElement().GetProperty("Title").GetString()); + Assert.Equal(10, result.ToJsonElement().GetProperty("Settings").GetProperty("Priority").GetInt32()); + + var tags = result.ToJsonElement().GetProperty("Settings").GetProperty("Tags"); + Assert.Equal(3, tags.GetArrayLength()); + Assert.Equal("production", tags[0].GetString()); + Assert.Equal("critical", tags[1].GetString()); + Assert.Equal("monitored", tags[2].GetString()); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public async Task FetchConfigurationAsync_WithEmptyJson_ReturnsEmptyObject() + { + + const string json = "{}"; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + + var result = await provider.FetchConfigurationBytesAsync(query); + + + Assert.Equal(JsonValueKind.Object, result.ToJsonElement().ValueKind); + Assert.Empty(result.ToJsonElement().EnumerateObject()); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public async Task FetchConfigurationAsync_MultipleCalls_ReturnsIdenticalData() + { + + const string json = """{"Name": "Consistent", "Value": 100}"""; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + + var result1 = await provider.FetchConfigurationBytesAsync(query); + var result2 = await provider.FetchConfigurationBytesAsync(query); + var result3 = await provider.FetchConfigurationBytesAsync(query); + + + Assert.Equal(result1.ToJsonElement().GetProperty("Name").GetString(), result2.ToJsonElement().GetProperty("Name").GetString()); + Assert.Equal(result1.ToJsonElement().GetProperty("Name").GetString(), result3.ToJsonElement().GetProperty("Name").GetString()); + Assert.Equal(result1.ToJsonElement().GetProperty("Value").GetInt32(), result2.ToJsonElement().GetProperty("Value").GetInt32()); + Assert.Equal(result1.ToJsonElement().GetProperty("Value").GetInt32(), result3.ToJsonElement().GetProperty("Value").GetInt32()); + } + + #endregion + + #region Error Handling Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public void Constructor_WithMalformedJson_ThrowsJsonException() + { + + // JsonReaderException is a subclass of JsonException, so use ThrowsAny + Assert.ThrowsAny(() => + { + const string malformedJson = """{"Name": "Test", "Value":}"""; // Missing value + using var document = JsonDocument.Parse(malformedJson); + }); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public async Task FetchConfigurationAsync_WithNullValues_HandlesGracefully() + { + + const string json = """{"Name": null, "Value": 42, "OptionalField": null}"""; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + + var result = await provider.FetchConfigurationBytesAsync(query); + + + Assert.Equal(JsonValueKind.Null, result.ToJsonElement().GetProperty("Name").ValueKind); + Assert.Equal(42, result.ToJsonElement().GetProperty("Value").GetInt32()); + Assert.Equal(JsonValueKind.Null, result.ToJsonElement().GetProperty("OptionalField").ValueKind); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public async Task FetchConfigurationAsync_WithVariousDataTypes_HandlesCorrectly() + { + + const string json = """ + { + "stringValue": "hello", + "intValue": 42, + "floatValue": 3.14, + "boolValue": true, + "arrayValue": [1, 2, 3], + "objectValue": {"nested": "data"}, + "nullValue": null + } + """; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + + var result = await provider.FetchConfigurationBytesAsync(query); + + + Assert.Equal("hello", result.ToJsonElement().GetProperty("stringValue").GetString()); + Assert.Equal(42, result.ToJsonElement().GetProperty("intValue").GetInt32()); + Assert.Equal(3.14, result.ToJsonElement().GetProperty("floatValue").GetDouble(), 2); + Assert.True(result.ToJsonElement().GetProperty("boolValue").GetBoolean()); + Assert.Equal(3, result.ToJsonElement().GetProperty("arrayValue").GetArrayLength()); + Assert.Equal("data", result.ToJsonElement().GetProperty("objectValue").GetProperty("nested").GetString()); + Assert.Equal(JsonValueKind.Null, result.ToJsonElement().GetProperty("nullValue").ValueKind); + } + + #endregion + + #region Observable/Reactive Tests + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public async Task Changes_Always_ReturnsEmptyObservable() + { + + const string json = """{"Name": "Static", "Value": 1}"""; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + var emissions = new List(); + var completed = false; + + + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement()), + _ => { }, // OnError + () => completed = true); // OnCompleted + + // Use active waiting to ensure observable behavior is deterministic + await ActiveWaitHelpers.WaitUntilAsync( + () => completed, + timeout: TimeSpan.FromSeconds(5), + description: "Changes observable completion"); + + + Assert.Empty(emissions); + Assert.True(completed); + + subscription.Dispose(); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public async Task Changes_Observable_CompletesImmediately() + { + + const string json = """{"Test": "Value"}"""; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + + await ObservableTestHelpers.WaitForCompletionAsync( + provider.ChangesAsBytes(query), + timeout: TimeSpan.FromSeconds(1), + description: "StaticJsonProvider Changes completion"); + } + + #endregion + + #region Performance Tests + [Fact] + [Trait("Type", "Performance")] + [Trait("Provider", "StaticJsonProvider")] + public async Task FetchConfigurationAsync_SingleRead_PerformanceUnder1ms() + { + + const string json = """{"Name": "Performance", "Value": 123, "Enabled": true}"""; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + // Warm up + await provider.FetchConfigurationBytesAsync(query); + + + var stopwatch = Stopwatch.StartNew(); + var result = await provider.FetchConfigurationBytesAsync(query); + stopwatch.Stop(); + + + Assert.NotEqual(default, result); + Assert.True(stopwatch.ElapsedMilliseconds < 1, + $"StaticJsonProvider read took {stopwatch.ElapsedMilliseconds}ms, expected < 1ms"); + } + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "StaticJsonProvider")] + public async Task FetchConfigurationAsync_1000Reads_PerformanceUnder100ms() + { + + const string json = """{"Name": "Stress", "Value": 999, "Tags": ["perf", "test"]}"""; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + + var stopwatch = Stopwatch.StartNew(); + for (var i = 0; i < 1000; i++) + { + var result = await provider.FetchConfigurationBytesAsync(query); + Assert.NotEqual(default, result); // Minimal validation to ensure work is done + } + stopwatch.Stop(); + + + Assert.True(stopwatch.ElapsedMilliseconds < 100, + $"1000 StaticJsonProvider reads took {stopwatch.ElapsedMilliseconds}ms, expected < 100ms"); + } + + #endregion + + #region Concurrency Testing + [Fact] + [Trait("Type", "Concurrency")] + [Trait("Provider", "StaticJsonProvider")] + public async Task FetchConfigurationAsync_ConcurrentAccess_NoRaceConditions() + { + + const string json = """{"ThreadSafe": true, "Value": 42}"""; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + const int threadCount = 10; + const int operationsPerThread = 100; + var results = new List[threadCount]; + var exceptions = new List(); + + + var tasks = Enumerable.Range(0, threadCount).Select(async threadId => + { + results[threadId] = new(); + try + { + for (var i = 0; i < operationsPerThread; i++) + { + var result = await provider.FetchConfigurationBytesAsync(query); + results[threadId].Add(result.ToJsonElement()); + } + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + }).ToArray(); + + await Task.WhenAll(tasks); + + + Assert.Empty(exceptions); + + + for (var i = 0; i < threadCount; i++) + { + Assert.Equal(operationsPerThread, results[i].Count); + + + foreach (var result in results[i]) + { + Assert.True(result.GetProperty("ThreadSafe").GetBoolean()); + Assert.Equal(42, result.GetProperty("Value").GetInt32()); + } + } + } + [Fact] + [Trait("Type", "Concurrency")] + [Trait("Provider", "StaticJsonProvider")] + public void Changes_ConcurrentSubscriptions_ConsistentBehavior() + { + + const string json = """{"Concurrent": "Test"}"""; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + const int subscriberCount = 20; + var completedCount = 0; + var emissionCounts = new int[subscriberCount]; + var subscriptions = new IDisposable[subscriberCount]; + + + var completedCountdown = new CountdownEvent(subscriberCount); + + for (var i = 0; i < subscriberCount; i++) + { + var subscriberId = i; // Capture for closure + subscriptions[i] = provider.ChangesAsBytes(query).Subscribe( + _ => Interlocked.Increment(ref emissionCounts[subscriberId]), + _ => { }, // OnError + () => + { + Interlocked.Increment(ref completedCount); + completedCountdown.Signal(); + }); + } + + // Wait for all observables to complete + var completed = completedCountdown.Wait(TimeSpan.FromSeconds(5)); + Assert.True(completed, "Not all Changes observables completed within timeout"); + + + Assert.Equal(subscriberCount, completedCount); + for (var i = 0; i < subscriberCount; i++) + { + Assert.Equal(0, emissionCounts[i]); + subscriptions[i].Dispose(); + } + + completedCountdown.Dispose(); + } + + #endregion + + #region Factory Function Tests (Rule Creation) + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public void CreateRule_WithJsonElement_CreatesValidRule() + { + const string json = """{"Name": "Test", "Value": 789, "Enabled": true}"""; + + // Verify rule can be used in ConfigManager + using var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(json) + ])); + var config = manager.GetConfig(); + + Assert.NotNull(config); + Assert.Equal("Test", config!.Name); + Assert.Equal(789, config.Value); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public void CreateRule_WithJsonString_CreatesValidRule() + { + const string json = """{"Name": "Works", "Value": 456, "Enabled": false}"""; + + // Verify rule works correctly + using var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(json) + ])); + var config = manager.GetConfig(); + + Assert.NotNull(config); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public void CreateRule_WithRequiredFlag_SetsRequiredCorrectly() + { + const string json = """{"Name": "Required", "Value": 123, "Enabled": true}"""; + + // Verify required flag is set by checking health service + using var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(json).Required() + ])); + // Simplified health API - just verify the health status is Healthy + Assert.Equal(Cocoar.Configuration.Health.HealthStatus.Healthy, manager.HealthStatus); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public void CreateRule_WithUseWhen_SetsConditionCorrectly() + { + const string json = """{"Name": "Conditional", "Value": 999, "Enabled": true}"""; + Func useWhen = (_) => Environment.GetEnvironmentVariable("TEST_ENV") == "true"; + + // Verify the rule can be created and used + using var manager = ConfigManager.Create(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(json).When(useWhen) + ])); + + // Use TryGetConfig since the rule may be skipped depending on TEST_ENV + // GetConfig would throw if the condition is false and rule is skipped + var hasConfig = manager.TryGetConfig(out _); + // hasConfig will be true if TEST_ENV == "true", false otherwise + } + + #endregion + + #region Edge Cases and Boundary Tests + [Fact] + [Trait("Type", "Performance")] + [Trait("Provider", "StaticJsonProvider")] + public async Task FetchConfigurationAsync_LargeJson_HandlesEfficiently() + { + + var largeData = new Dictionary(); + for (var i = 0; i < 1000; i++) + { + largeData[$"Property{i}"] = $"Value{i}"; + largeData[$"Number{i}"] = i; + largeData[$"Boolean{i}"] = i % 2 == 0; + } + + var json = JsonSerializer.Serialize(largeData); + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + + var stopwatch = Stopwatch.StartNew(); + var result = await provider.FetchConfigurationBytesAsync(query); + stopwatch.Stop(); + + + Assert.NotEqual(default, result); + Assert.True(result.ToJsonElement().EnumerateObject().Count() >= 1000); + Assert.True(stopwatch.ElapsedMilliseconds < 10, + $"Large JSON fetch took {stopwatch.ElapsedMilliseconds}ms, expected < 10ms"); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public async Task FetchConfigurationAsync_DeeplyNested_ParsesCorrectly() + { + + const string json = """ + { + "Level1": { + "Level2": { + "Level3": { + "Level4": { + "Level5": { + "Level6": { + "Level7": { + "Level8": { + "Level9": { + "Level10": { + "DeepValue": "Found at level 10!", + "DeepNumber": 42 + } + } + } + } + } + } + } + } + } + } + } + """; + + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + + var result = await provider.FetchConfigurationBytesAsync(query); + + + var deepValue = result.ToJsonElement().GetProperty("Level1") + .GetProperty("Level2") + .GetProperty("Level3") + .GetProperty("Level4") + .GetProperty("Level5") + .GetProperty("Level6") + .GetProperty("Level7") + .GetProperty("Level8") + .GetProperty("Level9") + .GetProperty("Level10"); + + Assert.Equal("Found at level 10!", deepValue.GetProperty("DeepValue").GetString()); + Assert.Equal(42, deepValue.GetProperty("DeepNumber").GetInt32()); + } + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "StaticJsonProvider")] + public async Task FetchConfigurationAsync_WithCancellation_HandlesGracefully() + { + + const string json = """{"Cancellable": true}"""; + using var document = JsonDocument.Parse(json); + var options = new StaticJsonProviderOptions(document.RootElement.Clone()); + var provider = new StaticJsonProvider(options); + var query = new StaticJsonProviderQueryOptions(); + + using var cancellationTokenSource = new CancellationTokenSource(); + + + var result = await provider.FetchConfigurationBytesAsync(query, cancellationTokenSource.Token); + + + Assert.True(result.ToJsonElement().GetProperty("Cancellable").GetBoolean()); + + + cancellationTokenSource.Cancel(); + var result2 = await provider.FetchConfigurationBytesAsync(query, cancellationTokenSource.Token); + + + Assert.True(result2.ToJsonElement().GetProperty("Cancellable").GetBoolean()); + } + + #endregion } \ No newline at end of file diff --git a/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/ActiveWaitHelpers.cs b/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/ActiveWaitHelpers.cs index b0e9a90..e6cd895 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/ActiveWaitHelpers.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/ActiveWaitHelpers.cs @@ -1,307 +1,307 @@ -namespace Cocoar.Configuration.Core.Tests.TestUtilities; - -/// -/// Active waiting helpers for bulletproof testing. -/// Provides utilities for condition-based waiting instead of fixed timing delays. -/// This is the foundation of reliable cross-platform testing. -/// -public static class ActiveWaitHelpers -{ - /// - /// Wait for a condition to become true using active polling instead of fixed delays. - /// This is the core pattern for bulletproof testing - never use Thread.Sleep or Task.Delay - /// for test synchronization! - /// - /// Condition to check repeatedly - /// Maximum time to wait (default: 15 seconds) - /// Interval between condition checks (default: 50ms) - /// Description for debugging failures - /// Task that completes when condition is true - /// Thrown when timeout is exceeded - public static async Task WaitUntilAsync( - Func condition, - TimeSpan timeout = default, - TimeSpan pollInterval = default, - string description = "condition") - { - timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; - pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; - - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - - while (stopwatch.Elapsed < timeout) - { - try - { - if (condition()) - { - return; - } - } - catch - { - // Condition threw (e.g., accessing property on incomplete state) - treat as "not yet met" - } - - await Task.Delay(pollInterval); - } - - throw new TimeoutException($"Timeout waiting for {description} after {timeout}"); - } - - /// - /// Wait for an async condition to become true using active polling. - /// Use this for conditions that require async operations to evaluate. - /// - /// Async condition to check repeatedly - /// Maximum time to wait (default: 15 seconds) - /// Interval between condition checks (default: 50ms) - /// Description for debugging failures - /// Task that completes when condition is true - /// Thrown when timeout is exceeded - public static async Task WaitUntilAsync( - Func> condition, - TimeSpan timeout = default, - TimeSpan pollInterval = default, - string description = "async condition") - { - timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; - pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; - - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - - while (stopwatch.Elapsed < timeout) - { - try - { - if (await condition()) - { - return; - } - } - catch - { - // Condition threw (e.g., accessing property on incomplete state) - treat as "not yet met" - } - - await Task.Delay(pollInterval); - } - - throw new TimeoutException($"Timeout waiting for {description} after {timeout}"); - } - - /// - /// Wait for a value source to return a specific value. - /// This is perfect for waiting for configuration updates, file changes, etc. - /// - /// Type of value to check - /// Function that returns the current value - /// The expected value to wait for - /// Maximum time to wait (default: 15 seconds) - /// Interval between checks (default: 50ms) - /// Description for debugging failures - /// Task that completes when value matches expected - /// Thrown when timeout is exceeded - public static async Task WaitForValueAsync( - Func valueSource, - T expectedValue, - TimeSpan timeout = default, - TimeSpan pollInterval = default, - string description = "expected value") - { - timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; - pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; - - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - - while (stopwatch.Elapsed < timeout) - { - var currentValue = valueSource(); - if (EqualityComparer.Default.Equals(currentValue, expectedValue)) - { - return; - } - - await Task.Delay(pollInterval); - } - - var finalValue = valueSource(); - throw new TimeoutException( - $"Timeout waiting for {description} after {timeout}. " + - $"Expected: {expectedValue}, Got: {finalValue}"); - } - - /// - /// Wait for an async value source to return a specific value. - /// Use this for async operations like configuration reads. - /// - /// Type of value to check - /// Async function that returns the current value - /// The expected value to wait for - /// Maximum time to wait (default: 15 seconds) - /// Interval between checks (default: 50ms) - /// Description for debugging failures - /// Task that completes when value matches expected - /// Thrown when timeout is exceeded - public static async Task WaitForValueAsync( - Func> valueSource, - T expectedValue, - TimeSpan timeout = default, - TimeSpan pollInterval = default, - string description = "expected value") - { - timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; - pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; - - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - - while (stopwatch.Elapsed < timeout) - { - var currentValue = await valueSource(); - if (EqualityComparer.Default.Equals(currentValue, expectedValue)) - { - return; - } - - await Task.Delay(pollInterval); - } - - var finalValue = await valueSource(); - throw new TimeoutException( - $"Timeout waiting for {description} after {timeout}. " + - $"Expected: {expectedValue}, Got: {finalValue}"); - } - - /// - /// Wait for a value to match a specific predicate condition. - /// This is the most flexible waiting pattern for complex conditions. - /// - /// Type of value to check - /// Function that returns the current value - /// Condition that must be satisfied - /// Maximum time to wait (default: 15 seconds) - /// Interval between checks (default: 50ms) - /// Description for debugging failures - /// The value that satisfied the condition - /// Thrown when timeout is exceeded - public static async Task WaitForConditionAsync( - Func valueSource, - Func predicate, - TimeSpan timeout = default, - TimeSpan pollInterval = default, - string description = "condition") - { - timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; - pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; - - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - - while (stopwatch.Elapsed < timeout) - { - var currentValue = valueSource(); - if (predicate(currentValue)) - { - return currentValue; - } - - await Task.Delay(pollInterval); - } - - var finalValue = valueSource(); - throw new TimeoutException( - $"Timeout waiting for {description} after {timeout}. Final value: {finalValue}"); - } - - /// - /// Wait for an async value to match a specific predicate condition. - /// - /// Type of value to check - /// Async function that returns the current value - /// Condition that must be satisfied - /// Maximum time to wait (default: 15 seconds) - /// Interval between checks (default: 50ms) - /// Description for debugging failures - /// The value that satisfied the condition - /// Thrown when timeout is exceeded - public static async Task WaitForConditionAsync( - Func> valueSource, - Func predicate, - TimeSpan timeout = default, - TimeSpan pollInterval = default, - string description = "condition") - { - timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; - pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; - - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - - while (stopwatch.Elapsed < timeout) - { - var currentValue = await valueSource(); - if (predicate(currentValue)) - { - return currentValue; - } - - await Task.Delay(pollInterval); - } - - var finalValue = await valueSource(); - throw new TimeoutException( - $"Timeout waiting for {description} after {timeout}. Final value: {finalValue}"); - } - - /// - /// Wait for a value to stabilize (remain unchanged for a period). - /// Useful for waiting for debouncing to settle or operations to complete. - /// - /// Type of value to monitor - /// Function that returns the current value - /// How long value must remain stable (default: 200ms) - /// Maximum time to wait (default: 15 seconds) - /// Interval between checks (default: 50ms) - /// Description for debugging failures - /// The stable value - /// Thrown when timeout is exceeded - public static async Task WaitForStableValueAsync( - Func valueSource, - TimeSpan stabilityPeriod = default, - TimeSpan timeout = default, - TimeSpan pollInterval = default, - string description = "stable value") - { - stabilityPeriod = stabilityPeriod == default ? TimeSpan.FromMilliseconds(200) : stabilityPeriod; - timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; - pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; - - var overallStopwatch = System.Diagnostics.Stopwatch.StartNew(); - var stabilityStopwatch = System.Diagnostics.Stopwatch.StartNew(); - - T lastValue = valueSource(); - T currentValue; - - while (overallStopwatch.Elapsed < timeout) - { - await Task.Delay(pollInterval); - currentValue = valueSource(); - - if (EqualityComparer.Default.Equals(currentValue, lastValue)) - { - // Value is the same, check if we've been stable long enough - if (stabilityStopwatch.Elapsed >= stabilityPeriod) - { - return currentValue; - } - } - else - { - // Value changed, reset stability timer - lastValue = currentValue; - stabilityStopwatch.Restart(); - } - } - - throw new TimeoutException( - $"Timeout waiting for {description} to stabilize after {timeout}"); - } -} +namespace Cocoar.Configuration.Core.Tests.TestUtilities; + +/// +/// Active waiting helpers for bulletproof testing. +/// Provides utilities for condition-based waiting instead of fixed timing delays. +/// This is the foundation of reliable cross-platform testing. +/// +public static class ActiveWaitHelpers +{ + /// + /// Wait for a condition to become true using active polling instead of fixed delays. + /// This is the core pattern for bulletproof testing - never use Thread.Sleep or Task.Delay + /// for test synchronization! + /// + /// Condition to check repeatedly + /// Maximum time to wait (default: 15 seconds) + /// Interval between condition checks (default: 50ms) + /// Description for debugging failures + /// Task that completes when condition is true + /// Thrown when timeout is exceeded + public static async Task WaitUntilAsync( + Func condition, + TimeSpan timeout = default, + TimeSpan pollInterval = default, + string description = "condition") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + try + { + if (condition()) + { + return; + } + } + catch + { + // Condition threw (e.g., accessing property on incomplete state) - treat as "not yet met" + } + + await Task.Delay(pollInterval); + } + + throw new TimeoutException($"Timeout waiting for {description} after {timeout}"); + } + + /// + /// Wait for an async condition to become true using active polling. + /// Use this for conditions that require async operations to evaluate. + /// + /// Async condition to check repeatedly + /// Maximum time to wait (default: 15 seconds) + /// Interval between condition checks (default: 50ms) + /// Description for debugging failures + /// Task that completes when condition is true + /// Thrown when timeout is exceeded + public static async Task WaitUntilAsync( + Func> condition, + TimeSpan timeout = default, + TimeSpan pollInterval = default, + string description = "async condition") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + try + { + if (await condition()) + { + return; + } + } + catch + { + // Condition threw (e.g., accessing property on incomplete state) - treat as "not yet met" + } + + await Task.Delay(pollInterval); + } + + throw new TimeoutException($"Timeout waiting for {description} after {timeout}"); + } + + /// + /// Wait for a value source to return a specific value. + /// This is perfect for waiting for configuration updates, file changes, etc. + /// + /// Type of value to check + /// Function that returns the current value + /// The expected value to wait for + /// Maximum time to wait (default: 15 seconds) + /// Interval between checks (default: 50ms) + /// Description for debugging failures + /// Task that completes when value matches expected + /// Thrown when timeout is exceeded + public static async Task WaitForValueAsync( + Func valueSource, + T expectedValue, + TimeSpan timeout = default, + TimeSpan pollInterval = default, + string description = "expected value") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + var currentValue = valueSource(); + if (EqualityComparer.Default.Equals(currentValue, expectedValue)) + { + return; + } + + await Task.Delay(pollInterval); + } + + var finalValue = valueSource(); + throw new TimeoutException( + $"Timeout waiting for {description} after {timeout}. " + + $"Expected: {expectedValue}, Got: {finalValue}"); + } + + /// + /// Wait for an async value source to return a specific value. + /// Use this for async operations like configuration reads. + /// + /// Type of value to check + /// Async function that returns the current value + /// The expected value to wait for + /// Maximum time to wait (default: 15 seconds) + /// Interval between checks (default: 50ms) + /// Description for debugging failures + /// Task that completes when value matches expected + /// Thrown when timeout is exceeded + public static async Task WaitForValueAsync( + Func> valueSource, + T expectedValue, + TimeSpan timeout = default, + TimeSpan pollInterval = default, + string description = "expected value") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + var currentValue = await valueSource(); + if (EqualityComparer.Default.Equals(currentValue, expectedValue)) + { + return; + } + + await Task.Delay(pollInterval); + } + + var finalValue = await valueSource(); + throw new TimeoutException( + $"Timeout waiting for {description} after {timeout}. " + + $"Expected: {expectedValue}, Got: {finalValue}"); + } + + /// + /// Wait for a value to match a specific predicate condition. + /// This is the most flexible waiting pattern for complex conditions. + /// + /// Type of value to check + /// Function that returns the current value + /// Condition that must be satisfied + /// Maximum time to wait (default: 15 seconds) + /// Interval between checks (default: 50ms) + /// Description for debugging failures + /// The value that satisfied the condition + /// Thrown when timeout is exceeded + public static async Task WaitForConditionAsync( + Func valueSource, + Func predicate, + TimeSpan timeout = default, + TimeSpan pollInterval = default, + string description = "condition") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + var currentValue = valueSource(); + if (predicate(currentValue)) + { + return currentValue; + } + + await Task.Delay(pollInterval); + } + + var finalValue = valueSource(); + throw new TimeoutException( + $"Timeout waiting for {description} after {timeout}. Final value: {finalValue}"); + } + + /// + /// Wait for an async value to match a specific predicate condition. + /// + /// Type of value to check + /// Async function that returns the current value + /// Condition that must be satisfied + /// Maximum time to wait (default: 15 seconds) + /// Interval between checks (default: 50ms) + /// Description for debugging failures + /// The value that satisfied the condition + /// Thrown when timeout is exceeded + public static async Task WaitForConditionAsync( + Func> valueSource, + Func predicate, + TimeSpan timeout = default, + TimeSpan pollInterval = default, + string description = "condition") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + var currentValue = await valueSource(); + if (predicate(currentValue)) + { + return currentValue; + } + + await Task.Delay(pollInterval); + } + + var finalValue = await valueSource(); + throw new TimeoutException( + $"Timeout waiting for {description} after {timeout}. Final value: {finalValue}"); + } + + /// + /// Wait for a value to stabilize (remain unchanged for a period). + /// Useful for waiting for debouncing to settle or operations to complete. + /// + /// Type of value to monitor + /// Function that returns the current value + /// How long value must remain stable (default: 200ms) + /// Maximum time to wait (default: 15 seconds) + /// Interval between checks (default: 50ms) + /// Description for debugging failures + /// The stable value + /// Thrown when timeout is exceeded + public static async Task WaitForStableValueAsync( + Func valueSource, + TimeSpan stabilityPeriod = default, + TimeSpan timeout = default, + TimeSpan pollInterval = default, + string description = "stable value") + { + stabilityPeriod = stabilityPeriod == default ? TimeSpan.FromMilliseconds(200) : stabilityPeriod; + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; + + var overallStopwatch = System.Diagnostics.Stopwatch.StartNew(); + var stabilityStopwatch = System.Diagnostics.Stopwatch.StartNew(); + + T lastValue = valueSource(); + T currentValue; + + while (overallStopwatch.Elapsed < timeout) + { + await Task.Delay(pollInterval); + currentValue = valueSource(); + + if (EqualityComparer.Default.Equals(currentValue, lastValue)) + { + // Value is the same, check if we've been stable long enough + if (stabilityStopwatch.Elapsed >= stabilityPeriod) + { + return currentValue; + } + } + else + { + // Value changed, reset stability timer + lastValue = currentValue; + stabilityStopwatch.Restart(); + } + } + + throw new TimeoutException( + $"Timeout waiting for {description} to stabilize after {timeout}"); + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/FailableProvider.cs b/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/FailableProvider.cs index 8de5b11..7ed802c 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/FailableProvider.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/FailableProvider.cs @@ -1,147 +1,147 @@ -using System.Reactive.Linq; -using System.Text.Json; - -using Cocoar.Configuration.Core.Tests.Helpers; - -namespace Cocoar.Configuration.Core.Tests.TestUtilities; - -/// -/// Test-only provider that can be configured to fail on demand. -/// Used to test ConfigManager error handling behavior for required vs optional rules. -/// Supports various failure scenarios: immediate, after N calls, or toggle-based. -/// -public sealed class FailableProvider : ConfigurationProvider -{ - private int _callCount = 0; - - public FailableProvider(FailableProviderOptions options) : base(options) - { - } - - public override Task FetchConfigurationBytesAsync(FailableProviderQuery query, CancellationToken ct = default) - { - _callCount++; - - // Check if we should fail based on the configured scenario - var shouldFail = ProviderOptions.FailureMode switch - { - FailureMode.Never => false, - FailureMode.Always => true, - FailureMode.AfterNCalls => _callCount > ProviderOptions.FailAfterCallCount, - FailureMode.OnSpecificCall => _callCount == ProviderOptions.FailOnCallNumber, - FailureMode.QueryControlled => query.ShouldFail, - _ => false - }; - - if (shouldFail) - { - var message = ProviderOptions.FailureMode == FailureMode.QueryControlled - ? query.FailureMessage - : $"FailableProvider configured to fail (Mode: {ProviderOptions.FailureMode}, Call: {_callCount})"; - - throw new InvalidOperationException(message); - } - - // Return the configured JSON data - var bytes = JsonSerializer.SerializeToUtf8Bytes(ProviderOptions.JsonData); - return Task.FromResult(bytes); - } - - public override IObservable ChangesAsBytes(FailableProviderQuery query) => - // For testing, we don't need change notifications - Observable.Empty(); -} - -/// -/// Defines different failure modes for testing various error scenarios. -/// -public enum FailureMode -{ - /// Never fail - provider always succeeds. - Never, - - /// Always fail - provider fails on every call. - Always, - - /// Fail after N successful calls - simulates runtime failures like file corruption. - AfterNCalls, - - /// Fail only on a specific call number - for precise testing. - OnSpecificCall, - - /// Failure controlled by query parameter - original behavior. - QueryControlled -} - -/// -/// Options for the FailableProvider - contains the JSON data to return when not failing. -/// -public sealed class FailableProviderOptions : IProviderConfiguration -{ - public JsonElement JsonData { get; } - public FailureMode FailureMode { get; } - public int FailAfterCallCount { get; } - public int FailOnCallNumber { get; } - - public FailableProviderOptions( - JsonElement jsonData, - FailureMode failureMode = FailureMode.Never, - int failAfterCallCount = 0, - int failOnCallNumber = 1) - { - JsonData = jsonData; - FailureMode = failureMode; - FailAfterCallCount = failAfterCallCount; - FailOnCallNumber = failOnCallNumber; - } - - public FailableProviderOptions( - string json, - FailureMode failureMode = FailureMode.Never, - int failAfterCallCount = 0, - int failOnCallNumber = 1) - { - using var document = JsonDocument.Parse(json); - JsonData = document.RootElement.Clone(); - FailureMode = failureMode; - FailAfterCallCount = failAfterCallCount; - FailOnCallNumber = failOnCallNumber; - } - - // Convenience factory methods for common scenarios - public static FailableProviderOptions AlwaysSucceed(string json) => - new(json, FailureMode.Never); - - public static FailableProviderOptions AlwaysFail(string json) => - new(json, FailureMode.Always); - - public static FailableProviderOptions FailAfterNCalls(string json, int callsBeforeFailure) => - new(json, FailureMode.AfterNCalls, failAfterCallCount: callsBeforeFailure); - - public static FailableProviderOptions FailOnCall(string json, int callNumber) => - new(json, FailureMode.OnSpecificCall, failOnCallNumber: callNumber); - - public static FailableProviderOptions QueryControlled(string json) => - new(json, FailureMode.QueryControlled); - - // Don't share instances - each test should have isolated providers - public string? GenerateProviderKey() => null; -} - -/// -/// Query options for the FailableProvider - controls whether the provider should fail. -/// -public sealed class FailableProviderQuery : IProviderQuery -{ - public bool ShouldFail { get; } - public string FailureMessage { get; } - - public FailableProviderQuery(bool shouldFail = false, string failureMessage = "Provider failed") - { - ShouldFail = shouldFail; - FailureMessage = failureMessage; - } - - public static readonly FailableProviderQuery Success = new(false); - public static readonly FailableProviderQuery Failure = new(true); -} +using System.Reactive.Linq; +using System.Text.Json; + +using Cocoar.Configuration.Core.Tests.Helpers; + +namespace Cocoar.Configuration.Core.Tests.TestUtilities; + +/// +/// Test-only provider that can be configured to fail on demand. +/// Used to test ConfigManager error handling behavior for required vs optional rules. +/// Supports various failure scenarios: immediate, after N calls, or toggle-based. +/// +public sealed class FailableProvider : ConfigurationProvider +{ + private int _callCount = 0; + + public FailableProvider(FailableProviderOptions options) : base(options) + { + } + + public override Task FetchConfigurationBytesAsync(FailableProviderQuery query, CancellationToken ct = default) + { + _callCount++; + + // Check if we should fail based on the configured scenario + var shouldFail = ProviderOptions.FailureMode switch + { + FailureMode.Never => false, + FailureMode.Always => true, + FailureMode.AfterNCalls => _callCount > ProviderOptions.FailAfterCallCount, + FailureMode.OnSpecificCall => _callCount == ProviderOptions.FailOnCallNumber, + FailureMode.QueryControlled => query.ShouldFail, + _ => false + }; + + if (shouldFail) + { + var message = ProviderOptions.FailureMode == FailureMode.QueryControlled + ? query.FailureMessage + : $"FailableProvider configured to fail (Mode: {ProviderOptions.FailureMode}, Call: {_callCount})"; + + throw new InvalidOperationException(message); + } + + // Return the configured JSON data + var bytes = JsonSerializer.SerializeToUtf8Bytes(ProviderOptions.JsonData); + return Task.FromResult(bytes); + } + + public override IObservable ChangesAsBytes(FailableProviderQuery query) => + // For testing, we don't need change notifications + Observable.Empty(); +} + +/// +/// Defines different failure modes for testing various error scenarios. +/// +public enum FailureMode +{ + /// Never fail - provider always succeeds. + Never, + + /// Always fail - provider fails on every call. + Always, + + /// Fail after N successful calls - simulates runtime failures like file corruption. + AfterNCalls, + + /// Fail only on a specific call number - for precise testing. + OnSpecificCall, + + /// Failure controlled by query parameter - original behavior. + QueryControlled +} + +/// +/// Options for the FailableProvider - contains the JSON data to return when not failing. +/// +public sealed class FailableProviderOptions : IProviderConfiguration +{ + public JsonElement JsonData { get; } + public FailureMode FailureMode { get; } + public int FailAfterCallCount { get; } + public int FailOnCallNumber { get; } + + public FailableProviderOptions( + JsonElement jsonData, + FailureMode failureMode = FailureMode.Never, + int failAfterCallCount = 0, + int failOnCallNumber = 1) + { + JsonData = jsonData; + FailureMode = failureMode; + FailAfterCallCount = failAfterCallCount; + FailOnCallNumber = failOnCallNumber; + } + + public FailableProviderOptions( + string json, + FailureMode failureMode = FailureMode.Never, + int failAfterCallCount = 0, + int failOnCallNumber = 1) + { + using var document = JsonDocument.Parse(json); + JsonData = document.RootElement.Clone(); + FailureMode = failureMode; + FailAfterCallCount = failAfterCallCount; + FailOnCallNumber = failOnCallNumber; + } + + // Convenience factory methods for common scenarios + public static FailableProviderOptions AlwaysSucceed(string json) => + new(json, FailureMode.Never); + + public static FailableProviderOptions AlwaysFail(string json) => + new(json, FailureMode.Always); + + public static FailableProviderOptions FailAfterNCalls(string json, int callsBeforeFailure) => + new(json, FailureMode.AfterNCalls, failAfterCallCount: callsBeforeFailure); + + public static FailableProviderOptions FailOnCall(string json, int callNumber) => + new(json, FailureMode.OnSpecificCall, failOnCallNumber: callNumber); + + public static FailableProviderOptions QueryControlled(string json) => + new(json, FailureMode.QueryControlled); + + // Don't share instances - each test should have isolated providers + public string? GenerateProviderKey() => null; +} + +/// +/// Query options for the FailableProvider - controls whether the provider should fail. +/// +public sealed class FailableProviderQuery : IProviderQuery +{ + public bool ShouldFail { get; } + public string FailureMessage { get; } + + public FailableProviderQuery(bool shouldFail = false, string failureMessage = "Provider failed") + { + ShouldFail = shouldFail; + FailureMessage = failureMessage; + } + + public static readonly FailableProviderQuery Success = new(false); + public static readonly FailableProviderQuery Failure = new(true); +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/ObservableTestHelpers.cs b/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/ObservableTestHelpers.cs index 00269a0..14a683e 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/ObservableTestHelpers.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/ObservableTestHelpers.cs @@ -1,281 +1,281 @@ - -using Cocoar.Configuration.Core.Tests.Helpers; - -namespace Cocoar.Configuration.Core.Tests.TestUtilities; - -/// -/// Observable test helpers for bulletproof reactive testing. -/// Provides utilities for testing IObservable streams with proper active waiting patterns. -/// -public static class ObservableTestHelpers -{ - /// - /// Wait for an observable to emit a value that matches the expected value within timeout. - /// Uses active waiting pattern to avoid timing dependencies. - /// - /// The type of values emitted by the observable - /// The observable source to monitor - /// The expected value to wait for - /// Maximum time to wait (default: 15 seconds) - /// Description for debugging failures - /// The matched value - /// Thrown when timeout is exceeded - public static async Task WaitForValueAsync( - IObservable source, - T expectedValue, - TimeSpan timeout = default, - string description = "expected value") - { - timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; - - var completionSource = new TaskCompletionSource(); - var subscription = source.Subscribe(value => - { - if (EqualityComparer.Default.Equals(value, expectedValue)) - { - completionSource.TrySetResult(value); - } - }, completionSource.SetException); - - using var cancellation = new CancellationTokenSource(timeout); - cancellation.Token.Register(() => - completionSource.TrySetException(new TimeoutException( - $"Timeout waiting for {description}. Expected: {expectedValue}"))); - - try - { - var result = await completionSource.Task; - subscription.Dispose(); - return result; - } - catch - { - subscription.Dispose(); - throw; - } - } - - /// - /// Wait for an observable to emit a value that matches the specified predicate within timeout. - /// Uses active waiting pattern to avoid timing dependencies. - /// - /// The type of values emitted by the observable - /// The observable source to monitor - /// Predicate to test emitted values - /// Maximum time to wait (default: 15 seconds) - /// Description for debugging failures - /// The matched value - /// Thrown when timeout is exceeded - public static async Task WaitForConditionAsync( - IObservable source, - Func predicate, - TimeSpan timeout = default, - string description = "condition") - { - timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; - - var completionSource = new TaskCompletionSource(); - var subscription = source.Subscribe(value => - { - if (predicate(value)) - { - completionSource.TrySetResult(value); - } - }, completionSource.SetException); - - using var cancellation = new CancellationTokenSource(timeout); - cancellation.Token.Register(() => - completionSource.TrySetException(new TimeoutException( - $"Timeout waiting for {description}"))); - - try - { - var result = await completionSource.Task; - subscription.Dispose(); - return result; - } - catch - { - subscription.Dispose(); - throw; - } - } - - /// - /// Verify that an observable emits a specific sequence of values in order. - /// Uses active waiting with timeout for reliability. - /// - /// The type of values emitted by the observable - /// The observable source to monitor - /// The expected sequence of values - /// Maximum time to wait for complete sequence (default: 15 seconds) - /// Description for debugging failures - /// Task that completes when sequence is verified - /// Thrown when timeout is exceeded - public static async Task AssertSequenceAsync( - IObservable source, - T[] expectedSequence, - TimeSpan timeout = default, - string description = "sequence") - { - timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; - - if (expectedSequence.Length == 0) - { - throw new ArgumentException("Expected sequence cannot be empty", nameof(expectedSequence)); - } - - var emissions = new List(); - var completionSource = new TaskCompletionSource(); - - var subscription = source.Subscribe(value => - { - emissions.Add(value); - - // Check if we have received the expected sequence - if (emissions.Count >= expectedSequence.Length) - { - var matches = true; - for (var i = 0; i < expectedSequence.Length; i++) - { - if (!EqualityComparer.Default.Equals(emissions[i], expectedSequence[i])) - { - matches = false; - break; - } - } - - if (matches) - { - completionSource.TrySetResult(true); - } - } - }, completionSource.SetException); - - using var cancellation = new CancellationTokenSource(timeout); - cancellation.Token.Register(() => - completionSource.TrySetException(new TimeoutException( - $"Timeout waiting for {description}. Expected: [{string.Join(", ", expectedSequence)}], " + - $"Got: [{string.Join(", ", emissions)}]"))); - - try - { - await completionSource.Task; - subscription.Dispose(); - } - catch - { - subscription.Dispose(); - throw; - } - } - - /// - /// Wait for an observable to complete within the specified timeout. - /// Uses active waiting pattern to avoid timing dependencies. - /// - /// The type of values emitted by the observable - /// The observable source to monitor - /// Maximum time to wait (default: 15 seconds) - /// Description for debugging failures - /// Task that completes when observable completes - /// Thrown when timeout is exceeded - public static async Task WaitForCompletionAsync( - IObservable source, - TimeSpan timeout = default, - string description = "completion") - { - timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; - - var completionSource = new TaskCompletionSource(); - var subscription = source.Subscribe( - _ => { }, // OnNext - ignore values - completionSource.SetException, // OnError - () => completionSource.TrySetResult(true)); // OnCompleted - - using var cancellation = new CancellationTokenSource(timeout); - cancellation.Token.Register(() => - completionSource.TrySetException(new TimeoutException( - $"Timeout waiting for {description}"))); - - try - { - await completionSource.Task; - subscription.Dispose(); - } - catch - { - subscription.Dispose(); - throw; - } - } - - /// - /// Wait for an observable to emit at least the specified number of values. - /// Returns all emitted values up to that point. - /// - /// The type of values emitted by the observable - /// The observable source to monitor - /// Minimum number of emissions to wait for - /// Maximum time to wait (default: 15 seconds) - /// Description for debugging failures - /// List of all emitted values - /// Thrown when timeout is exceeded - public static async Task> WaitForEmissionsAsync( - IObservable source, - int count, - TimeSpan timeout = default, - string description = "emissions") - { - timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; - - var emissions = new List(); - var completionSource = new TaskCompletionSource>(); - - var subscription = source.Subscribe(value => - { - emissions.Add(value); - if (emissions.Count >= count) - { - completionSource.TrySetResult(emissions.AsReadOnly()); - } - }, completionSource.SetException); - - using var cancellation = new CancellationTokenSource(timeout); - cancellation.Token.Register(() => - completionSource.TrySetException(new TimeoutException( - $"Timeout waiting for {count} {description}. Got {emissions.Count}"))); - - try - { - var result = await completionSource.Task; - subscription.Dispose(); - return result; - } - catch - { - subscription.Dispose(); - throw; - } - } - - /// - /// Create an observable test helper using BehaviorSubject. - /// Perfect for testing scenarios where you need full control over emissions. - /// - /// The type of values to emit - /// Initial value (optional) - /// BehaviorSubject for test control - public static BehaviorSubject CreateObservableSubject(T? initialValue = default) => - initialValue is not null - ? new BehaviorSubject(initialValue) - : throw new ArgumentException("BehaviorSubject requires an initial value. Use CreateEmptyObservableSubject() if no initial value is desired."); - - /// - /// Create an observable test helper that starts without an initial value. - /// Use this when you want to control exactly when the first emission occurs. - /// - /// The type of values to emit - /// Subject for test control - public static Subject CreateEmptyObservableSubject() => new(); -} + +using Cocoar.Configuration.Core.Tests.Helpers; + +namespace Cocoar.Configuration.Core.Tests.TestUtilities; + +/// +/// Observable test helpers for bulletproof reactive testing. +/// Provides utilities for testing IObservable streams with proper active waiting patterns. +/// +public static class ObservableTestHelpers +{ + /// + /// Wait for an observable to emit a value that matches the expected value within timeout. + /// Uses active waiting pattern to avoid timing dependencies. + /// + /// The type of values emitted by the observable + /// The observable source to monitor + /// The expected value to wait for + /// Maximum time to wait (default: 15 seconds) + /// Description for debugging failures + /// The matched value + /// Thrown when timeout is exceeded + public static async Task WaitForValueAsync( + IObservable source, + T expectedValue, + TimeSpan timeout = default, + string description = "expected value") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + + var completionSource = new TaskCompletionSource(); + var subscription = source.Subscribe(value => + { + if (EqualityComparer.Default.Equals(value, expectedValue)) + { + completionSource.TrySetResult(value); + } + }, completionSource.SetException); + + using var cancellation = new CancellationTokenSource(timeout); + cancellation.Token.Register(() => + completionSource.TrySetException(new TimeoutException( + $"Timeout waiting for {description}. Expected: {expectedValue}"))); + + try + { + var result = await completionSource.Task; + subscription.Dispose(); + return result; + } + catch + { + subscription.Dispose(); + throw; + } + } + + /// + /// Wait for an observable to emit a value that matches the specified predicate within timeout. + /// Uses active waiting pattern to avoid timing dependencies. + /// + /// The type of values emitted by the observable + /// The observable source to monitor + /// Predicate to test emitted values + /// Maximum time to wait (default: 15 seconds) + /// Description for debugging failures + /// The matched value + /// Thrown when timeout is exceeded + public static async Task WaitForConditionAsync( + IObservable source, + Func predicate, + TimeSpan timeout = default, + string description = "condition") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + + var completionSource = new TaskCompletionSource(); + var subscription = source.Subscribe(value => + { + if (predicate(value)) + { + completionSource.TrySetResult(value); + } + }, completionSource.SetException); + + using var cancellation = new CancellationTokenSource(timeout); + cancellation.Token.Register(() => + completionSource.TrySetException(new TimeoutException( + $"Timeout waiting for {description}"))); + + try + { + var result = await completionSource.Task; + subscription.Dispose(); + return result; + } + catch + { + subscription.Dispose(); + throw; + } + } + + /// + /// Verify that an observable emits a specific sequence of values in order. + /// Uses active waiting with timeout for reliability. + /// + /// The type of values emitted by the observable + /// The observable source to monitor + /// The expected sequence of values + /// Maximum time to wait for complete sequence (default: 15 seconds) + /// Description for debugging failures + /// Task that completes when sequence is verified + /// Thrown when timeout is exceeded + public static async Task AssertSequenceAsync( + IObservable source, + T[] expectedSequence, + TimeSpan timeout = default, + string description = "sequence") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + + if (expectedSequence.Length == 0) + { + throw new ArgumentException("Expected sequence cannot be empty", nameof(expectedSequence)); + } + + var emissions = new List(); + var completionSource = new TaskCompletionSource(); + + var subscription = source.Subscribe(value => + { + emissions.Add(value); + + // Check if we have received the expected sequence + if (emissions.Count >= expectedSequence.Length) + { + var matches = true; + for (var i = 0; i < expectedSequence.Length; i++) + { + if (!EqualityComparer.Default.Equals(emissions[i], expectedSequence[i])) + { + matches = false; + break; + } + } + + if (matches) + { + completionSource.TrySetResult(true); + } + } + }, completionSource.SetException); + + using var cancellation = new CancellationTokenSource(timeout); + cancellation.Token.Register(() => + completionSource.TrySetException(new TimeoutException( + $"Timeout waiting for {description}. Expected: [{string.Join(", ", expectedSequence)}], " + + $"Got: [{string.Join(", ", emissions)}]"))); + + try + { + await completionSource.Task; + subscription.Dispose(); + } + catch + { + subscription.Dispose(); + throw; + } + } + + /// + /// Wait for an observable to complete within the specified timeout. + /// Uses active waiting pattern to avoid timing dependencies. + /// + /// The type of values emitted by the observable + /// The observable source to monitor + /// Maximum time to wait (default: 15 seconds) + /// Description for debugging failures + /// Task that completes when observable completes + /// Thrown when timeout is exceeded + public static async Task WaitForCompletionAsync( + IObservable source, + TimeSpan timeout = default, + string description = "completion") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + + var completionSource = new TaskCompletionSource(); + var subscription = source.Subscribe( + _ => { }, // OnNext - ignore values + completionSource.SetException, // OnError + () => completionSource.TrySetResult(true)); // OnCompleted + + using var cancellation = new CancellationTokenSource(timeout); + cancellation.Token.Register(() => + completionSource.TrySetException(new TimeoutException( + $"Timeout waiting for {description}"))); + + try + { + await completionSource.Task; + subscription.Dispose(); + } + catch + { + subscription.Dispose(); + throw; + } + } + + /// + /// Wait for an observable to emit at least the specified number of values. + /// Returns all emitted values up to that point. + /// + /// The type of values emitted by the observable + /// The observable source to monitor + /// Minimum number of emissions to wait for + /// Maximum time to wait (default: 15 seconds) + /// Description for debugging failures + /// List of all emitted values + /// Thrown when timeout is exceeded + public static async Task> WaitForEmissionsAsync( + IObservable source, + int count, + TimeSpan timeout = default, + string description = "emissions") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + + var emissions = new List(); + var completionSource = new TaskCompletionSource>(); + + var subscription = source.Subscribe(value => + { + emissions.Add(value); + if (emissions.Count >= count) + { + completionSource.TrySetResult(emissions.AsReadOnly()); + } + }, completionSource.SetException); + + using var cancellation = new CancellationTokenSource(timeout); + cancellation.Token.Register(() => + completionSource.TrySetException(new TimeoutException( + $"Timeout waiting for {count} {description}. Got {emissions.Count}"))); + + try + { + var result = await completionSource.Task; + subscription.Dispose(); + return result; + } + catch + { + subscription.Dispose(); + throw; + } + } + + /// + /// Create an observable test helper using BehaviorSubject. + /// Perfect for testing scenarios where you need full control over emissions. + /// + /// The type of values to emit + /// Initial value (optional) + /// BehaviorSubject for test control + public static BehaviorSubject CreateObservableSubject(T? initialValue = default) => + initialValue is not null + ? new BehaviorSubject(initialValue) + : throw new ArgumentException("BehaviorSubject requires an initial value. Use CreateEmptyObservableSubject() if no initial value is desired."); + + /// + /// Create an observable test helper that starts without an initial value. + /// Use this when you want to control exactly when the first emission occurs. + /// + /// The type of values to emit + /// Subject for test control + public static Subject CreateEmptyObservableSubject() => new(); +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/TestLogger.cs b/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/TestLogger.cs index 3c3d93e..0c28cb2 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/TestLogger.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/TestUtilities/TestLogger.cs @@ -1,61 +1,61 @@ -using Microsoft.Extensions.Logging; - -namespace Cocoar.Configuration.Core.Tests.TestUtilities; - -/// -/// A test logger that captures log entries for assertion. -/// -public class TestLogger : ILogger -{ - private readonly List _entries = new(); - private readonly object _lock = new(); - - public IReadOnlyList Entries - { - get - { - lock (_lock) - { - return _entries.ToList().AsReadOnly(); - } - } - } - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - var message = formatter(state, exception); - lock (_lock) - { - _entries.Add(new LogEntry(logLevel, eventId, message, exception)); - } - } - - public bool IsEnabled(LogLevel logLevel) => true; - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool HasLogEntry(LogLevel level, string messageContains) - { - return Entries.Any(e => e.Level == level && e.Message.Contains(messageContains, StringComparison.OrdinalIgnoreCase)); - } - - public bool HasLogEntry(LogLevel level, int eventId) - { - return Entries.Any(e => e.Level == level && e.EventId.Id == eventId); - } - - public LogEntry? FindEntry(LogLevel level, string messageContains) - { - return Entries.FirstOrDefault(e => e.Level == level && e.Message.Contains(messageContains, StringComparison.OrdinalIgnoreCase)); - } - - public void Clear() - { - lock (_lock) - { - _entries.Clear(); - } - } -} - -public record LogEntry(LogLevel Level, EventId EventId, string Message, Exception? Exception); +using Microsoft.Extensions.Logging; + +namespace Cocoar.Configuration.Core.Tests.TestUtilities; + +/// +/// A test logger that captures log entries for assertion. +/// +public class TestLogger : ILogger +{ + private readonly List _entries = new(); + private readonly object _lock = new(); + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return _entries.ToList().AsReadOnly(); + } + } + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var message = formatter(state, exception); + lock (_lock) + { + _entries.Add(new LogEntry(logLevel, eventId, message, exception)); + } + } + + public bool IsEnabled(LogLevel logLevel) => true; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool HasLogEntry(LogLevel level, string messageContains) + { + return Entries.Any(e => e.Level == level && e.Message.Contains(messageContains, StringComparison.OrdinalIgnoreCase)); + } + + public bool HasLogEntry(LogLevel level, int eventId) + { + return Entries.Any(e => e.Level == level && e.EventId.Id == eventId); + } + + public LogEntry? FindEntry(LogLevel level, string messageContains) + { + return Entries.FirstOrDefault(e => e.Level == level && e.Message.Contains(messageContains, StringComparison.OrdinalIgnoreCase)); + } + + public void Clear() + { + lock (_lock) + { + _entries.Clear(); + } + } +} + +public record LogEntry(LogLevel Level, EventId EventId, string Message, Exception? Exception); diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Testing/CocoarTestConfigurationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Testing/CocoarTestConfigurationTests.cs index 2477e42..5a1003a 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Testing/CocoarTestConfigurationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Testing/CocoarTestConfigurationTests.cs @@ -1,762 +1,762 @@ -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Testing; - -namespace Cocoar.Configuration.Core.Tests.Testing; - -public class CocoarTestConfigurationTests -{ - public CocoarTestConfigurationTests() - { - // Ensure clean state before each test - CocoarTestConfiguration.Clear(); - } - - [Fact] - public void ReplaceConfiguration_SetsContextInReplaceMode() - { - // Act - CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]); - - // Assert - Assert.True(CocoarTestConfiguration.IsActive); - Assert.NotNull(CocoarTestConfiguration.Current); - Assert.Equal(TestConfigurationMode.Replace, CocoarTestConfiguration.Current!.ConfigurationMode); - - // Cleanup - CocoarTestConfiguration.Clear(); - } - - [Fact] - public void AppendConfiguration_SetsContextInAppendMode() - { - // Act - CocoarTestConfiguration.AppendConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]); - - // Assert - Assert.True(CocoarTestConfiguration.IsActive); - Assert.NotNull(CocoarTestConfiguration.Current); - Assert.Equal(TestConfigurationMode.Append, CocoarTestConfiguration.Current!.ConfigurationMode); - - // Cleanup - CocoarTestConfiguration.Clear(); - } - - [Fact] - public void Clear_RemovesContext() - { - // Arrange - CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]); - Assert.True(CocoarTestConfiguration.IsActive); - - // Act - CocoarTestConfiguration.Clear(); - - // Assert - Assert.False(CocoarTestConfiguration.IsActive); - Assert.Null(CocoarTestConfiguration.Current); - } - - [Fact] - public void ReplaceConfiguration_ThrowsOnNullRules() - { - Assert.Throws(() => CocoarTestConfiguration.ReplaceConfiguration(null!)); - } - - [Fact] - public void AppendConfiguration_ThrowsOnNullRules() - { - Assert.Throws(() => CocoarTestConfiguration.AppendConfiguration(null!)); - } - - private sealed class TestConfig - { - public string Value { get; set; } = ""; - } -} - -public class TestConfigurationScopeTests -{ - public TestConfigurationScopeTests() - { - CocoarTestConfiguration.Clear(); - } - - [Fact] - public void ReplaceConfiguration_ReturnsBuilder() - { - // Act - var builder = CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]); - - // Assert — builder is active (auto-activated) - Assert.True(CocoarTestConfiguration.IsActive); - - // Cleanup - builder.Dispose(); - Assert.False(CocoarTestConfiguration.IsActive); - } - - [Fact] - public void AppendConfiguration_ReturnsBuilder() - { - // Act - var builder = CocoarTestConfiguration.AppendConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]); - - // Assert - Assert.True(CocoarTestConfiguration.IsActive); - - // Cleanup - builder.Dispose(); - Assert.False(CocoarTestConfiguration.IsActive); - } - - [Fact] - public void Scope_ClearsConfigurationOnDispose() - { - // Arrange - Assert.False(CocoarTestConfiguration.IsActive); - - // Act & Assert - using (var scope = CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ])) - { - Assert.True(CocoarTestConfiguration.IsActive); - } - - // Configuration is cleared after scope disposal - Assert.False(CocoarTestConfiguration.IsActive); - } - - [Fact] - public void Scope_ClearsConfigurationEvenOnException() - { - // Arrange - Assert.False(CocoarTestConfiguration.IsActive); - - // Act - try - { - using var scope = CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]); - - Assert.True(CocoarTestConfiguration.IsActive); - throw new InvalidOperationException("Simulated exception"); - } - catch (InvalidOperationException) - { - // Expected - } - - // Assert - still cleared - Assert.False(CocoarTestConfiguration.IsActive); - } - - [Fact] - public void UsingPattern_WorksAsExpected() - { - // Arrange & Act - using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "scoped-test" }) - ]); - - // Assert - Assert.True(CocoarTestConfiguration.IsActive); - Assert.Equal(TestConfigurationMode.Replace, CocoarTestConfiguration.Current!.ConfigurationMode); - } - - private sealed class TestConfig - { - public string Value { get; set; } = ""; - } -} - -public class TestConfigurationContextTests -{ - [Fact] - public void Replace_CreatesContextInReplaceMode() - { - // Act - var context = TestConfigurationContext.Replace(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]); - - // Assert - Assert.Equal(TestConfigurationMode.Replace, context.ConfigurationMode); - Assert.NotNull(context.Rules); - } - - [Fact] - public void Append_CreatesContextInAppendMode() - { - // Act - var context = TestConfigurationContext.Append(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]); - - // Assert - Assert.Equal(TestConfigurationMode.Append, context.ConfigurationMode); - Assert.NotNull(context.Rules); - } - - [Fact] - public void Replace_ThrowsOnNullRules() - { - Assert.Throws(() => TestConfigurationContext.Replace(null!)); - } - - [Fact] - public void Append_ThrowsOnNullRules() - { - Assert.Throws(() => TestConfigurationContext.Append(null!)); - } - - private sealed class TestConfig - { - public string Value { get; set; } = ""; - } -} - -public class TestOverrideBuilderTests -{ - [Fact] - public void Builder_ReplaceConfiguration_SetsReplaceMode() - { - // Arrange & Act - var context = new TestOverrideBuilder() - .ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]) - .Build(); - - // Assert - Assert.Equal(TestConfigurationMode.Replace, context.ConfigurationMode); - Assert.NotNull(context.Rules); - } - - [Fact] - public void Builder_AppendConfiguration_SetsAppendMode() - { - // Arrange & Act - var context = new TestOverrideBuilder() - .AppendConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]) - .Build(); - - // Assert - Assert.Equal(TestConfigurationMode.Append, context.ConfigurationMode); - Assert.NotNull(context.Rules); - } - - [Fact] - public void Builder_ReplaceConfiguration_ThrowsOnNullRules() - { - Assert.Throws(() => - new TestOverrideBuilder().ReplaceConfiguration(null!)); - } - - [Fact] - public void Builder_AppendConfiguration_ThrowsOnNullRules() - { - Assert.Throws(() => - new TestOverrideBuilder().AppendConfiguration(null!)); - } - - [Fact] - public void Builder_Build_DoesNotActivate() - { - // Arrange - CocoarTestConfiguration.Clear(); - - // Act — fixture pattern: build without activating - var context = new TestOverrideBuilder() - .ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]) - .Build(); - - // Assert — not yet active - Assert.False(CocoarTestConfiguration.IsActive); - Assert.NotNull(context); - - CocoarTestConfiguration.Clear(); - } - - private sealed class TestConfig - { - public string Value { get; set; } = ""; - } -} - -public class ApplyMethodTests -{ - public ApplyMethodTests() - { - CocoarTestConfiguration.Clear(); - } - - [Fact] - public void Apply_SetsContextFromExistingInstance() - { - // Arrange - var context = TestConfigurationContext.Replace(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "applied" }) - ]); - - Assert.False(CocoarTestConfiguration.IsActive); - - // Act - using var scope = CocoarTestConfiguration.Apply(context); - - // Assert - Assert.True(CocoarTestConfiguration.IsActive); - Assert.Same(context, CocoarTestConfiguration.Current); - } - - [Fact] - public void Apply_ThrowsOnNullContext() - { - Assert.Throws(() => CocoarTestConfiguration.Apply(null!)); - } - - [Fact] - public void Apply_ReturnsScope() - { - // Arrange - var context = TestConfigurationContext.Replace(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]); - - // Act - var scope = CocoarTestConfiguration.Apply(context); - - // Assert - Assert.True(scope.IsActive); - - // Cleanup - scope.Dispose(); - Assert.False(CocoarTestConfiguration.IsActive); - } - - [Fact] - public void Apply_ScopeDisposeClearsContext() - { - // Arrange - var context = TestConfigurationContext.Replace(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]); - - // Act - using (var scope = CocoarTestConfiguration.Apply(context)) - { - Assert.True(CocoarTestConfiguration.IsActive); - } - - // Assert - Assert.False(CocoarTestConfiguration.IsActive); - } - - [Fact] - public void Apply_CanBeCalledMultipleTimes_EachScopeClears() - { - // Arrange - var context1 = TestConfigurationContext.Replace(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "first" }) - ]); - var context2 = TestConfigurationContext.Replace(rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "second" }) - ]); - - // Act & Assert - using (var scope1 = CocoarTestConfiguration.Apply(context1)) - { - Assert.Same(context1, CocoarTestConfiguration.Current); - } - Assert.False(CocoarTestConfiguration.IsActive); - - using (var scope2 = CocoarTestConfiguration.Apply(context2)) - { - Assert.Same(context2, CocoarTestConfiguration.Current); - } - Assert.False(CocoarTestConfiguration.IsActive); - } - - private sealed class TestConfig - { - public string Value { get; set; } = ""; - } -} - -public class ConfigManagerIntegrationTests -{ - public ConfigManagerIntegrationTests() - { - CocoarTestConfiguration.Clear(); - } - - [Fact] - public void ConfigManager_AppliesTestOverrides_ReplaceMode() - { - // Arrange - using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig - { - Connection = "test-connection", - MaxConnections = 42 - }) - ]); - - // Act - ConfigManager with original rules - var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig - { - Connection = "original-connection", - MaxConnections = 10 - }) - ])); - - var config = configManager.GetRequiredConfig(); - - // Assert - Test overrides are used - Assert.Equal("test-connection", config.Connection); - Assert.Equal(42, config.MaxConnections); - } - - [Fact] - public void ConfigManager_AppliesTestOverrides_AppendMode() - { - // Arrange - using var _ = CocoarTestConfiguration.AppendConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig - { - MaxConnections = 999 - }) - ]); - - // Act - var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig - { - Connection = "original-connection", - MaxConnections = 10 - }) - ])); - - var config = configManager.GetRequiredConfig(); - - // Assert - Test override merged (last-write-wins) - Assert.Equal(999, config.MaxConnections); - } - - [Fact] - public void ConfigManager_WorksNormally_WhenNoTestOverride() - { - // Arrange - No test configuration set - - // Act - var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig - { - Connection = "normal-connection", - MaxConnections = 50 - }) - ])); - - var config = configManager.GetRequiredConfig(); - - // Assert - Normal behavior - Assert.Equal("normal-connection", config.Connection); - Assert.Equal(50, config.MaxConnections); - } - - [Fact] - public void ConfigManager_AppliesContextFromApply() - { - // Arrange - var context = TestConfigurationContext.Replace(rule => [ - rule.For().FromStatic(_ => new TestConfig - { - Connection = "applied-connection", - MaxConnections = 123 - }) - ]); - - using var _ = CocoarTestConfiguration.Apply(context); - - // Act - var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromStatic(_ => new TestConfig - { - Connection = "original-connection", - MaxConnections = 10 - }) - ])); - - var config = configManager.GetRequiredConfig(); - - // Assert - Assert.Equal("applied-connection", config.Connection); - Assert.Equal(123, config.MaxConnections); - } - - private sealed class TestConfig - { - public string Connection { get; set; } = ""; - public int MaxConnections { get; set; } - } -} - -public class SetupOverrideTests -{ - public SetupOverrideTests() - { - CocoarTestConfiguration.Clear(); - } - - [Fact] - public void ReplaceConfiguration_WithSetup_SetsContextWithBothRulesAndSetup() - { - // Arrange - Func rules = rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]; - Func setup = builder => []; - - // Act - using var _ = CocoarTestConfiguration.ReplaceConfiguration(rules, setup); - - // Assert - Assert.True(CocoarTestConfiguration.IsActive); - Assert.NotNull(CocoarTestConfiguration.Current); - Assert.NotNull(CocoarTestConfiguration.Current!.Rules); - Assert.NotNull(CocoarTestConfiguration.Current.Setup); - Assert.Equal(TestConfigurationMode.Replace, CocoarTestConfiguration.Current.ConfigurationMode); - - // Cleanup - CocoarTestConfiguration.Clear(); - } - - [Fact] - public void AppendConfiguration_WithSetup_SetsContextWithBothRulesAndSetup() - { - // Arrange - Func rules = rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]; - Func setup = builder => []; - - // Act - using var _ = CocoarTestConfiguration.AppendConfiguration(rules, setup); - - // Assert - Assert.True(CocoarTestConfiguration.IsActive); - Assert.NotNull(CocoarTestConfiguration.Current); - Assert.NotNull(CocoarTestConfiguration.Current!.Rules); - Assert.NotNull(CocoarTestConfiguration.Current.Setup); - Assert.Equal(TestConfigurationMode.Append, CocoarTestConfiguration.Current.ConfigurationMode); - - // Cleanup - CocoarTestConfiguration.Clear(); - } - - private sealed class TestConfig - { - public string Value { get; set; } = ""; - } -} - -public class TestConfigurationContextSetupTests -{ - [Fact] - public void Replace_WithSetup_CreatesContextWithSetup() - { - // Arrange - Func setup = builder => []; - - // Act - var context = TestConfigurationContext.Replace( - rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ], - setup); - - // Assert - Assert.Equal(TestConfigurationMode.Replace, context.ConfigurationMode); - Assert.NotNull(context.Rules); - Assert.Same(setup, context.Setup); - } - - [Fact] - public void Append_WithSetup_CreatesContextWithSetup() - { - // Arrange - Func setup = builder => []; - - // Act - var context = TestConfigurationContext.Append( - rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ], - setup); - - // Assert - Assert.Equal(TestConfigurationMode.Append, context.ConfigurationMode); - Assert.NotNull(context.Rules); - Assert.Same(setup, context.Setup); - } - - [Fact] - public void Builder_WithRulesAndSetup_BuiltContextHasBoth() - { - // Arrange - Func rules = rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]; - Func setup = builder => []; - - // Act - var context = new TestOverrideBuilder() - .ReplaceConfiguration(rules, setup) - .Build(); - - // Assert - Assert.Same(rules, context.Rules); - Assert.Same(setup, context.Setup); - Assert.Equal(TestConfigurationMode.Replace, context.ConfigurationMode); - } - - private sealed class TestConfig - { - public string Value { get; set; } = ""; - } -} - -public class ConfigManagerSetupIntegrationTests -{ - public ConfigManagerSetupIntegrationTests() - { - CocoarTestConfiguration.Clear(); - } - - [Fact] - public void ConfigManager_AppliesTestSetupOverrides_WithReplaceConfiguration() - { - // Arrange - var testSetupCalled = false; - using var _ = CocoarTestConfiguration.ReplaceConfiguration( - rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ], - setup => - { - testSetupCalled = true; - return []; - }); - - // Act - var configManager = ConfigManager.Create(c => c.UseConfiguration( - rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "original" }) - ], - setup => [])); - - // Assert - Assert.True(testSetupCalled); - } - - [Fact] - public void ConfigManager_AppliesTestSetupOverrides_WithAppendConfiguration() - { - // Arrange - var testSetupCalled = false; - using var _ = CocoarTestConfiguration.AppendConfiguration( - rule => [], - setup => - { - testSetupCalled = true; - return []; - }); - - // Act - var configManager = ConfigManager.Create(c => c.UseConfiguration( - rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "original" }) - ], - setup => [])); - - // Assert - Assert.True(testSetupCalled); - } - - [Fact] - public void ConfigManager_WorksWithoutSetup_WhenTestSetupIsNull() - { - // Arrange - var configuredSetupCalled = false; - using var _ = CocoarTestConfiguration.ReplaceConfiguration( - rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ]); // No setup parameter - - // Act - var configManager = ConfigManager.Create(c => c.UseConfiguration( - rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "original" }) - ], - setup => - { - configuredSetupCalled = true; - return []; - })); - - // Assert - Configured setup should still run - Assert.True(configuredSetupCalled); - } - - [Fact] - public void ConfigManager_AppliesTestSetupOverrides_FromAppliedContext() - { - // Arrange - var testSetupCalled = false; - var context = TestConfigurationContext.Replace( - rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "test" }) - ], - setup => - { - testSetupCalled = true; - return []; - }); - - using var _ = CocoarTestConfiguration.Apply(context); - - // Act - var configManager = ConfigManager.Create(c => c.UseConfiguration( - rule => [ - rule.For().FromStatic(_ => new TestConfig { Value = "original" }) - ], - setup => [])); - - // Assert - Assert.True(testSetupCalled); - var config = configManager.GetRequiredConfig(); - Assert.Equal("test", config.Value); - } - - private sealed class TestConfig - { - public string Value { get; set; } = ""; - } -} +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Testing; + +namespace Cocoar.Configuration.Core.Tests.Testing; + +public class CocoarTestConfigurationTests +{ + public CocoarTestConfigurationTests() + { + // Ensure clean state before each test + CocoarTestConfiguration.Clear(); + } + + [Fact] + public void ReplaceConfiguration_SetsContextInReplaceMode() + { + // Act + CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]); + + // Assert + Assert.True(CocoarTestConfiguration.IsActive); + Assert.NotNull(CocoarTestConfiguration.Current); + Assert.Equal(TestConfigurationMode.Replace, CocoarTestConfiguration.Current!.ConfigurationMode); + + // Cleanup + CocoarTestConfiguration.Clear(); + } + + [Fact] + public void AppendConfiguration_SetsContextInAppendMode() + { + // Act + CocoarTestConfiguration.AppendConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]); + + // Assert + Assert.True(CocoarTestConfiguration.IsActive); + Assert.NotNull(CocoarTestConfiguration.Current); + Assert.Equal(TestConfigurationMode.Append, CocoarTestConfiguration.Current!.ConfigurationMode); + + // Cleanup + CocoarTestConfiguration.Clear(); + } + + [Fact] + public void Clear_RemovesContext() + { + // Arrange + CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]); + Assert.True(CocoarTestConfiguration.IsActive); + + // Act + CocoarTestConfiguration.Clear(); + + // Assert + Assert.False(CocoarTestConfiguration.IsActive); + Assert.Null(CocoarTestConfiguration.Current); + } + + [Fact] + public void ReplaceConfiguration_ThrowsOnNullRules() + { + Assert.Throws(() => CocoarTestConfiguration.ReplaceConfiguration(null!)); + } + + [Fact] + public void AppendConfiguration_ThrowsOnNullRules() + { + Assert.Throws(() => CocoarTestConfiguration.AppendConfiguration(null!)); + } + + private sealed class TestConfig + { + public string Value { get; set; } = ""; + } +} + +public class TestConfigurationScopeTests +{ + public TestConfigurationScopeTests() + { + CocoarTestConfiguration.Clear(); + } + + [Fact] + public void ReplaceConfiguration_ReturnsBuilder() + { + // Act + var builder = CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]); + + // Assert — builder is active (auto-activated) + Assert.True(CocoarTestConfiguration.IsActive); + + // Cleanup + builder.Dispose(); + Assert.False(CocoarTestConfiguration.IsActive); + } + + [Fact] + public void AppendConfiguration_ReturnsBuilder() + { + // Act + var builder = CocoarTestConfiguration.AppendConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]); + + // Assert + Assert.True(CocoarTestConfiguration.IsActive); + + // Cleanup + builder.Dispose(); + Assert.False(CocoarTestConfiguration.IsActive); + } + + [Fact] + public void Scope_ClearsConfigurationOnDispose() + { + // Arrange + Assert.False(CocoarTestConfiguration.IsActive); + + // Act & Assert + using (var scope = CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ])) + { + Assert.True(CocoarTestConfiguration.IsActive); + } + + // Configuration is cleared after scope disposal + Assert.False(CocoarTestConfiguration.IsActive); + } + + [Fact] + public void Scope_ClearsConfigurationEvenOnException() + { + // Arrange + Assert.False(CocoarTestConfiguration.IsActive); + + // Act + try + { + using var scope = CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]); + + Assert.True(CocoarTestConfiguration.IsActive); + throw new InvalidOperationException("Simulated exception"); + } + catch (InvalidOperationException) + { + // Expected + } + + // Assert - still cleared + Assert.False(CocoarTestConfiguration.IsActive); + } + + [Fact] + public void UsingPattern_WorksAsExpected() + { + // Arrange & Act + using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "scoped-test" }) + ]); + + // Assert + Assert.True(CocoarTestConfiguration.IsActive); + Assert.Equal(TestConfigurationMode.Replace, CocoarTestConfiguration.Current!.ConfigurationMode); + } + + private sealed class TestConfig + { + public string Value { get; set; } = ""; + } +} + +public class TestConfigurationContextTests +{ + [Fact] + public void Replace_CreatesContextInReplaceMode() + { + // Act + var context = TestConfigurationContext.Replace(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]); + + // Assert + Assert.Equal(TestConfigurationMode.Replace, context.ConfigurationMode); + Assert.NotNull(context.Rules); + } + + [Fact] + public void Append_CreatesContextInAppendMode() + { + // Act + var context = TestConfigurationContext.Append(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]); + + // Assert + Assert.Equal(TestConfigurationMode.Append, context.ConfigurationMode); + Assert.NotNull(context.Rules); + } + + [Fact] + public void Replace_ThrowsOnNullRules() + { + Assert.Throws(() => TestConfigurationContext.Replace(null!)); + } + + [Fact] + public void Append_ThrowsOnNullRules() + { + Assert.Throws(() => TestConfigurationContext.Append(null!)); + } + + private sealed class TestConfig + { + public string Value { get; set; } = ""; + } +} + +public class TestOverrideBuilderTests +{ + [Fact] + public void Builder_ReplaceConfiguration_SetsReplaceMode() + { + // Arrange & Act + var context = new TestOverrideBuilder() + .ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]) + .Build(); + + // Assert + Assert.Equal(TestConfigurationMode.Replace, context.ConfigurationMode); + Assert.NotNull(context.Rules); + } + + [Fact] + public void Builder_AppendConfiguration_SetsAppendMode() + { + // Arrange & Act + var context = new TestOverrideBuilder() + .AppendConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]) + .Build(); + + // Assert + Assert.Equal(TestConfigurationMode.Append, context.ConfigurationMode); + Assert.NotNull(context.Rules); + } + + [Fact] + public void Builder_ReplaceConfiguration_ThrowsOnNullRules() + { + Assert.Throws(() => + new TestOverrideBuilder().ReplaceConfiguration(null!)); + } + + [Fact] + public void Builder_AppendConfiguration_ThrowsOnNullRules() + { + Assert.Throws(() => + new TestOverrideBuilder().AppendConfiguration(null!)); + } + + [Fact] + public void Builder_Build_DoesNotActivate() + { + // Arrange + CocoarTestConfiguration.Clear(); + + // Act — fixture pattern: build without activating + var context = new TestOverrideBuilder() + .ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]) + .Build(); + + // Assert — not yet active + Assert.False(CocoarTestConfiguration.IsActive); + Assert.NotNull(context); + + CocoarTestConfiguration.Clear(); + } + + private sealed class TestConfig + { + public string Value { get; set; } = ""; + } +} + +public class ApplyMethodTests +{ + public ApplyMethodTests() + { + CocoarTestConfiguration.Clear(); + } + + [Fact] + public void Apply_SetsContextFromExistingInstance() + { + // Arrange + var context = TestConfigurationContext.Replace(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "applied" }) + ]); + + Assert.False(CocoarTestConfiguration.IsActive); + + // Act + using var scope = CocoarTestConfiguration.Apply(context); + + // Assert + Assert.True(CocoarTestConfiguration.IsActive); + Assert.Same(context, CocoarTestConfiguration.Current); + } + + [Fact] + public void Apply_ThrowsOnNullContext() + { + Assert.Throws(() => CocoarTestConfiguration.Apply(null!)); + } + + [Fact] + public void Apply_ReturnsScope() + { + // Arrange + var context = TestConfigurationContext.Replace(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]); + + // Act + var scope = CocoarTestConfiguration.Apply(context); + + // Assert + Assert.True(scope.IsActive); + + // Cleanup + scope.Dispose(); + Assert.False(CocoarTestConfiguration.IsActive); + } + + [Fact] + public void Apply_ScopeDisposeClearsContext() + { + // Arrange + var context = TestConfigurationContext.Replace(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]); + + // Act + using (var scope = CocoarTestConfiguration.Apply(context)) + { + Assert.True(CocoarTestConfiguration.IsActive); + } + + // Assert + Assert.False(CocoarTestConfiguration.IsActive); + } + + [Fact] + public void Apply_CanBeCalledMultipleTimes_EachScopeClears() + { + // Arrange + var context1 = TestConfigurationContext.Replace(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "first" }) + ]); + var context2 = TestConfigurationContext.Replace(rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "second" }) + ]); + + // Act & Assert + using (var scope1 = CocoarTestConfiguration.Apply(context1)) + { + Assert.Same(context1, CocoarTestConfiguration.Current); + } + Assert.False(CocoarTestConfiguration.IsActive); + + using (var scope2 = CocoarTestConfiguration.Apply(context2)) + { + Assert.Same(context2, CocoarTestConfiguration.Current); + } + Assert.False(CocoarTestConfiguration.IsActive); + } + + private sealed class TestConfig + { + public string Value { get; set; } = ""; + } +} + +public class ConfigManagerIntegrationTests +{ + public ConfigManagerIntegrationTests() + { + CocoarTestConfiguration.Clear(); + } + + [Fact] + public void ConfigManager_AppliesTestOverrides_ReplaceMode() + { + // Arrange + using var _ = CocoarTestConfiguration.ReplaceConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig + { + Connection = "test-connection", + MaxConnections = 42 + }) + ]); + + // Act - ConfigManager with original rules + var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig + { + Connection = "original-connection", + MaxConnections = 10 + }) + ])); + + var config = configManager.GetRequiredConfig(); + + // Assert - Test overrides are used + Assert.Equal("test-connection", config.Connection); + Assert.Equal(42, config.MaxConnections); + } + + [Fact] + public void ConfigManager_AppliesTestOverrides_AppendMode() + { + // Arrange + using var _ = CocoarTestConfiguration.AppendConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig + { + MaxConnections = 999 + }) + ]); + + // Act + var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig + { + Connection = "original-connection", + MaxConnections = 10 + }) + ])); + + var config = configManager.GetRequiredConfig(); + + // Assert - Test override merged (last-write-wins) + Assert.Equal(999, config.MaxConnections); + } + + [Fact] + public void ConfigManager_WorksNormally_WhenNoTestOverride() + { + // Arrange - No test configuration set + + // Act + var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig + { + Connection = "normal-connection", + MaxConnections = 50 + }) + ])); + + var config = configManager.GetRequiredConfig(); + + // Assert - Normal behavior + Assert.Equal("normal-connection", config.Connection); + Assert.Equal(50, config.MaxConnections); + } + + [Fact] + public void ConfigManager_AppliesContextFromApply() + { + // Arrange + var context = TestConfigurationContext.Replace(rule => [ + rule.For().FromStatic(_ => new TestConfig + { + Connection = "applied-connection", + MaxConnections = 123 + }) + ]); + + using var _ = CocoarTestConfiguration.Apply(context); + + // Act + var configManager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromStatic(_ => new TestConfig + { + Connection = "original-connection", + MaxConnections = 10 + }) + ])); + + var config = configManager.GetRequiredConfig(); + + // Assert + Assert.Equal("applied-connection", config.Connection); + Assert.Equal(123, config.MaxConnections); + } + + private sealed class TestConfig + { + public string Connection { get; set; } = ""; + public int MaxConnections { get; set; } + } +} + +public class SetupOverrideTests +{ + public SetupOverrideTests() + { + CocoarTestConfiguration.Clear(); + } + + [Fact] + public void ReplaceConfiguration_WithSetup_SetsContextWithBothRulesAndSetup() + { + // Arrange + Func rules = rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]; + Func setup = builder => []; + + // Act + using var _ = CocoarTestConfiguration.ReplaceConfiguration(rules, setup); + + // Assert + Assert.True(CocoarTestConfiguration.IsActive); + Assert.NotNull(CocoarTestConfiguration.Current); + Assert.NotNull(CocoarTestConfiguration.Current!.Rules); + Assert.NotNull(CocoarTestConfiguration.Current.Setup); + Assert.Equal(TestConfigurationMode.Replace, CocoarTestConfiguration.Current.ConfigurationMode); + + // Cleanup + CocoarTestConfiguration.Clear(); + } + + [Fact] + public void AppendConfiguration_WithSetup_SetsContextWithBothRulesAndSetup() + { + // Arrange + Func rules = rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]; + Func setup = builder => []; + + // Act + using var _ = CocoarTestConfiguration.AppendConfiguration(rules, setup); + + // Assert + Assert.True(CocoarTestConfiguration.IsActive); + Assert.NotNull(CocoarTestConfiguration.Current); + Assert.NotNull(CocoarTestConfiguration.Current!.Rules); + Assert.NotNull(CocoarTestConfiguration.Current.Setup); + Assert.Equal(TestConfigurationMode.Append, CocoarTestConfiguration.Current.ConfigurationMode); + + // Cleanup + CocoarTestConfiguration.Clear(); + } + + private sealed class TestConfig + { + public string Value { get; set; } = ""; + } +} + +public class TestConfigurationContextSetupTests +{ + [Fact] + public void Replace_WithSetup_CreatesContextWithSetup() + { + // Arrange + Func setup = builder => []; + + // Act + var context = TestConfigurationContext.Replace( + rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ], + setup); + + // Assert + Assert.Equal(TestConfigurationMode.Replace, context.ConfigurationMode); + Assert.NotNull(context.Rules); + Assert.Same(setup, context.Setup); + } + + [Fact] + public void Append_WithSetup_CreatesContextWithSetup() + { + // Arrange + Func setup = builder => []; + + // Act + var context = TestConfigurationContext.Append( + rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ], + setup); + + // Assert + Assert.Equal(TestConfigurationMode.Append, context.ConfigurationMode); + Assert.NotNull(context.Rules); + Assert.Same(setup, context.Setup); + } + + [Fact] + public void Builder_WithRulesAndSetup_BuiltContextHasBoth() + { + // Arrange + Func rules = rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]; + Func setup = builder => []; + + // Act + var context = new TestOverrideBuilder() + .ReplaceConfiguration(rules, setup) + .Build(); + + // Assert + Assert.Same(rules, context.Rules); + Assert.Same(setup, context.Setup); + Assert.Equal(TestConfigurationMode.Replace, context.ConfigurationMode); + } + + private sealed class TestConfig + { + public string Value { get; set; } = ""; + } +} + +public class ConfigManagerSetupIntegrationTests +{ + public ConfigManagerSetupIntegrationTests() + { + CocoarTestConfiguration.Clear(); + } + + [Fact] + public void ConfigManager_AppliesTestSetupOverrides_WithReplaceConfiguration() + { + // Arrange + var testSetupCalled = false; + using var _ = CocoarTestConfiguration.ReplaceConfiguration( + rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ], + setup => + { + testSetupCalled = true; + return []; + }); + + // Act + var configManager = ConfigManager.Create(c => c.UseConfiguration( + rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "original" }) + ], + setup => [])); + + // Assert + Assert.True(testSetupCalled); + } + + [Fact] + public void ConfigManager_AppliesTestSetupOverrides_WithAppendConfiguration() + { + // Arrange + var testSetupCalled = false; + using var _ = CocoarTestConfiguration.AppendConfiguration( + rule => [], + setup => + { + testSetupCalled = true; + return []; + }); + + // Act + var configManager = ConfigManager.Create(c => c.UseConfiguration( + rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "original" }) + ], + setup => [])); + + // Assert + Assert.True(testSetupCalled); + } + + [Fact] + public void ConfigManager_WorksWithoutSetup_WhenTestSetupIsNull() + { + // Arrange + var configuredSetupCalled = false; + using var _ = CocoarTestConfiguration.ReplaceConfiguration( + rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ]); // No setup parameter + + // Act + var configManager = ConfigManager.Create(c => c.UseConfiguration( + rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "original" }) + ], + setup => + { + configuredSetupCalled = true; + return []; + })); + + // Assert - Configured setup should still run + Assert.True(configuredSetupCalled); + } + + [Fact] + public void ConfigManager_AppliesTestSetupOverrides_FromAppliedContext() + { + // Arrange + var testSetupCalled = false; + var context = TestConfigurationContext.Replace( + rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "test" }) + ], + setup => + { + testSetupCalled = true; + return []; + }); + + using var _ = CocoarTestConfiguration.Apply(context); + + // Act + var configManager = ConfigManager.Create(c => c.UseConfiguration( + rule => [ + rule.For().FromStatic(_ => new TestConfig { Value = "original" }) + ], + setup => [])); + + // Assert + Assert.True(testSetupCalled); + var config = configManager.GetRequiredConfig(); + Assert.Equal("test", config.Value); + } + + private sealed class TestConfig + { + public string Value { get; set; } = ""; + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/AdvancedCancellationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/AdvancedCancellationTests.cs index 6ed70d4..c6077e1 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/AdvancedCancellationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/AdvancedCancellationTests.cs @@ -1,207 +1,207 @@ -using Microsoft.Extensions.Logging.Abstractions; -using System.Reactive.Subjects; -using Cocoar.Configuration.Rules; - -using Cocoar.Configuration.Core.Tests.Helpers; -using Cocoar.Configuration.Core.Tests.TestUtilities; - -namespace Cocoar.Configuration.Core.Tests.WhiteBox; - -[Trait("Type", "WhiteBox")] -[Trait("Provider", "ConfigManager")] -[Trait("Feature", "Cancellation")] -public class AdvancedCancellationTests : IDisposable -{ - private readonly List _disposables = new(); - - public void Dispose() - { - foreach (var disposable in _disposables) - { - try { disposable.Dispose(); } catch { /* ignore */ } - } - _disposables.Clear(); - } - - private void TrackForDisposal(IDisposable disposable) => _disposables.Add(disposable); - - public record CancellationConfig(string Id, int Value, string Status); - - [Fact] - public async Task MultipleOverlappingChanges_HandlesCancellationCorrectly() - { - - var subject1 = new BehaviorSubject(new("provider-1", 0, "initial")); - var subject2 = new BehaviorSubject(new("provider-2", 0, "initial")); - var subject3 = new BehaviorSubject(new("provider-3", 0, "initial")); - - TrackForDisposal(subject1); - TrackForDisposal(subject2); - TrackForDisposal(subject3); - - var rules = new List - { - ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(subject1), - _ => ObservableProviderQuery.Default, - typeof(CancellationConfig), - new()), - - ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(subject2), - _ => ObservableProviderQuery.Default, - typeof(CancellationConfig), - new()), - - ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(subject3), - _ => ObservableProviderQuery.Default, - typeof(CancellationConfig), - new()) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(30)); - TrackForDisposal(configManager); - - // Wait for initial configuration - await ActiveWaitHelpers.WaitUntilAsync( - () => configManager.GetConfig() != null, - description: "initial configuration load"); - - subject3.OnNext(new("provider-3", 1, "changed")); - await Task.Delay(10); // Small delay between rapid changes - subject2.OnNext(new("provider-2", 1, "changed")); - await Task.Delay(10); // Small delay between rapid changes - subject1.OnNext(new("provider-1", 1, "changed")); - - // Wait for all changes to be processed - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config != null && config.Value == 1; - }, - description: "multi-provider changes completion"); - - - var finalConfig = configManager.GetConfig(); - Assert.NotNull(finalConfig); - Assert.Equal("changed", finalConfig.Status); - Assert.Equal(1, finalConfig.Value); - } - [Fact] - public async Task RapidOverlappingChanges_HandlesCancellationsChaotically() - { - - var subjects = Enumerable.Range(0, 4) - .Select(i => new BehaviorSubject(new($"provider-{i}", 0, "initial"))) - .ToList(); - - foreach (var subject in subjects) - { - TrackForDisposal(subject); - } - - var rules = subjects.Select((subject, index) => - ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(subject), - _ => ObservableProviderQuery.Default, - typeof(CancellationConfig), - new())).ToList(); - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(100)); - TrackForDisposal(configManager); - - // Wait for initial state - await ActiveWaitHelpers.WaitUntilAsync( - () => configManager.GetConfig() != null, - timeout: TimeSpan.FromSeconds(1), - description: "initial configuration load"); - - - // This should cause multiple cancellations as earlier indices arrive - for (var wave = 0; wave < 3; wave++) - { - for (var i = subjects.Count - 1; i >= 0; i--) - { - subjects[i].OnNext(new($"provider-{i}", wave * 10 + i, $"wave-{wave}")); - await Task.Delay(10); // Small delay between rapid changes - } - } - - // Wait for all changes to settle - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config != null && config.Status.Contains("wave-2"); - }, - timeout: TimeSpan.FromSeconds(2), - description: "all provider waves to complete"); - - - var finalConfig = configManager.GetConfig(); - Assert.NotNull(finalConfig); - - // Configuration should reflect the final wave of changes - Assert.True(finalConfig.Status.Contains("wave-2"), - $"Config should reflect final wave changes, got status: {finalConfig.Status}"); - } - [Fact] - public async Task HighFrequencyChanges_DebounceAndCoalesceCorrectly() - { - - var subject = new BehaviorSubject(new("burst", 0, "initial")); - TrackForDisposal(subject); - - var rules = new List - { - ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(subject), - _ => ObservableProviderQuery.Default, - typeof(CancellationConfig), - new()) - }; - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(50)); - TrackForDisposal(configManager); - - // Track emissions - var emissionCount = 0; - var reactive = configManager.GetReactiveConfig(); - using var subscription = reactive.Subscribe(_ => Interlocked.Increment(ref emissionCount)); - - // Wait for initial emission - await ActiveWaitHelpers.WaitUntilAsync( - () => emissionCount > 0, - timeout: TimeSpan.FromSeconds(1), - description: "initial observable emission"); - var initialEmissions = emissionCount; - - - const int changeCount = 100; - for (var i = 1; i <= changeCount; i++) - { - subject.OnNext(new("burst", i, $"rapid-{i}")); - await Task.Delay(1); // 1ms intervals - much faster than debounce - } - - // Wait for debouncing to complete - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config != null && config.Value == changeCount; - }, - timeout: TimeSpan.FromSeconds(2), - description: "debouncing to complete and final value to propagate"); - - - var finalEmissions = emissionCount - initialEmissions; - Assert.True(finalEmissions < changeCount / 2, - $"Expected significant debouncing. Got {finalEmissions} emissions for {changeCount} changes"); - - // Final configuration should reflect the last change - var finalConfig = configManager.GetConfig(); - Assert.NotNull(finalConfig); - Assert.Equal(changeCount, finalConfig.Value); - Assert.Equal($"rapid-{changeCount}", finalConfig.Status); - } +using Microsoft.Extensions.Logging.Abstractions; +using System.Reactive.Subjects; +using Cocoar.Configuration.Rules; + +using Cocoar.Configuration.Core.Tests.Helpers; +using Cocoar.Configuration.Core.Tests.TestUtilities; + +namespace Cocoar.Configuration.Core.Tests.WhiteBox; + +[Trait("Type", "WhiteBox")] +[Trait("Provider", "ConfigManager")] +[Trait("Feature", "Cancellation")] +public class AdvancedCancellationTests : IDisposable +{ + private readonly List _disposables = new(); + + public void Dispose() + { + foreach (var disposable in _disposables) + { + try { disposable.Dispose(); } catch { /* ignore */ } + } + _disposables.Clear(); + } + + private void TrackForDisposal(IDisposable disposable) => _disposables.Add(disposable); + + public record CancellationConfig(string Id, int Value, string Status); + + [Fact] + public async Task MultipleOverlappingChanges_HandlesCancellationCorrectly() + { + + var subject1 = new BehaviorSubject(new("provider-1", 0, "initial")); + var subject2 = new BehaviorSubject(new("provider-2", 0, "initial")); + var subject3 = new BehaviorSubject(new("provider-3", 0, "initial")); + + TrackForDisposal(subject1); + TrackForDisposal(subject2); + TrackForDisposal(subject3); + + var rules = new List + { + ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(subject1), + _ => ObservableProviderQuery.Default, + typeof(CancellationConfig), + new()), + + ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(subject2), + _ => ObservableProviderQuery.Default, + typeof(CancellationConfig), + new()), + + ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(subject3), + _ => ObservableProviderQuery.Default, + typeof(CancellationConfig), + new()) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(30)); + TrackForDisposal(configManager); + + // Wait for initial configuration + await ActiveWaitHelpers.WaitUntilAsync( + () => configManager.GetConfig() != null, + description: "initial configuration load"); + + subject3.OnNext(new("provider-3", 1, "changed")); + await Task.Delay(10); // Small delay between rapid changes + subject2.OnNext(new("provider-2", 1, "changed")); + await Task.Delay(10); // Small delay between rapid changes + subject1.OnNext(new("provider-1", 1, "changed")); + + // Wait for all changes to be processed + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config != null && config.Value == 1; + }, + description: "multi-provider changes completion"); + + + var finalConfig = configManager.GetConfig(); + Assert.NotNull(finalConfig); + Assert.Equal("changed", finalConfig.Status); + Assert.Equal(1, finalConfig.Value); + } + [Fact] + public async Task RapidOverlappingChanges_HandlesCancellationsChaotically() + { + + var subjects = Enumerable.Range(0, 4) + .Select(i => new BehaviorSubject(new($"provider-{i}", 0, "initial"))) + .ToList(); + + foreach (var subject in subjects) + { + TrackForDisposal(subject); + } + + var rules = subjects.Select((subject, index) => + ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(subject), + _ => ObservableProviderQuery.Default, + typeof(CancellationConfig), + new())).ToList(); + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(100)); + TrackForDisposal(configManager); + + // Wait for initial state + await ActiveWaitHelpers.WaitUntilAsync( + () => configManager.GetConfig() != null, + timeout: TimeSpan.FromSeconds(1), + description: "initial configuration load"); + + + // This should cause multiple cancellations as earlier indices arrive + for (var wave = 0; wave < 3; wave++) + { + for (var i = subjects.Count - 1; i >= 0; i--) + { + subjects[i].OnNext(new($"provider-{i}", wave * 10 + i, $"wave-{wave}")); + await Task.Delay(10); // Small delay between rapid changes + } + } + + // Wait for all changes to settle + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config != null && config.Status.Contains("wave-2"); + }, + timeout: TimeSpan.FromSeconds(2), + description: "all provider waves to complete"); + + + var finalConfig = configManager.GetConfig(); + Assert.NotNull(finalConfig); + + // Configuration should reflect the final wave of changes + Assert.True(finalConfig.Status.Contains("wave-2"), + $"Config should reflect final wave changes, got status: {finalConfig.Status}"); + } + [Fact] + public async Task HighFrequencyChanges_DebounceAndCoalesceCorrectly() + { + + var subject = new BehaviorSubject(new("burst", 0, "initial")); + TrackForDisposal(subject); + + var rules = new List + { + ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(subject), + _ => ObservableProviderQuery.Default, + typeof(CancellationConfig), + new()) + }; + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(50)); + TrackForDisposal(configManager); + + // Track emissions + var emissionCount = 0; + var reactive = configManager.GetReactiveConfig(); + using var subscription = reactive.Subscribe(_ => Interlocked.Increment(ref emissionCount)); + + // Wait for initial emission + await ActiveWaitHelpers.WaitUntilAsync( + () => emissionCount > 0, + timeout: TimeSpan.FromSeconds(1), + description: "initial observable emission"); + var initialEmissions = emissionCount; + + + const int changeCount = 100; + for (var i = 1; i <= changeCount; i++) + { + subject.OnNext(new("burst", i, $"rapid-{i}")); + await Task.Delay(1); // 1ms intervals - much faster than debounce + } + + // Wait for debouncing to complete + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config != null && config.Value == changeCount; + }, + timeout: TimeSpan.FromSeconds(2), + description: "debouncing to complete and final value to propagate"); + + + var finalEmissions = emissionCount - initialEmissions; + Assert.True(finalEmissions < changeCount / 2, + $"Expected significant debouncing. Got {finalEmissions} emissions for {changeCount} changes"); + + // Final configuration should reflect the last change + var finalConfig = configManager.GetConfig(); + Assert.NotNull(finalConfig); + Assert.Equal(changeCount, finalConfig.Value); + Assert.Equal($"rapid-{changeCount}", finalConfig.Status); + } } \ No newline at end of file diff --git a/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/DifferentialCorrectnessFuzzTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/DifferentialCorrectnessFuzzTests.cs index c9407cb..d35abe6 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/DifferentialCorrectnessFuzzTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/DifferentialCorrectnessFuzzTests.cs @@ -1,285 +1,285 @@ -using System.Text.Json; -using System.Reactive.Subjects; -using Microsoft.Extensions.Logging.Abstractions; -using Cocoar.Configuration.Providers; - -using Cocoar.Configuration.Core.Tests.Helpers; -using Cocoar.Configuration.Core.Tests.TestUtilities; - -namespace Cocoar.Configuration.Core.Tests.WhiteBox; - -/// -/// Differential correctness fuzz tests that prove functional correctness of the incremental recompute engine. -/// -/// PURPOSE: -/// Prove functional correctness of the incremental recompute engine irrespective of cancellation, -/// debounce or partial-prefix reuse by comparing the engine's published end-state to a canonical -/// naive full recomputation after randomized change sequences. -/// -/// APPROACH: -/// - Build N provider-backed rules with deterministic injection -/// - Apply waves of random mutations (add/update/delete) to randomly chosen providers -/// - After waves settle, capture the published aggregate JSON -/// - Independently perform a full recompute by re-running selection + flatten/merge logic -/// - Assert exact key/value parity (last-write-wins semantics preserved) -/// -/// VALIDATION: -/// - Published config exists (atomic commit succeeded) -/// - Flattened map size and every key/value matches naive merge exactly -/// - No lost updates due to cancellation, incorrect deletion propagation, or ordering errors -/// -[Trait("Type", "Fuzz")] -[Trait("Provider", "ConfigManager")] -[Trait("Feature", "CorrectnessValidation")] -public class DifferentialCorrectnessFuzzTests : IDisposable -{ - private readonly List _disposables = new(); - - public void Dispose() - { - foreach (var disposable in _disposables) - { - try { disposable.Dispose(); } catch { /* ignore */ } - } - _disposables.Clear(); - } - - private void TrackForDisposal(IDisposable disposable) => _disposables.Add(disposable); - - public record FuzzConfig(Dictionary Data); - - /// - /// Core fuzz test that validates correctness under random change sequences. - /// This is the most important test for ensuring incremental recompute correctness. - /// - [Fact] - public async Task RandomChangeSequences_ProduceCorrectIncrementalResults() - { - const int providerCount = 6; - const int waveCount = 5; - const int changesPerWave = 8; - - - var providers = new List>(); - var rules = new List(); - - for (var i = 0; i < providerCount; i++) - { - var initialData = new Dictionary - { - [$"provider{i}_id"] = i, - [$"provider{i}_value"] = 0, - ["shared_key"] = $"from_provider_{i}" // Last writer wins - }; - - var subject = new BehaviorSubject(new(initialData)); - providers.Add(subject); - TrackForDisposal(subject); - - var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(subject), - _ => ObservableProviderQuery.Default, - typeof(FuzzConfig), - new()); - - rules.Add(rule); - } - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(50)); - TrackForDisposal(configManager); - - var random = new Random(42); // Deterministic seed for reproducible tests - - - for (var wave = 0; wave < waveCount; wave++) - { - for (var change = 0; change < changesPerWave; change++) - { - var providerIndex = random.Next(providerCount); - var operation = random.Next(3); // 0=update, 1=add, 2=delete - - var currentProvider = providers[providerIndex]; - var currentData = new Dictionary(currentProvider.Value.Data); - - switch (operation) - { - case 0: // Update existing key - var existingKey = currentData.Keys.FirstOrDefault(); - if (existingKey != null) - { - currentData[existingKey] = $"updated_wave{wave}_change{change}"; - } - break; - - case 1: // Add new key - var newKey = $"dynamic_p{providerIndex}_w{wave}_c{change}"; - currentData[newKey] = random.Next(1000); - break; - - case 2: // Delete key (if exists) - var keyToDelete = currentData.Keys.Where(k => k.StartsWith("dynamic_")).FirstOrDefault(); - if (keyToDelete != null) - { - currentData.Remove(keyToDelete); - } - break; - } - - // Always update shared_key to test last-writer-wins - currentData["shared_key"] = $"from_provider_{providerIndex}_wave{wave}"; - - currentProvider.OnNext(new(currentData)); - - // Small random delay to create timing variations - await Task.Delay(random.Next(1, 10)); - } - - // Brief delay between waves - await Task.Delay(100); - } - - // Compute expected result from current provider states - var naiveResult = ComputeNaiveFullMerge(providers.Select(p => p.Value).ToList()); - - // Wait for incremental config to catch up with all debounced changes - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config != null && config.Data.Count == naiveResult.Count; - }, - timeout: TimeSpan.FromSeconds(5), - description: "incremental config to match expected key count"); - - var incrementalConfig = configManager.GetConfig(); - - ValidateResultsMatch(incrementalConfig, naiveResult); - } - [Fact] - public async Task HighFrequencyChangeBursts_MaintainCorrectness() - { - const int providerCount = 4; - - - var providers = new List>(); - var rules = new List(); - - for (var i = 0; i < providerCount; i++) - { - var initialData = new Dictionary - { - [$"burst_counter"] = 0, - [$"provider_id"] = i - }; - - var subject = new BehaviorSubject(new(initialData)); - providers.Add(subject); - TrackForDisposal(subject); - - var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(subject), - _ => ObservableProviderQuery.Default, - typeof(FuzzConfig), - new()); - - rules.Add(rule); - } - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(100)); - TrackForDisposal(configManager); - - - for (var burst = 0; burst < 3; burst++) - { - // Rapid fire changes within debounce window - for (var rapid = 0; rapid < 20; rapid++) - { - var providerIndex = rapid % providerCount; - var data = new Dictionary - { - ["burst_counter"] = burst * 100 + rapid, - ["provider_id"] = providerIndex, - ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - }; - - providers[providerIndex].OnNext(new(data)); - await Task.Delay(1); // Very rapid changes - } - - // Wait for burst to settle - await Task.Delay(200); - } - - // Final wait for complete processing - await Task.Delay(300); - - - var incrementalConfig = configManager.GetConfig(); - var naiveResult = ComputeNaiveFullMerge(providers.Select(p => p.Value).ToList()); - - ValidateResultsMatch(incrementalConfig, naiveResult); - - // Additional validation: Ensure final values reflect the last burst - if (incrementalConfig != null) - { - var burstCounter = ((JsonElement)incrementalConfig.Data["burst_counter"]).GetInt32(); - Assert.True(burstCounter >= 200, $"Config should reflect final burst values, got {burstCounter}"); - } - } - - /// - /// Performs a naive full recompute for correctness comparison. - /// This implements the same merge logic but without incremental optimizations. - /// - private Dictionary ComputeNaiveFullMerge(List providerStates) - { - var result = new Dictionary(); - - // Apply each provider's configuration in order (last writer wins) - foreach (var config in providerStates) - { - foreach (var kvp in config.Data) - { - result[kvp.Key] = kvp.Value; - } - } - - return result; - } - private void ValidateResultsMatch(FuzzConfig? incrementalConfig, Dictionary naiveResult) - { - Assert.NotNull(incrementalConfig); - - // Convert incremental result to flat dictionary - var incrementalFlat = new Dictionary(incrementalConfig.Data); - - - Assert.Equal(naiveResult.Count, incrementalFlat.Count); - - foreach (var kvp in naiveResult) - { - Assert.True(incrementalFlat.ContainsKey(kvp.Key), - $"Incremental result missing key: {kvp.Key}"); - - var incrementalValue = incrementalFlat[kvp.Key]; - var naiveValue = kvp.Value; - - // Handle JsonElement comparison - if (incrementalValue is JsonElement jsonElement) - { - var incrementalJson = jsonElement.GetRawText(); - var naiveJson = JsonSerializer.Serialize(naiveValue); - if (!string.Equals(naiveJson, incrementalJson, StringComparison.Ordinal)) - { - Assert.Fail($"Value mismatch for key {kvp.Key}: naive={naiveJson}, incremental={incrementalJson}"); - } - } - else - { - if (!Equals(naiveValue, incrementalValue)) - { - Assert.Fail($"Value mismatch for key {kvp.Key}: naive={naiveValue}, incremental={incrementalValue}"); - } - } - } - } +using System.Text.Json; +using System.Reactive.Subjects; +using Microsoft.Extensions.Logging.Abstractions; +using Cocoar.Configuration.Providers; + +using Cocoar.Configuration.Core.Tests.Helpers; +using Cocoar.Configuration.Core.Tests.TestUtilities; + +namespace Cocoar.Configuration.Core.Tests.WhiteBox; + +/// +/// Differential correctness fuzz tests that prove functional correctness of the incremental recompute engine. +/// +/// PURPOSE: +/// Prove functional correctness of the incremental recompute engine irrespective of cancellation, +/// debounce or partial-prefix reuse by comparing the engine's published end-state to a canonical +/// naive full recomputation after randomized change sequences. +/// +/// APPROACH: +/// - Build N provider-backed rules with deterministic injection +/// - Apply waves of random mutations (add/update/delete) to randomly chosen providers +/// - After waves settle, capture the published aggregate JSON +/// - Independently perform a full recompute by re-running selection + flatten/merge logic +/// - Assert exact key/value parity (last-write-wins semantics preserved) +/// +/// VALIDATION: +/// - Published config exists (atomic commit succeeded) +/// - Flattened map size and every key/value matches naive merge exactly +/// - No lost updates due to cancellation, incorrect deletion propagation, or ordering errors +/// +[Trait("Type", "Fuzz")] +[Trait("Provider", "ConfigManager")] +[Trait("Feature", "CorrectnessValidation")] +public class DifferentialCorrectnessFuzzTests : IDisposable +{ + private readonly List _disposables = new(); + + public void Dispose() + { + foreach (var disposable in _disposables) + { + try { disposable.Dispose(); } catch { /* ignore */ } + } + _disposables.Clear(); + } + + private void TrackForDisposal(IDisposable disposable) => _disposables.Add(disposable); + + public record FuzzConfig(Dictionary Data); + + /// + /// Core fuzz test that validates correctness under random change sequences. + /// This is the most important test for ensuring incremental recompute correctness. + /// + [Fact] + public async Task RandomChangeSequences_ProduceCorrectIncrementalResults() + { + const int providerCount = 6; + const int waveCount = 5; + const int changesPerWave = 8; + + + var providers = new List>(); + var rules = new List(); + + for (var i = 0; i < providerCount; i++) + { + var initialData = new Dictionary + { + [$"provider{i}_id"] = i, + [$"provider{i}_value"] = 0, + ["shared_key"] = $"from_provider_{i}" // Last writer wins + }; + + var subject = new BehaviorSubject(new(initialData)); + providers.Add(subject); + TrackForDisposal(subject); + + var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(subject), + _ => ObservableProviderQuery.Default, + typeof(FuzzConfig), + new()); + + rules.Add(rule); + } + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(50)); + TrackForDisposal(configManager); + + var random = new Random(42); // Deterministic seed for reproducible tests + + + for (var wave = 0; wave < waveCount; wave++) + { + for (var change = 0; change < changesPerWave; change++) + { + var providerIndex = random.Next(providerCount); + var operation = random.Next(3); // 0=update, 1=add, 2=delete + + var currentProvider = providers[providerIndex]; + var currentData = new Dictionary(currentProvider.Value.Data); + + switch (operation) + { + case 0: // Update existing key + var existingKey = currentData.Keys.FirstOrDefault(); + if (existingKey != null) + { + currentData[existingKey] = $"updated_wave{wave}_change{change}"; + } + break; + + case 1: // Add new key + var newKey = $"dynamic_p{providerIndex}_w{wave}_c{change}"; + currentData[newKey] = random.Next(1000); + break; + + case 2: // Delete key (if exists) + var keyToDelete = currentData.Keys.Where(k => k.StartsWith("dynamic_")).FirstOrDefault(); + if (keyToDelete != null) + { + currentData.Remove(keyToDelete); + } + break; + } + + // Always update shared_key to test last-writer-wins + currentData["shared_key"] = $"from_provider_{providerIndex}_wave{wave}"; + + currentProvider.OnNext(new(currentData)); + + // Small random delay to create timing variations + await Task.Delay(random.Next(1, 10)); + } + + // Brief delay between waves + await Task.Delay(100); + } + + // Compute expected result from current provider states + var naiveResult = ComputeNaiveFullMerge(providers.Select(p => p.Value).ToList()); + + // Wait for incremental config to catch up with all debounced changes + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config != null && config.Data.Count == naiveResult.Count; + }, + timeout: TimeSpan.FromSeconds(5), + description: "incremental config to match expected key count"); + + var incrementalConfig = configManager.GetConfig(); + + ValidateResultsMatch(incrementalConfig, naiveResult); + } + [Fact] + public async Task HighFrequencyChangeBursts_MaintainCorrectness() + { + const int providerCount = 4; + + + var providers = new List>(); + var rules = new List(); + + for (var i = 0; i < providerCount; i++) + { + var initialData = new Dictionary + { + [$"burst_counter"] = 0, + [$"provider_id"] = i + }; + + var subject = new BehaviorSubject(new(initialData)); + providers.Add(subject); + TrackForDisposal(subject); + + var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(subject), + _ => ObservableProviderQuery.Default, + typeof(FuzzConfig), + new()); + + rules.Add(rule); + } + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(100)); + TrackForDisposal(configManager); + + + for (var burst = 0; burst < 3; burst++) + { + // Rapid fire changes within debounce window + for (var rapid = 0; rapid < 20; rapid++) + { + var providerIndex = rapid % providerCount; + var data = new Dictionary + { + ["burst_counter"] = burst * 100 + rapid, + ["provider_id"] = providerIndex, + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + + providers[providerIndex].OnNext(new(data)); + await Task.Delay(1); // Very rapid changes + } + + // Wait for burst to settle + await Task.Delay(200); + } + + // Final wait for complete processing + await Task.Delay(300); + + + var incrementalConfig = configManager.GetConfig(); + var naiveResult = ComputeNaiveFullMerge(providers.Select(p => p.Value).ToList()); + + ValidateResultsMatch(incrementalConfig, naiveResult); + + // Additional validation: Ensure final values reflect the last burst + if (incrementalConfig != null) + { + var burstCounter = ((JsonElement)incrementalConfig.Data["burst_counter"]).GetInt32(); + Assert.True(burstCounter >= 200, $"Config should reflect final burst values, got {burstCounter}"); + } + } + + /// + /// Performs a naive full recompute for correctness comparison. + /// This implements the same merge logic but without incremental optimizations. + /// + private Dictionary ComputeNaiveFullMerge(List providerStates) + { + var result = new Dictionary(); + + // Apply each provider's configuration in order (last writer wins) + foreach (var config in providerStates) + { + foreach (var kvp in config.Data) + { + result[kvp.Key] = kvp.Value; + } + } + + return result; + } + private void ValidateResultsMatch(FuzzConfig? incrementalConfig, Dictionary naiveResult) + { + Assert.NotNull(incrementalConfig); + + // Convert incremental result to flat dictionary + var incrementalFlat = new Dictionary(incrementalConfig.Data); + + + Assert.Equal(naiveResult.Count, incrementalFlat.Count); + + foreach (var kvp in naiveResult) + { + Assert.True(incrementalFlat.ContainsKey(kvp.Key), + $"Incremental result missing key: {kvp.Key}"); + + var incrementalValue = incrementalFlat[kvp.Key]; + var naiveValue = kvp.Value; + + // Handle JsonElement comparison + if (incrementalValue is JsonElement jsonElement) + { + var incrementalJson = jsonElement.GetRawText(); + var naiveJson = JsonSerializer.Serialize(naiveValue); + if (!string.Equals(naiveJson, incrementalJson, StringComparison.Ordinal)) + { + Assert.Fail($"Value mismatch for key {kvp.Key}: naive={naiveJson}, incremental={incrementalJson}"); + } + } + else + { + if (!Equals(naiveValue, incrementalValue)) + { + Assert.Fail($"Value mismatch for key {kvp.Key}: naive={naiveValue}, incremental={incrementalValue}"); + } + } + } + } } \ No newline at end of file diff --git a/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/InterfaceReactiveConfigTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/InterfaceReactiveConfigTests.cs index 1bf0edd..b075f62 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/InterfaceReactiveConfigTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/InterfaceReactiveConfigTests.cs @@ -1,274 +1,274 @@ -using System.Reactive.Subjects; -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Rules; -using Cocoar.Configuration.Core.Tests.TestUtilities; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Cocoar.Configuration.Core.Tests.WhiteBox; - -/// -/// Tests for IReactiveConfig with interface types. -/// Validates that interfaces exposed via ExposeAs work correctly with GetReactiveConfig. -/// -[Trait("Type", "Unit")] -[Trait("Feature", "InterfaceReactiveConfig")] -public class InterfaceReactiveConfigTests -{ - // Test interfaces and implementations - public interface IAppSettings - { - string Name { get; } - int Version { get; } - } - - public record AppSettings(string Name, int Version) : IAppSettings; - - public interface IDatabaseSettings - { - string ConnectionString { get; } - } - - public record DatabaseSettings(string ConnectionString) : IDatabaseSettings; - - public interface IFeatureFlags - { - bool EnableNewUI { get; } - } - - public record FeatureFlags(bool EnableNewUI) : IFeatureFlags; - - private static (ConfigRule Rule, BehaviorSubject Subject) CreateRule(T initialValue) - { - var subject = new BehaviorSubject(initialValue); - var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(subject), - _ => ObservableProviderQuery.Default, - typeof(T), - new() { Required = true }); - return (rule, subject); - } - - private static ConfigManager CreateManager( - ConfigRule[] rules, - Func? setup = null) - { - return ConfigManager.Create(c => c.UseConfiguration(rules, setup).UseLogger(NullLogger.Instance).UseDebounce(10)); - } - - [Fact] - public void InterfaceReactiveConfig_CurrentValue_ReturnsCorrectValue() - { - var (rule, _) = CreateRule(new AppSettings("TestApp", 1)); - var mgr = CreateManager( - [rule], - setup => [setup.ConcreteType().ExposeAs()]); - - var reactive = mgr.GetReactiveConfig(); - - Assert.NotNull(reactive); - Assert.NotNull(reactive.CurrentValue); - Assert.Equal("TestApp", reactive.CurrentValue.Name); - Assert.Equal(1, reactive.CurrentValue.Version); - } - - [Fact] - public async Task InterfaceReactiveConfig_Subscribe_ReceivesUpdates() - { - var (rule, subject) = CreateRule(new AppSettings("Initial", 1)); - var mgr = CreateManager( - [rule], - setup => [setup.ConcreteType().ExposeAs()]); - - var reactive = mgr.GetReactiveConfig(); - var emissions = new List(); - using var sub = reactive.Subscribe(v => emissions.Add(v)); - - // Update the configuration - subject.OnNext(new AppSettings("Updated", 2)); - - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Any(e => e.Name == "Updated" && e.Version == 2), - TimeSpan.FromSeconds(2), - description: "interface reactive config emission after change"); - - Assert.Contains(emissions, e => e.Name == "Updated" && e.Version == 2); - } - - [Fact] - public async Task InterfaceReactiveConfig_MultipleUpdates_AllReceived() - { - var (rule, subject) = CreateRule(new AppSettings("v1", 1)); - var mgr = CreateManager( - [rule], - setup => [setup.ConcreteType().ExposeAs()]); - - var reactive = mgr.GetReactiveConfig(); - var emissions = new List(); - using var sub = reactive.Subscribe(v => emissions.Add(v)); - - // Multiple rapid updates - subject.OnNext(new AppSettings("v2", 2)); - await Task.Delay(50); - subject.OnNext(new AppSettings("v3", 3)); - await Task.Delay(50); - subject.OnNext(new AppSettings("v4", 4)); - - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Any(e => e.Version == 4), - TimeSpan.FromSeconds(2), - description: "final emission received"); - - // Should have received updates (may be coalesced due to debouncing) - Assert.True(emissions.Count >= 1); - Assert.Equal(4, emissions.Last().Version); - } - - [Fact] - public void InterfaceReactiveConfig_NotExposed_ThrowsHelpfulError() - { - var (rule, _) = CreateRule(new AppSettings("Test", 1)); - // Note: NOT exposing AppSettings as IAppSettings - var mgr = CreateManager([rule]); - - var ex = Assert.Throws( - () => mgr.GetReactiveConfig()); - - Assert.Contains("ExposeAs", ex.Message); - Assert.Contains("IAppSettings", ex.Message); - } - - [Fact] - public void InterfaceReactiveConfig_MultipleInterfaces_EachWorks() - { - var (appRule, _) = CreateRule(new AppSettings("App", 1)); - var (dbRule, _) = CreateRule(new DatabaseSettings("Server=localhost")); - - var mgr = CreateManager( - [appRule, dbRule], - setup => [ - setup.ConcreteType().ExposeAs(), - setup.ConcreteType().ExposeAs() - ]); - - var appReactive = mgr.GetReactiveConfig(); - var dbReactive = mgr.GetReactiveConfig(); - - Assert.Equal("App", appReactive.CurrentValue.Name); - Assert.Equal("Server=localhost", dbReactive.CurrentValue.ConnectionString); - } - - [Fact] - public async Task InterfaceReactiveConfig_WithConcreteReactive_BothWork() - { - var (rule, subject) = CreateRule(new AppSettings("Test", 1)); - var mgr = CreateManager( - [rule], - setup => [setup.ConcreteType().ExposeAs()]); - - // Get both concrete and interface reactive configs - var concreteReactive = mgr.GetReactiveConfig(); - var interfaceReactive = mgr.GetReactiveConfig(); - - var concreteEmissions = new List(); - var interfaceEmissions = new List(); - - using var concreteSub = concreteReactive.Subscribe(v => concreteEmissions.Add(v)); - using var interfaceSub = interfaceReactive.Subscribe(v => interfaceEmissions.Add(v)); - - // Update configuration - subject.OnNext(new AppSettings("Updated", 2)); - - await ActiveWaitHelpers.WaitUntilAsync( - () => concreteEmissions.Any(e => e.Version == 2) && - interfaceEmissions.Any(e => e.Version == 2), - TimeSpan.FromSeconds(2), - description: "both concrete and interface emissions received"); - - Assert.Contains(concreteEmissions, e => e.Version == 2); - Assert.Contains(interfaceEmissions, e => e.Version == 2); - } - - [Fact] - public async Task TupleWithInterface_Works() - { - var (appRule, appSubject) = CreateRule(new AppSettings("App", 1)); - var (dbRule, dbSubject) = CreateRule(new DatabaseSettings("Server=db")); - - var mgr = CreateManager( - [appRule, dbRule], - setup => [ - setup.ConcreteType().ExposeAs(), - setup.ConcreteType().ExposeAs() - ]); - - // Get tuple with interface types - var reactive = mgr.GetReactiveConfig<(IAppSettings, IDatabaseSettings)>(); - var emissions = new List<(IAppSettings, IDatabaseSettings)>(); - using var sub = reactive.Subscribe(v => emissions.Add(v)); - - // Verify initial state - var current = reactive.CurrentValue; - Assert.Equal("App", current.Item1.Name); - Assert.Equal("Server=db", current.Item2.ConnectionString); - - // Update one of the configs - appSubject.OnNext(new AppSettings("UpdatedApp", 2)); - - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Any(e => e.Item1.Name == "UpdatedApp"), - TimeSpan.FromSeconds(2), - description: "tuple with interface emission after change"); - - Assert.Contains(emissions, e => e.Item1.Name == "UpdatedApp" && e.Item2.ConnectionString == "Server=db"); - } - - [Fact] - public async Task TupleWithMixedConcreteAndInterface_Works() - { - var (appRule, appSubject) = CreateRule(new AppSettings("App", 1)); - var (featureRule, featureSubject) = CreateRule(new FeatureFlags(false)); - - var mgr = CreateManager( - [appRule, featureRule], - setup => [ - setup.ConcreteType().ExposeAs() - // Note: FeatureFlags is NOT exposed as interface - ]); - - // Get tuple with mixed concrete and interface types - var reactive = mgr.GetReactiveConfig<(IAppSettings, FeatureFlags)>(); - var emissions = new List<(IAppSettings, FeatureFlags)>(); - using var sub = reactive.Subscribe(v => emissions.Add(v)); - - // Update both configs - appSubject.OnNext(new AppSettings("NewApp", 2)); - featureSubject.OnNext(new FeatureFlags(true)); - - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Any(e => e.Item1.Name == "NewApp" && e.Item2.EnableNewUI), - TimeSpan.FromSeconds(2), - description: "mixed tuple emission after change"); - - Assert.Contains(emissions, e => e.Item1.Name == "NewApp" && e.Item2.EnableNewUI); - } - - [Fact] - public void InterfaceReactiveConfig_MultipleCalls_ShareUnderlyingData() - { - var (rule, _) = CreateRule(new AppSettings("Test", 1)); - var mgr = CreateManager( - [rule], - setup => [setup.ConcreteType().ExposeAs()]); - - var reactive1 = mgr.GetReactiveConfig(); - var reactive2 = mgr.GetReactiveConfig(); - - // Interface adapters are created fresh each call, but they share the same underlying data - // (they wrap the same cached concrete type reactive config) - Assert.Equal(reactive1.CurrentValue.Name, reactive2.CurrentValue.Name); - Assert.Equal(reactive1.CurrentValue.Version, reactive2.CurrentValue.Version); - } -} +using System.Reactive.Subjects; +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Core.Tests.TestUtilities; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cocoar.Configuration.Core.Tests.WhiteBox; + +/// +/// Tests for IReactiveConfig with interface types. +/// Validates that interfaces exposed via ExposeAs work correctly with GetReactiveConfig. +/// +[Trait("Type", "Unit")] +[Trait("Feature", "InterfaceReactiveConfig")] +public class InterfaceReactiveConfigTests +{ + // Test interfaces and implementations + public interface IAppSettings + { + string Name { get; } + int Version { get; } + } + + public record AppSettings(string Name, int Version) : IAppSettings; + + public interface IDatabaseSettings + { + string ConnectionString { get; } + } + + public record DatabaseSettings(string ConnectionString) : IDatabaseSettings; + + public interface IFeatureFlags + { + bool EnableNewUI { get; } + } + + public record FeatureFlags(bool EnableNewUI) : IFeatureFlags; + + private static (ConfigRule Rule, BehaviorSubject Subject) CreateRule(T initialValue) + { + var subject = new BehaviorSubject(initialValue); + var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(subject), + _ => ObservableProviderQuery.Default, + typeof(T), + new() { Required = true }); + return (rule, subject); + } + + private static ConfigManager CreateManager( + ConfigRule[] rules, + Func? setup = null) + { + return ConfigManager.Create(c => c.UseConfiguration(rules, setup).UseLogger(NullLogger.Instance).UseDebounce(10)); + } + + [Fact] + public void InterfaceReactiveConfig_CurrentValue_ReturnsCorrectValue() + { + var (rule, _) = CreateRule(new AppSettings("TestApp", 1)); + var mgr = CreateManager( + [rule], + setup => [setup.ConcreteType().ExposeAs()]); + + var reactive = mgr.GetReactiveConfig(); + + Assert.NotNull(reactive); + Assert.NotNull(reactive.CurrentValue); + Assert.Equal("TestApp", reactive.CurrentValue.Name); + Assert.Equal(1, reactive.CurrentValue.Version); + } + + [Fact] + public async Task InterfaceReactiveConfig_Subscribe_ReceivesUpdates() + { + var (rule, subject) = CreateRule(new AppSettings("Initial", 1)); + var mgr = CreateManager( + [rule], + setup => [setup.ConcreteType().ExposeAs()]); + + var reactive = mgr.GetReactiveConfig(); + var emissions = new List(); + using var sub = reactive.Subscribe(v => emissions.Add(v)); + + // Update the configuration + subject.OnNext(new AppSettings("Updated", 2)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Any(e => e.Name == "Updated" && e.Version == 2), + TimeSpan.FromSeconds(2), + description: "interface reactive config emission after change"); + + Assert.Contains(emissions, e => e.Name == "Updated" && e.Version == 2); + } + + [Fact] + public async Task InterfaceReactiveConfig_MultipleUpdates_AllReceived() + { + var (rule, subject) = CreateRule(new AppSettings("v1", 1)); + var mgr = CreateManager( + [rule], + setup => [setup.ConcreteType().ExposeAs()]); + + var reactive = mgr.GetReactiveConfig(); + var emissions = new List(); + using var sub = reactive.Subscribe(v => emissions.Add(v)); + + // Multiple rapid updates + subject.OnNext(new AppSettings("v2", 2)); + await Task.Delay(50); + subject.OnNext(new AppSettings("v3", 3)); + await Task.Delay(50); + subject.OnNext(new AppSettings("v4", 4)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Any(e => e.Version == 4), + TimeSpan.FromSeconds(2), + description: "final emission received"); + + // Should have received updates (may be coalesced due to debouncing) + Assert.True(emissions.Count >= 1); + Assert.Equal(4, emissions.Last().Version); + } + + [Fact] + public void InterfaceReactiveConfig_NotExposed_ThrowsHelpfulError() + { + var (rule, _) = CreateRule(new AppSettings("Test", 1)); + // Note: NOT exposing AppSettings as IAppSettings + var mgr = CreateManager([rule]); + + var ex = Assert.Throws( + () => mgr.GetReactiveConfig()); + + Assert.Contains("ExposeAs", ex.Message); + Assert.Contains("IAppSettings", ex.Message); + } + + [Fact] + public void InterfaceReactiveConfig_MultipleInterfaces_EachWorks() + { + var (appRule, _) = CreateRule(new AppSettings("App", 1)); + var (dbRule, _) = CreateRule(new DatabaseSettings("Server=localhost")); + + var mgr = CreateManager( + [appRule, dbRule], + setup => [ + setup.ConcreteType().ExposeAs(), + setup.ConcreteType().ExposeAs() + ]); + + var appReactive = mgr.GetReactiveConfig(); + var dbReactive = mgr.GetReactiveConfig(); + + Assert.Equal("App", appReactive.CurrentValue.Name); + Assert.Equal("Server=localhost", dbReactive.CurrentValue.ConnectionString); + } + + [Fact] + public async Task InterfaceReactiveConfig_WithConcreteReactive_BothWork() + { + var (rule, subject) = CreateRule(new AppSettings("Test", 1)); + var mgr = CreateManager( + [rule], + setup => [setup.ConcreteType().ExposeAs()]); + + // Get both concrete and interface reactive configs + var concreteReactive = mgr.GetReactiveConfig(); + var interfaceReactive = mgr.GetReactiveConfig(); + + var concreteEmissions = new List(); + var interfaceEmissions = new List(); + + using var concreteSub = concreteReactive.Subscribe(v => concreteEmissions.Add(v)); + using var interfaceSub = interfaceReactive.Subscribe(v => interfaceEmissions.Add(v)); + + // Update configuration + subject.OnNext(new AppSettings("Updated", 2)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => concreteEmissions.Any(e => e.Version == 2) && + interfaceEmissions.Any(e => e.Version == 2), + TimeSpan.FromSeconds(2), + description: "both concrete and interface emissions received"); + + Assert.Contains(concreteEmissions, e => e.Version == 2); + Assert.Contains(interfaceEmissions, e => e.Version == 2); + } + + [Fact] + public async Task TupleWithInterface_Works() + { + var (appRule, appSubject) = CreateRule(new AppSettings("App", 1)); + var (dbRule, dbSubject) = CreateRule(new DatabaseSettings("Server=db")); + + var mgr = CreateManager( + [appRule, dbRule], + setup => [ + setup.ConcreteType().ExposeAs(), + setup.ConcreteType().ExposeAs() + ]); + + // Get tuple with interface types + var reactive = mgr.GetReactiveConfig<(IAppSettings, IDatabaseSettings)>(); + var emissions = new List<(IAppSettings, IDatabaseSettings)>(); + using var sub = reactive.Subscribe(v => emissions.Add(v)); + + // Verify initial state + var current = reactive.CurrentValue; + Assert.Equal("App", current.Item1.Name); + Assert.Equal("Server=db", current.Item2.ConnectionString); + + // Update one of the configs + appSubject.OnNext(new AppSettings("UpdatedApp", 2)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Any(e => e.Item1.Name == "UpdatedApp"), + TimeSpan.FromSeconds(2), + description: "tuple with interface emission after change"); + + Assert.Contains(emissions, e => e.Item1.Name == "UpdatedApp" && e.Item2.ConnectionString == "Server=db"); + } + + [Fact] + public async Task TupleWithMixedConcreteAndInterface_Works() + { + var (appRule, appSubject) = CreateRule(new AppSettings("App", 1)); + var (featureRule, featureSubject) = CreateRule(new FeatureFlags(false)); + + var mgr = CreateManager( + [appRule, featureRule], + setup => [ + setup.ConcreteType().ExposeAs() + // Note: FeatureFlags is NOT exposed as interface + ]); + + // Get tuple with mixed concrete and interface types + var reactive = mgr.GetReactiveConfig<(IAppSettings, FeatureFlags)>(); + var emissions = new List<(IAppSettings, FeatureFlags)>(); + using var sub = reactive.Subscribe(v => emissions.Add(v)); + + // Update both configs + appSubject.OnNext(new AppSettings("NewApp", 2)); + featureSubject.OnNext(new FeatureFlags(true)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Any(e => e.Item1.Name == "NewApp" && e.Item2.EnableNewUI), + TimeSpan.FromSeconds(2), + description: "mixed tuple emission after change"); + + Assert.Contains(emissions, e => e.Item1.Name == "NewApp" && e.Item2.EnableNewUI); + } + + [Fact] + public void InterfaceReactiveConfig_MultipleCalls_ShareUnderlyingData() + { + var (rule, _) = CreateRule(new AppSettings("Test", 1)); + var mgr = CreateManager( + [rule], + setup => [setup.ConcreteType().ExposeAs()]); + + var reactive1 = mgr.GetReactiveConfig(); + var reactive2 = mgr.GetReactiveConfig(); + + // Interface adapters are created fresh each call, but they share the same underlying data + // (they wrap the same cached concrete type reactive config) + Assert.Equal(reactive1.CurrentValue.Name, reactive2.CurrentValue.Name); + Assert.Equal(reactive1.CurrentValue.Version, reactive2.CurrentValue.Version); + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/RecomputeStressTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/RecomputeStressTests.cs index c884b88..c573a09 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/RecomputeStressTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/RecomputeStressTests.cs @@ -1,394 +1,394 @@ -using System.Text.Json; -using System.Reactive.Subjects; -using Microsoft.Extensions.Logging.Abstractions; -using Cocoar.Configuration.Providers; - -using Cocoar.Configuration.Core.Tests.Helpers; -using Cocoar.Configuration.Core.Tests.TestUtilities; - -namespace Cocoar.Configuration.Core.Tests.WhiteBox; - -/// -/// Stress tests for the recompute engine under high-frequency change scenarios. -/// -/// PURPOSE: -/// Validate that the ConfigManager maintains stability, correctness, and reasonable -/// performance under sustained high-frequency configuration changes that would -/// overwhelm naive implementations. -/// -/// SCENARIOS: -/// - Sustained rapid changes from multiple providers -/// - Overlapping debounce windows with complex interdependencies -/// - Memory and resource stability under prolonged stress -/// - Cancellation and cleanup behavior under load -/// -/// VALIDATION: -/// - No memory leaks or resource exhaustion -/// - Final configuration state remains consistent -/// - Debouncing and coalescing work correctly under pressure -/// - Cleanup and disposal work properly even under stress -/// -[Trait("Type", "Stress")] -[Trait("Provider", "ConfigManager")] -[Trait("Feature", "HighFrequencyChanges")] -public class RecomputeStressTests : IDisposable -{ - private readonly List _disposables = new(); - - public void Dispose() - { - foreach (var disposable in _disposables) - { - try { disposable.Dispose(); } catch { /* ignore */ } - } - _disposables.Clear(); - } - - private void TrackForDisposal(IDisposable disposable) => _disposables.Add(disposable); - - public record StressConfig(Dictionary Data); - [Fact] - public async Task SustainedRapidChanges_MaintainsStabilityAndCorrectness() - { - const int providerCount = 8; - const int changesPerProvider = 200; - const int changeIntervalMs = 5; - - - var providers = new List>(); - var rules = new List(); - var changeCounters = new int[providerCount]; - - for (var i = 0; i < providerCount; i++) - { - var initialData = new Dictionary - { - [$"provider_id"] = i, - [$"change_count"] = 0, - ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - }; - - var subject = new BehaviorSubject(new(initialData)); - providers.Add(subject); - TrackForDisposal(subject); - - var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(subject), - _ => ObservableProviderQuery.Default, - typeof(StressConfig), - new()); - - rules.Add(rule); - } - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(20)); - TrackForDisposal(configManager); - - var initialMemory = GC.GetTotalMemory(true); - - - var changeTasks = providers.Select(async (provider, providerIndex) => - { - for (var change = 0; change < changesPerProvider; change++) - { - var data = new Dictionary - { - ["provider_id"] = providerIndex, - ["change_count"] = change, - ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - [$"data_p{providerIndex}_c{change}"] = $"value_{change}", - ["shared_key"] = $"from_provider_{providerIndex}_change_{change}" // Last writer wins - }; - - provider.OnNext(new(data)); - changeCounters[providerIndex] = change; - - await Task.Delay(changeIntervalMs); - } - }); - - await Task.WhenAll(changeTasks); - - // Wait for all changes to settle - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config != null; - }, - timeout: TimeSpan.FromSeconds(5), - description: "sustained rapid changes completion"); - - var finalConfig = configManager.GetConfig(); - var finalMemory = GC.GetTotalMemory(true); - - // Validate configuration exists and reflects final states - Assert.NotNull(finalConfig); - - // Verify final change count is reasonable (allowing for coalescing) - Assert.True(finalConfig.Data.ContainsKey("change_count")); - - // Memory usage should be reasonable (not a strict assertion due to GC timing) - var memoryIncrease = finalMemory - initialMemory; - Assert.True(memoryIncrease < 10_000_000, - $"Memory increase too large: {memoryIncrease} bytes. Possible memory leak."); - } - [Fact] - public async Task OverlappingDebounceWindows_CoalesceCorrectly() - { - const int providerCount = 4; - const int burstCount = 10; - const int changesPerBurst = 15; - - - var providers = new List>(); - var rules = new List(); - - for (var i = 0; i < providerCount; i++) - { - var initialData = new Dictionary - { - [$"provider_id"] = i, - [$"burst_counter"] = 0 - }; - - var subject = new BehaviorSubject(new(initialData)); - providers.Add(subject); - TrackForDisposal(subject); - - var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(subject), - _ => ObservableProviderQuery.Default, - typeof(StressConfig), - new()); - - rules.Add(rule); - } - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(100)); - TrackForDisposal(configManager); - - var publicationCount = 0; - var reactive = configManager.GetReactiveConfig(); - using var subscription = reactive.Subscribe(_ => Interlocked.Increment(ref publicationCount)); - - - for (var burst = 0; burst < burstCount; burst++) - { - // Start overlapping bursts on all providers - var burstTasks = providers.Select(async (provider, providerIndex) => - { - for (var change = 0; change < changesPerBurst; change++) - { - var data = new Dictionary - { - ["provider_id"] = providerIndex, - ["burst_counter"] = burst, - ["change_in_burst"] = change, - [$"rapid_key"] = $"burst{burst}_change{change}", - ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - }; - - provider.OnNext(new(data)); - await Task.Delay(2); // Much faster than debounce window - } - }); - - await Task.WhenAll(burstTasks); - await Task.Delay(50); // Overlap with debounce window - } - - // Wait for final debounce - await Task.Delay(300); - - - var finalConfig = configManager.GetConfig(); - - Assert.NotNull(finalConfig); - - // Publication count should be much less than total changes due to coalescing - var totalChanges = burstCount * changesPerBurst * providerCount; - Assert.True(publicationCount < totalChanges / 10, - $"Expected significant coalescing. Publications: {publicationCount}, Total changes: {totalChanges}"); - - // Configuration should reflect final burst state - var burstCounter = ((JsonElement)finalConfig.Data["burst_counter"]).GetInt32(); - Assert.Equal(burstCount - 1, burstCounter); // Final burst (0-indexed) - } - [Fact] - public async Task LargeConfigurationChanges_HandleMemoryPressureGracefully() - { - const int providerCount = 5; - const int largeDataSize = 1000; // Keys per configuration - - - var providers = new List>(); - var rules = new List(); - - for (var i = 0; i < providerCount; i++) - { - var largeData = new Dictionary(); - for (var j = 0; j < largeDataSize; j++) - { - largeData[$"large_key_{i}_{j}"] = $"large_value_{i}_{j}_{Guid.NewGuid()}"; - } - largeData["provider_id"] = i; - - var subject = new BehaviorSubject(new(largeData)); - providers.Add(subject); - TrackForDisposal(subject); - - var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(subject), - _ => ObservableProviderQuery.Default, - typeof(StressConfig), - new()); - - rules.Add(rule); - } - - var initialMemory = GC.GetTotalMemory(true); - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(50)); - TrackForDisposal(configManager); - - // Wait for initial configuration - await ActiveWaitHelpers.WaitUntilAsync( - () => configManager.GetConfig() != null, - description: "initial configuration in memory stress test"); - - for (var update = 0; update < 20; update++) - { - foreach (var (provider, index) in providers.Select((p, i) => (p, i))) - { - var updatedData = new Dictionary(); - for (var j = 0; j < largeDataSize; j++) - { - updatedData[$"large_key_{index}_{j}"] = $"updated_value_{index}_{j}_{update}_{Guid.NewGuid()}"; - } - updatedData["provider_id"] = index; - updatedData["update_count"] = update; - - provider.OnNext(new(updatedData)); - } - - await Task.Delay(25); // Rapid updates to test memory handling - } - - // Wait for all updates to complete - // Note: Due to debouncing (50ms) and rapid updates (25ms interval), many emissions are coalesced. - // The critical aspect for this stress test is that the system remains stable, not that every - // individual update is captured. We wait for any update to settle rather than a specific count. - await Task.Delay(1000); // Allow time for most updates to propagate - - await ActiveWaitHelpers.WaitUntilAsync( - () => { - var config = configManager.GetConfig(); - return config != null && config.Data.ContainsKey("update_count"); - }, - timeout: TimeSpan.FromSeconds(5), - description: "large configuration updates completion"); - - var finalConfig = configManager.GetConfig(); - var finalMemory = GC.GetTotalMemory(true); - - Assert.NotNull(finalConfig); - - // Configuration should have the expected number of keys - Assert.True(finalConfig.Data.Count >= largeDataSize, - $"Configuration should contain large data set, got {finalConfig.Data.Count} keys"); - - Assert.True(finalConfig.Data.ContainsKey("update_count"), - "Configuration should reflect updates"); - - // Memory should be managed reasonably (allowing for some growth) - var memoryIncrease = finalMemory - initialMemory; - Assert.True(memoryIncrease < 100_000_000, - $"Memory increase excessive: {memoryIncrease} bytes. Possible memory issue."); - } - [Fact] - public async Task DisposalUnderStress_CleansUpProperly() - { - const int providerCount = 6; - - - var providers = new List>(); - var rules = new List(); - - for (var i = 0; i < providerCount; i++) - { - var initialData = new Dictionary - { - ["provider_id"] = i, - ["value"] = i * 100 - }; - - var subject = new BehaviorSubject(new(initialData)); - providers.Add(subject); - - var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( - _ => new(subject), - _ => ObservableProviderQuery.Default, - typeof(StressConfig), - new()); - - rules.Add(rule); - } - - var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(30)); - - - var changeTask = Task.Run(async () => - { - for (var change = 0; change < 100; change++) - { - foreach (var (provider, index) in providers.Select((p, i) => (p, i))) - { - var data = new Dictionary - { - ["provider_id"] = index, - ["value"] = change * 100 + index, - ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - }; - - try - { - provider.OnNext(new(data)); - } - catch (ObjectDisposedException) - { - // Expected when disposal happens during changes - return; - } - } - - await Task.Delay(5); - } - }); - - // Let some changes accumulate - await Task.Delay(100); - - // Dispose while changes are in flight - configManager.Dispose(); - - // Dispose providers - foreach (var provider in providers) - { - provider.Dispose(); - } - - // Wait for change task to complete or timeout - try - { - await changeTask.WaitAsync(TimeSpan.FromSeconds(2)); - } - catch (OperationCanceledException) - { - // Expected - disposal should stop processing - } - - - // (The test passes if no exceptions were thrown during disposal) - Assert.True(true, "Disposal under stress completed without exceptions"); - } +using System.Text.Json; +using System.Reactive.Subjects; +using Microsoft.Extensions.Logging.Abstractions; +using Cocoar.Configuration.Providers; + +using Cocoar.Configuration.Core.Tests.Helpers; +using Cocoar.Configuration.Core.Tests.TestUtilities; + +namespace Cocoar.Configuration.Core.Tests.WhiteBox; + +/// +/// Stress tests for the recompute engine under high-frequency change scenarios. +/// +/// PURPOSE: +/// Validate that the ConfigManager maintains stability, correctness, and reasonable +/// performance under sustained high-frequency configuration changes that would +/// overwhelm naive implementations. +/// +/// SCENARIOS: +/// - Sustained rapid changes from multiple providers +/// - Overlapping debounce windows with complex interdependencies +/// - Memory and resource stability under prolonged stress +/// - Cancellation and cleanup behavior under load +/// +/// VALIDATION: +/// - No memory leaks or resource exhaustion +/// - Final configuration state remains consistent +/// - Debouncing and coalescing work correctly under pressure +/// - Cleanup and disposal work properly even under stress +/// +[Trait("Type", "Stress")] +[Trait("Provider", "ConfigManager")] +[Trait("Feature", "HighFrequencyChanges")] +public class RecomputeStressTests : IDisposable +{ + private readonly List _disposables = new(); + + public void Dispose() + { + foreach (var disposable in _disposables) + { + try { disposable.Dispose(); } catch { /* ignore */ } + } + _disposables.Clear(); + } + + private void TrackForDisposal(IDisposable disposable) => _disposables.Add(disposable); + + public record StressConfig(Dictionary Data); + [Fact] + public async Task SustainedRapidChanges_MaintainsStabilityAndCorrectness() + { + const int providerCount = 8; + const int changesPerProvider = 200; + const int changeIntervalMs = 5; + + + var providers = new List>(); + var rules = new List(); + var changeCounters = new int[providerCount]; + + for (var i = 0; i < providerCount; i++) + { + var initialData = new Dictionary + { + [$"provider_id"] = i, + [$"change_count"] = 0, + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + + var subject = new BehaviorSubject(new(initialData)); + providers.Add(subject); + TrackForDisposal(subject); + + var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(subject), + _ => ObservableProviderQuery.Default, + typeof(StressConfig), + new()); + + rules.Add(rule); + } + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(20)); + TrackForDisposal(configManager); + + var initialMemory = GC.GetTotalMemory(true); + + + var changeTasks = providers.Select(async (provider, providerIndex) => + { + for (var change = 0; change < changesPerProvider; change++) + { + var data = new Dictionary + { + ["provider_id"] = providerIndex, + ["change_count"] = change, + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + [$"data_p{providerIndex}_c{change}"] = $"value_{change}", + ["shared_key"] = $"from_provider_{providerIndex}_change_{change}" // Last writer wins + }; + + provider.OnNext(new(data)); + changeCounters[providerIndex] = change; + + await Task.Delay(changeIntervalMs); + } + }); + + await Task.WhenAll(changeTasks); + + // Wait for all changes to settle + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config != null; + }, + timeout: TimeSpan.FromSeconds(5), + description: "sustained rapid changes completion"); + + var finalConfig = configManager.GetConfig(); + var finalMemory = GC.GetTotalMemory(true); + + // Validate configuration exists and reflects final states + Assert.NotNull(finalConfig); + + // Verify final change count is reasonable (allowing for coalescing) + Assert.True(finalConfig.Data.ContainsKey("change_count")); + + // Memory usage should be reasonable (not a strict assertion due to GC timing) + var memoryIncrease = finalMemory - initialMemory; + Assert.True(memoryIncrease < 10_000_000, + $"Memory increase too large: {memoryIncrease} bytes. Possible memory leak."); + } + [Fact] + public async Task OverlappingDebounceWindows_CoalesceCorrectly() + { + const int providerCount = 4; + const int burstCount = 10; + const int changesPerBurst = 15; + + + var providers = new List>(); + var rules = new List(); + + for (var i = 0; i < providerCount; i++) + { + var initialData = new Dictionary + { + [$"provider_id"] = i, + [$"burst_counter"] = 0 + }; + + var subject = new BehaviorSubject(new(initialData)); + providers.Add(subject); + TrackForDisposal(subject); + + var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(subject), + _ => ObservableProviderQuery.Default, + typeof(StressConfig), + new()); + + rules.Add(rule); + } + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(100)); + TrackForDisposal(configManager); + + var publicationCount = 0; + var reactive = configManager.GetReactiveConfig(); + using var subscription = reactive.Subscribe(_ => Interlocked.Increment(ref publicationCount)); + + + for (var burst = 0; burst < burstCount; burst++) + { + // Start overlapping bursts on all providers + var burstTasks = providers.Select(async (provider, providerIndex) => + { + for (var change = 0; change < changesPerBurst; change++) + { + var data = new Dictionary + { + ["provider_id"] = providerIndex, + ["burst_counter"] = burst, + ["change_in_burst"] = change, + [$"rapid_key"] = $"burst{burst}_change{change}", + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + + provider.OnNext(new(data)); + await Task.Delay(2); // Much faster than debounce window + } + }); + + await Task.WhenAll(burstTasks); + await Task.Delay(50); // Overlap with debounce window + } + + // Wait for final debounce + await Task.Delay(300); + + + var finalConfig = configManager.GetConfig(); + + Assert.NotNull(finalConfig); + + // Publication count should be much less than total changes due to coalescing + var totalChanges = burstCount * changesPerBurst * providerCount; + Assert.True(publicationCount < totalChanges / 10, + $"Expected significant coalescing. Publications: {publicationCount}, Total changes: {totalChanges}"); + + // Configuration should reflect final burst state + var burstCounter = ((JsonElement)finalConfig.Data["burst_counter"]).GetInt32(); + Assert.Equal(burstCount - 1, burstCounter); // Final burst (0-indexed) + } + [Fact] + public async Task LargeConfigurationChanges_HandleMemoryPressureGracefully() + { + const int providerCount = 5; + const int largeDataSize = 1000; // Keys per configuration + + + var providers = new List>(); + var rules = new List(); + + for (var i = 0; i < providerCount; i++) + { + var largeData = new Dictionary(); + for (var j = 0; j < largeDataSize; j++) + { + largeData[$"large_key_{i}_{j}"] = $"large_value_{i}_{j}_{Guid.NewGuid()}"; + } + largeData["provider_id"] = i; + + var subject = new BehaviorSubject(new(largeData)); + providers.Add(subject); + TrackForDisposal(subject); + + var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(subject), + _ => ObservableProviderQuery.Default, + typeof(StressConfig), + new()); + + rules.Add(rule); + } + + var initialMemory = GC.GetTotalMemory(true); + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(50)); + TrackForDisposal(configManager); + + // Wait for initial configuration + await ActiveWaitHelpers.WaitUntilAsync( + () => configManager.GetConfig() != null, + description: "initial configuration in memory stress test"); + + for (var update = 0; update < 20; update++) + { + foreach (var (provider, index) in providers.Select((p, i) => (p, i))) + { + var updatedData = new Dictionary(); + for (var j = 0; j < largeDataSize; j++) + { + updatedData[$"large_key_{index}_{j}"] = $"updated_value_{index}_{j}_{update}_{Guid.NewGuid()}"; + } + updatedData["provider_id"] = index; + updatedData["update_count"] = update; + + provider.OnNext(new(updatedData)); + } + + await Task.Delay(25); // Rapid updates to test memory handling + } + + // Wait for all updates to complete + // Note: Due to debouncing (50ms) and rapid updates (25ms interval), many emissions are coalesced. + // The critical aspect for this stress test is that the system remains stable, not that every + // individual update is captured. We wait for any update to settle rather than a specific count. + await Task.Delay(1000); // Allow time for most updates to propagate + + await ActiveWaitHelpers.WaitUntilAsync( + () => { + var config = configManager.GetConfig(); + return config != null && config.Data.ContainsKey("update_count"); + }, + timeout: TimeSpan.FromSeconds(5), + description: "large configuration updates completion"); + + var finalConfig = configManager.GetConfig(); + var finalMemory = GC.GetTotalMemory(true); + + Assert.NotNull(finalConfig); + + // Configuration should have the expected number of keys + Assert.True(finalConfig.Data.Count >= largeDataSize, + $"Configuration should contain large data set, got {finalConfig.Data.Count} keys"); + + Assert.True(finalConfig.Data.ContainsKey("update_count"), + "Configuration should reflect updates"); + + // Memory should be managed reasonably (allowing for some growth) + var memoryIncrease = finalMemory - initialMemory; + Assert.True(memoryIncrease < 100_000_000, + $"Memory increase excessive: {memoryIncrease} bytes. Possible memory issue."); + } + [Fact] + public async Task DisposalUnderStress_CleansUpProperly() + { + const int providerCount = 6; + + + var providers = new List>(); + var rules = new List(); + + for (var i = 0; i < providerCount; i++) + { + var initialData = new Dictionary + { + ["provider_id"] = i, + ["value"] = i * 100 + }; + + var subject = new BehaviorSubject(new(initialData)); + providers.Add(subject); + + var rule = ConfigRule.Create, ObservableProviderOptions, ObservableProviderQuery>( + _ => new(subject), + _ => ObservableProviderQuery.Default, + typeof(StressConfig), + new()); + + rules.Add(rule); + } + + var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(30)); + + + var changeTask = Task.Run(async () => + { + for (var change = 0; change < 100; change++) + { + foreach (var (provider, index) in providers.Select((p, i) => (p, i))) + { + var data = new Dictionary + { + ["provider_id"] = index, + ["value"] = change * 100 + index, + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + + try + { + provider.OnNext(new(data)); + } + catch (ObjectDisposedException) + { + // Expected when disposal happens during changes + return; + } + } + + await Task.Delay(5); + } + }); + + // Let some changes accumulate + await Task.Delay(100); + + // Dispose while changes are in flight + configManager.Dispose(); + + // Dispose providers + foreach (var provider in providers) + { + provider.Dispose(); + } + + // Wait for change task to complete or timeout + try + { + await changeTask.WaitAsync(TimeSpan.FromSeconds(2)); + } + catch (OperationCanceledException) + { + // Expected - disposal should stop processing + } + + + // (The test passes if no exceptions were thrown during disposal) + Assert.True(true, "Disposal under stress completed without exceptions"); + } } \ No newline at end of file diff --git a/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/TupleReactiveConfigGuardTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/TupleReactiveConfigGuardTests.cs index 3d385c4..1d30563 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/TupleReactiveConfigGuardTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/TupleReactiveConfigGuardTests.cs @@ -1,49 +1,49 @@ -using Microsoft.Extensions.Logging.Abstractions; -using Cocoar.Configuration; -using Cocoar.Configuration.Core.Tests.Helpers; -using Cocoar.Configuration.Fluent; - -namespace Cocoar.Configuration.Core.Tests.WhiteBox; - -public class TupleReactiveConfigGuardTests -{ - private interface IApp { int V { get; } } - private record App(int V) : IApp; - - private static ConfigRule CreateStaticRule(T value) where T : class - { - var rulesBuilder = new RulesBuilder(); - return rulesBuilder.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(value)).Required(); - } - - [Fact] - public void Tuple_With_Unconfigured_Primitive_Fails() - { - var mgr = ConfigManager.Create(c => c.UseConfiguration(new[]{ - CreateStaticRule(new App(1)) - }).UseLogger(NullLogger.Instance)); - // int is not a configured type; guard should throw - Assert.Throws(() => mgr.GetReactiveConfig<(App,int)>()); - } - - [Fact] - public void Tuple_With_Exposed_Interface_Succeeds() - { - var mgr = ConfigManager.Create(c => c.UseConfiguration(new[]{ - CreateStaticRule(new App(2)) - }, setup => [setup.ConcreteType().ExposeAs()]).UseLogger(NullLogger.Instance)); - var cfg = mgr.GetReactiveConfig<(IApp,App)>(); - var snapshot = cfg.CurrentValue; - Assert.Equal(2, snapshot.Item1.V); - Assert.Equal(2, snapshot.Item2.V); - } - - [Fact] - public void Tuple_With_Unexposed_Interface_Fails() - { - var mgr = ConfigManager.Create(c => c.UseConfiguration(new[]{ - CreateStaticRule(new App(3)) - }).UseLogger(NullLogger.Instance)); - Assert.Throws(() => mgr.GetReactiveConfig<(IApp,App)>()); - } -} +using Microsoft.Extensions.Logging.Abstractions; +using Cocoar.Configuration; +using Cocoar.Configuration.Core.Tests.Helpers; +using Cocoar.Configuration.Fluent; + +namespace Cocoar.Configuration.Core.Tests.WhiteBox; + +public class TupleReactiveConfigGuardTests +{ + private interface IApp { int V { get; } } + private record App(int V) : IApp; + + private static ConfigRule CreateStaticRule(T value) where T : class + { + var rulesBuilder = new RulesBuilder(); + return rulesBuilder.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(value)).Required(); + } + + [Fact] + public void Tuple_With_Unconfigured_Primitive_Fails() + { + var mgr = ConfigManager.Create(c => c.UseConfiguration(new[]{ + CreateStaticRule(new App(1)) + }).UseLogger(NullLogger.Instance)); + // int is not a configured type; guard should throw + Assert.Throws(() => mgr.GetReactiveConfig<(App,int)>()); + } + + [Fact] + public void Tuple_With_Exposed_Interface_Succeeds() + { + var mgr = ConfigManager.Create(c => c.UseConfiguration(new[]{ + CreateStaticRule(new App(2)) + }, setup => [setup.ConcreteType().ExposeAs()]).UseLogger(NullLogger.Instance)); + var cfg = mgr.GetReactiveConfig<(IApp,App)>(); + var snapshot = cfg.CurrentValue; + Assert.Equal(2, snapshot.Item1.V); + Assert.Equal(2, snapshot.Item2.V); + } + + [Fact] + public void Tuple_With_Unexposed_Interface_Fails() + { + var mgr = ConfigManager.Create(c => c.UseConfiguration(new[]{ + CreateStaticRule(new App(3)) + }).UseLogger(NullLogger.Instance)); + Assert.Throws(() => mgr.GetReactiveConfig<(IApp,App)>()); + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/TupleReactiveConfigTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/TupleReactiveConfigTests.cs index 8688953..f1aa8ac 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/TupleReactiveConfigTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/WhiteBox/TupleReactiveConfigTests.cs @@ -1,115 +1,115 @@ -using Cocoar.Configuration.Rules; - -using Cocoar.Configuration.Core.Tests.Helpers; -using Cocoar.Configuration.Core.Tests.TestUtilities; - -namespace Cocoar.Configuration.Core.Tests.WhiteBox; - -public class TupleReactiveConfigTests -{ - // Simple POCO configs - private record A(int V); - private record B(string S); - private record C(bool Flag); - private record D(double X); - private record E(int Z); - private record F(Guid Id); - private record G(DateTime T); - private record H(long L); - - private static ConfigManager Create(params ConfigRule[] rules) - { - var mgr = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(10)); - return mgr; - } - - private static (ConfigRule Rule, System.Reactive.Subjects.BehaviorSubject Subject) Rule(T value) - { - var subject = new System.Reactive.Subjects.BehaviorSubject(value); - var rule = ConfigRule.Create, global::Cocoar.Configuration.Providers.ObservableProviderOptions, global::Cocoar.Configuration.Providers.ObservableProviderQuery>( - _ => new(subject), - _ => global::Cocoar.Configuration.Providers.ObservableProviderQuery.Default, - typeof(T), - new() { Required = true }); - return (rule, subject); - } - - [Fact] - public async Task Tuple2_Emits_WhenEitherElementChanges() - { - var (ruleA, subjA) = Rule(new A(1)); - var (ruleB, subjB) = Rule(new B("x")); - var mgr = Create(ruleA, ruleB); - var reactive = mgr.GetReactiveConfig<(A,B)>(); - var emitted = new List<(A,B)>(); - using var sub = reactive.Subscribe(v => emitted.Add(v)); - subjA.OnNext(new(2)); - await ActiveWaitHelpers.WaitUntilAsync( - () => emitted.Any(t => t.Item1.V == 2 && t.Item2.S == "x"), - TimeSpan.FromSeconds(2), - description: "tuple emission after change"); - Assert.Contains(emitted, t => t.Item1.V == 2 && t.Item2.S == "x"); - } - - [Fact] - public async Task Tuple5_Emits_SinglePass_WhenMultipleChange() - { - var rules = new List(); - var (r1, s1) = Rule(new A(1)); rules.Add(r1); - var (r2, s2) = Rule(new B("a")); rules.Add(r2); - var (r3, s3) = Rule(new C(true)); rules.Add(r3); - var (r4, s4) = Rule(new D(1.0)); rules.Add(r4); - var (r5, s5) = Rule(new E(5)); rules.Add(r5); - var mgr = Create(rules.ToArray()); - var reactive = mgr.GetReactiveConfig<(A,B,C,D,E)>(); - var list = new List<(A,B,C,D,E)>(); - using var sub = reactive.Subscribe(v => list.Add(v)); - - // Fire multiple rapid changes - should be coalesced into one emission - s1.OnNext(new(9)); - s3.OnNext(new(false)); - s5.OnNext(new(42)); - - // Wait for the coalesced emission with final values - await ActiveWaitHelpers.WaitUntilAsync( - () => list.Any(v => v.Item1.V == 9 && v.Item3.Flag == false && v.Item5.Z == 42), - TimeSpan.FromSeconds(3), - description: "coalesced emission with updated values"); - - // Verify only one emission with the final values (debouncing/coalescing worked) - var matching = list.Where(v => v.Item1.V==9 && v.Item3.Flag==false && v.Item5.Z==42).ToList(); - Assert.Single(matching); - } - - [Fact] - public async Task LargeTuple8_Supported_WithNestedRest() - { - var rules = new List(); - var (r1, s1) = Rule(new A(1)); rules.Add(r1); - var (r2, s2) = Rule(new B("b")); rules.Add(r2); - var (r3, s3) = Rule(new C(true)); rules.Add(r3); - var (r4, s4) = Rule(new D(2.5)); rules.Add(r4); - var (r5, s5) = Rule(new E(7)); rules.Add(r5); - var (r6, s6) = Rule(new F(Guid.NewGuid())); rules.Add(r6); - var (r7, s7) = Rule(new G(DateTime.UtcNow)); rules.Add(r7); - var (r8, s8) = Rule(new H(1234)); rules.Add(r8); - var mgr = Create(rules.ToArray()); - var reactive = mgr.GetReactiveConfig<(A,B,C,D,E,F,G,H)>(); - var emissions = new List<(A,B,C,D,E,F,G,H)>(); - using var sub = reactive.Subscribe(e => emissions.Add(e)); - s8.OnNext(new(9999)); - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Any(t => t.Item8.L == 9999), - TimeSpan.FromSeconds(2), - description: "large tuple emission after change"); - Assert.Contains(emissions, t => t.Item8.L == 9999); - } - - [Fact] - public void MissingConfig_Throws() - { - var (only, _) = Rule(new A(1)); - var mgr = Create(only); - Assert.Throws(() => mgr.GetReactiveConfig<(A,B)>()); - } -} +using Cocoar.Configuration.Rules; + +using Cocoar.Configuration.Core.Tests.Helpers; +using Cocoar.Configuration.Core.Tests.TestUtilities; + +namespace Cocoar.Configuration.Core.Tests.WhiteBox; + +public class TupleReactiveConfigTests +{ + // Simple POCO configs + private record A(int V); + private record B(string S); + private record C(bool Flag); + private record D(double X); + private record E(int Z); + private record F(Guid Id); + private record G(DateTime T); + private record H(long L); + + private static ConfigManager Create(params ConfigRule[] rules) + { + var mgr = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance).UseDebounce(10)); + return mgr; + } + + private static (ConfigRule Rule, System.Reactive.Subjects.BehaviorSubject Subject) Rule(T value) + { + var subject = new System.Reactive.Subjects.BehaviorSubject(value); + var rule = ConfigRule.Create, global::Cocoar.Configuration.Providers.ObservableProviderOptions, global::Cocoar.Configuration.Providers.ObservableProviderQuery>( + _ => new(subject), + _ => global::Cocoar.Configuration.Providers.ObservableProviderQuery.Default, + typeof(T), + new() { Required = true }); + return (rule, subject); + } + + [Fact] + public async Task Tuple2_Emits_WhenEitherElementChanges() + { + var (ruleA, subjA) = Rule(new A(1)); + var (ruleB, subjB) = Rule(new B("x")); + var mgr = Create(ruleA, ruleB); + var reactive = mgr.GetReactiveConfig<(A,B)>(); + var emitted = new List<(A,B)>(); + using var sub = reactive.Subscribe(v => emitted.Add(v)); + subjA.OnNext(new(2)); + await ActiveWaitHelpers.WaitUntilAsync( + () => emitted.Any(t => t.Item1.V == 2 && t.Item2.S == "x"), + TimeSpan.FromSeconds(2), + description: "tuple emission after change"); + Assert.Contains(emitted, t => t.Item1.V == 2 && t.Item2.S == "x"); + } + + [Fact] + public async Task Tuple5_Emits_SinglePass_WhenMultipleChange() + { + var rules = new List(); + var (r1, s1) = Rule(new A(1)); rules.Add(r1); + var (r2, s2) = Rule(new B("a")); rules.Add(r2); + var (r3, s3) = Rule(new C(true)); rules.Add(r3); + var (r4, s4) = Rule(new D(1.0)); rules.Add(r4); + var (r5, s5) = Rule(new E(5)); rules.Add(r5); + var mgr = Create(rules.ToArray()); + var reactive = mgr.GetReactiveConfig<(A,B,C,D,E)>(); + var list = new List<(A,B,C,D,E)>(); + using var sub = reactive.Subscribe(v => list.Add(v)); + + // Fire multiple rapid changes - should be coalesced into one emission + s1.OnNext(new(9)); + s3.OnNext(new(false)); + s5.OnNext(new(42)); + + // Wait for the coalesced emission with final values + await ActiveWaitHelpers.WaitUntilAsync( + () => list.Any(v => v.Item1.V == 9 && v.Item3.Flag == false && v.Item5.Z == 42), + TimeSpan.FromSeconds(3), + description: "coalesced emission with updated values"); + + // Verify only one emission with the final values (debouncing/coalescing worked) + var matching = list.Where(v => v.Item1.V==9 && v.Item3.Flag==false && v.Item5.Z==42).ToList(); + Assert.Single(matching); + } + + [Fact] + public async Task LargeTuple8_Supported_WithNestedRest() + { + var rules = new List(); + var (r1, s1) = Rule(new A(1)); rules.Add(r1); + var (r2, s2) = Rule(new B("b")); rules.Add(r2); + var (r3, s3) = Rule(new C(true)); rules.Add(r3); + var (r4, s4) = Rule(new D(2.5)); rules.Add(r4); + var (r5, s5) = Rule(new E(7)); rules.Add(r5); + var (r6, s6) = Rule(new F(Guid.NewGuid())); rules.Add(r6); + var (r7, s7) = Rule(new G(DateTime.UtcNow)); rules.Add(r7); + var (r8, s8) = Rule(new H(1234)); rules.Add(r8); + var mgr = Create(rules.ToArray()); + var reactive = mgr.GetReactiveConfig<(A,B,C,D,E,F,G,H)>(); + var emissions = new List<(A,B,C,D,E,F,G,H)>(); + using var sub = reactive.Subscribe(e => emissions.Add(e)); + s8.OnNext(new(9999)); + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Any(t => t.Item8.L == 9999), + TimeSpan.FromSeconds(2), + description: "large tuple emission after change"); + Assert.Contains(emissions, t => t.Item8.L == 9999); + } + + [Fact] + public void MissingConfig_Throws() + { + var (only, _) = Rule(new A(1)); + var mgr = Create(only); + Assert.Throws(() => mgr.GetReactiveConfig<(A,B)>()); + } +} diff --git a/src/tests/Cocoar.Configuration.DI.Tests/AutomaticRegistrationTests.cs b/src/tests/Cocoar.Configuration.DI.Tests/AutomaticRegistrationTests.cs index 70d7f2f..0a36c13 100644 --- a/src/tests/Cocoar.Configuration.DI.Tests/AutomaticRegistrationTests.cs +++ b/src/tests/Cocoar.Configuration.DI.Tests/AutomaticRegistrationTests.cs @@ -1,227 +1,227 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.DI; -using Cocoar.Configuration.DI.Extensions; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Reactive; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Cocoar.Configuration.DI.Tests; - -/// -/// Tests for automatic registration of configuration types from rules without explicit setup.ConcreteType() calls. -/// This validates backward compatibility - types should be auto-registered just by having a rule. -/// -public class AutomaticRegistrationTests -{ - private record AppConfig(string Name, int Version); - private record DatabaseConfig(string Host, int Port); - private record FeatureFlags(bool EnableNewUI, bool EnableBetaFeatures); - - [Fact] - public void Should_AutoRegister_Type_From_Rule_Without_Explicit_Setup() - { - // Arrange - var services = new ServiceCollection(); - - // Act - Register WITHOUT setup.ConcreteType<>() call - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() - ])); - - // Assert - Type should still be injectable - var sp = services.BuildServiceProvider(); - var config = sp.GetService(); - - Assert.NotNull(config); - Assert.Equal("TestApp", config.Name); - Assert.Equal(1, config.Version); - } - - [Fact] - public void Should_AutoRegister_Multiple_Types_From_Rules() - { - // Arrange - var services = new ServiceCollection(); - - // Act - Multiple rules, no explicit setup - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required(), - rules.For().FromStaticJson("{\"Host\":\"localhost\",\"Port\":5432}").Required(), - rules.For().FromStaticJson("{\"EnableNewUI\":true,\"EnableBetaFeatures\":false}").Required() - ])); - - // Assert - All types should be injectable - var sp = services.BuildServiceProvider(); - - var appConfig = sp.GetService(); - var dbConfig = sp.GetService(); - var features = sp.GetService(); - - Assert.NotNull(appConfig); - Assert.NotNull(dbConfig); - Assert.NotNull(features); - - Assert.Equal("TestApp", appConfig.Name); - Assert.Equal("localhost", dbConfig.Host); - Assert.True(features.EnableNewUI); - } - - [Fact] - public void AutoRegistered_Types_Should_Use_Scoped_Lifetime_By_Default() - { - // Arrange - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() - ])); - - // Assert - Check that AppConfig is registered as Scoped - var descriptor = services.FirstOrDefault(s => s.ServiceType == typeof(AppConfig)); - Assert.NotNull(descriptor); - Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime); - } - - [Fact] - public void AutoRegistered_Types_Should_Have_Same_Cached_Instance_Across_Scopes() - { - // With Master Backplane architecture, configuration instances are cached globally. - // All scopes receive the same cached instance. - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() - ])); - - var sp = services.BuildServiceProvider(); - - // Act - Get instances from different scopes - AppConfig? instance1; - AppConfig? instance2; - - using (var scope1 = sp.CreateScope()) - { - instance1 = scope1.ServiceProvider.GetRequiredService(); - } - - using (var scope2 = sp.CreateScope()) - { - instance2 = scope2.ServiceProvider.GetRequiredService(); - } - - // Assert - Same cached instance across scopes (Master Backplane behavior) - Assert.Same(instance1, instance2); - Assert.Equal("TestApp", instance1.Name); - Assert.Equal(1, instance1.Version); - } - - [Fact] - public void AutoRegistered_Types_Should_Have_Same_Instance_Within_Scope() - { - // Arrange - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() - ])); - - var sp = services.BuildServiceProvider(); - - // Act - Get instances from same scope - using var scope = sp.CreateScope(); - var instance1 = scope.ServiceProvider.GetRequiredService(); - var instance2 = scope.ServiceProvider.GetRequiredService(); - - // Assert - Same instance within scope (Scoped behavior) - Assert.Same(instance1, instance2); - } - - [Fact] - public void Explicit_Setup_Should_Override_AutoRegistration() - { - // Arrange - var services = new ServiceCollection(); - - // Act - Explicit setup.ConcreteType() should take precedence - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() - ], setup => [ - setup.ConcreteType().AsSingleton() - ])); - - var sp = services.BuildServiceProvider(); - - // Act - Get instances - var instance1 = sp.GetRequiredService(); - var instance2 = sp.GetRequiredService(); - - // Assert - Should be Singleton (not default Scoped) - Assert.Same(instance1, instance2); - } - - [Fact] - public void Should_Register_IReactiveConfig_For_AutoRegistered_Types() - { - // Arrange - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() - ])); - - // Assert - IReactiveConfig should also be available - var sp = services.BuildServiceProvider(); - var reactiveConfig = sp.GetService>(); - - Assert.NotNull(reactiveConfig); - - var currentValue = reactiveConfig.CurrentValue; - Assert.Equal("TestApp", currentValue.Name); - Assert.Equal(1, currentValue.Version); - } - - [Fact] - public void DisableAutoRegistration_Should_Prevent_Registration() - { - // Arrange - var services = new ServiceCollection(); - - // Act - Explicitly disable auto-registration - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() - ], setup => [ - setup.ConcreteType().DisableAutoRegistration() - ])); - - // Assert - Type should NOT be registered - var sp = services.BuildServiceProvider(); - var config = sp.GetService(); - - Assert.Null(config); - - // But ConfigManager should still work - var manager = sp.GetRequiredService(); - var manualConfig = manager.GetConfig(); - Assert.NotNull(manualConfig); - Assert.Equal("TestApp", manualConfig.Name); - } - - [Fact] - public void Should_AutoRegister_Types_With_Multiple_Rules() - { - // Arrange - var services = new ServiceCollection(); - - // Act - Same type from multiple rules (layering) - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson("{\"Name\":\"BaseApp\",\"Version\":1}").Required(), - rules.For().FromStaticJson("{\"Version\":2}").Required() // Overrides Version - ])); - - // Assert - Should get merged config - var sp = services.BuildServiceProvider(); - var config = sp.GetRequiredService(); - - Assert.NotNull(config); - Assert.Equal("BaseApp", config.Name); - Assert.Equal(2, config.Version); // Last rule wins - } -} +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.DI.Extensions; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Reactive; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.DI.Tests; + +/// +/// Tests for automatic registration of configuration types from rules without explicit setup.ConcreteType() calls. +/// This validates backward compatibility - types should be auto-registered just by having a rule. +/// +public class AutomaticRegistrationTests +{ + private record AppConfig(string Name, int Version); + private record DatabaseConfig(string Host, int Port); + private record FeatureFlags(bool EnableNewUI, bool EnableBetaFeatures); + + [Fact] + public void Should_AutoRegister_Type_From_Rule_Without_Explicit_Setup() + { + // Arrange + var services = new ServiceCollection(); + + // Act - Register WITHOUT setup.ConcreteType<>() call + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() + ])); + + // Assert - Type should still be injectable + var sp = services.BuildServiceProvider(); + var config = sp.GetService(); + + Assert.NotNull(config); + Assert.Equal("TestApp", config.Name); + Assert.Equal(1, config.Version); + } + + [Fact] + public void Should_AutoRegister_Multiple_Types_From_Rules() + { + // Arrange + var services = new ServiceCollection(); + + // Act - Multiple rules, no explicit setup + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required(), + rules.For().FromStaticJson("{\"Host\":\"localhost\",\"Port\":5432}").Required(), + rules.For().FromStaticJson("{\"EnableNewUI\":true,\"EnableBetaFeatures\":false}").Required() + ])); + + // Assert - All types should be injectable + var sp = services.BuildServiceProvider(); + + var appConfig = sp.GetService(); + var dbConfig = sp.GetService(); + var features = sp.GetService(); + + Assert.NotNull(appConfig); + Assert.NotNull(dbConfig); + Assert.NotNull(features); + + Assert.Equal("TestApp", appConfig.Name); + Assert.Equal("localhost", dbConfig.Host); + Assert.True(features.EnableNewUI); + } + + [Fact] + public void AutoRegistered_Types_Should_Use_Scoped_Lifetime_By_Default() + { + // Arrange + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() + ])); + + // Assert - Check that AppConfig is registered as Scoped + var descriptor = services.FirstOrDefault(s => s.ServiceType == typeof(AppConfig)); + Assert.NotNull(descriptor); + Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime); + } + + [Fact] + public void AutoRegistered_Types_Should_Have_Same_Cached_Instance_Across_Scopes() + { + // With Master Backplane architecture, configuration instances are cached globally. + // All scopes receive the same cached instance. + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() + ])); + + var sp = services.BuildServiceProvider(); + + // Act - Get instances from different scopes + AppConfig? instance1; + AppConfig? instance2; + + using (var scope1 = sp.CreateScope()) + { + instance1 = scope1.ServiceProvider.GetRequiredService(); + } + + using (var scope2 = sp.CreateScope()) + { + instance2 = scope2.ServiceProvider.GetRequiredService(); + } + + // Assert - Same cached instance across scopes (Master Backplane behavior) + Assert.Same(instance1, instance2); + Assert.Equal("TestApp", instance1.Name); + Assert.Equal(1, instance1.Version); + } + + [Fact] + public void AutoRegistered_Types_Should_Have_Same_Instance_Within_Scope() + { + // Arrange + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() + ])); + + var sp = services.BuildServiceProvider(); + + // Act - Get instances from same scope + using var scope = sp.CreateScope(); + var instance1 = scope.ServiceProvider.GetRequiredService(); + var instance2 = scope.ServiceProvider.GetRequiredService(); + + // Assert - Same instance within scope (Scoped behavior) + Assert.Same(instance1, instance2); + } + + [Fact] + public void Explicit_Setup_Should_Override_AutoRegistration() + { + // Arrange + var services = new ServiceCollection(); + + // Act - Explicit setup.ConcreteType() should take precedence + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() + ], setup => [ + setup.ConcreteType().AsSingleton() + ])); + + var sp = services.BuildServiceProvider(); + + // Act - Get instances + var instance1 = sp.GetRequiredService(); + var instance2 = sp.GetRequiredService(); + + // Assert - Should be Singleton (not default Scoped) + Assert.Same(instance1, instance2); + } + + [Fact] + public void Should_Register_IReactiveConfig_For_AutoRegistered_Types() + { + // Arrange + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() + ])); + + // Assert - IReactiveConfig should also be available + var sp = services.BuildServiceProvider(); + var reactiveConfig = sp.GetService>(); + + Assert.NotNull(reactiveConfig); + + var currentValue = reactiveConfig.CurrentValue; + Assert.Equal("TestApp", currentValue.Name); + Assert.Equal(1, currentValue.Version); + } + + [Fact] + public void DisableAutoRegistration_Should_Prevent_Registration() + { + // Arrange + var services = new ServiceCollection(); + + // Act - Explicitly disable auto-registration + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson("{\"Name\":\"TestApp\",\"Version\":1}").Required() + ], setup => [ + setup.ConcreteType().DisableAutoRegistration() + ])); + + // Assert - Type should NOT be registered + var sp = services.BuildServiceProvider(); + var config = sp.GetService(); + + Assert.Null(config); + + // But ConfigManager should still work + var manager = sp.GetRequiredService(); + var manualConfig = manager.GetConfig(); + Assert.NotNull(manualConfig); + Assert.Equal("TestApp", manualConfig.Name); + } + + [Fact] + public void Should_AutoRegister_Types_With_Multiple_Rules() + { + // Arrange + var services = new ServiceCollection(); + + // Act - Same type from multiple rules (layering) + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson("{\"Name\":\"BaseApp\",\"Version\":1}").Required(), + rules.For().FromStaticJson("{\"Version\":2}").Required() // Overrides Version + ])); + + // Assert - Should get merged config + var sp = services.BuildServiceProvider(); + var config = sp.GetRequiredService(); + + Assert.NotNull(config); + Assert.Equal("BaseApp", config.Name); + Assert.Equal(2, config.Version); // Last rule wins + } +} diff --git a/src/tests/Cocoar.Configuration.DI.Tests/BuilderPatternApiTests.cs b/src/tests/Cocoar.Configuration.DI.Tests/BuilderPatternApiTests.cs index 68e3823..98ed9fc 100644 --- a/src/tests/Cocoar.Configuration.DI.Tests/BuilderPatternApiTests.cs +++ b/src/tests/Cocoar.Configuration.DI.Tests/BuilderPatternApiTests.cs @@ -1,49 +1,49 @@ -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using Cocoar.Configuration.DI; -using Cocoar.Configuration.DI.Extensions; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; - -namespace Cocoar.Configuration.DI.Tests; - -public class BuilderPatternApiTests -{ - public interface ITestConfig { string Value { get; } } - public record TestConfig(string Value) : ITestConfig; - - [Fact] - public void Builder_Pattern_Works_With_Multiple_Types() - { - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestConfig("Hello"))).Required(), - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new AppImpl(42))).Required() - ], setup => [ - setup.ConcreteType().ExposeAs(), - setup.ConcreteType() - ])); - - var sp = services.BuildServiceProvider(); - - // Test concrete registrations - var testConfig = sp.GetRequiredService(); - var appImpl = sp.GetRequiredService(); - - // Test interface exposure - var interfaceConfig = sp.GetRequiredService(); - - Assert.Equal("Hello", testConfig.Value); - Assert.Equal(42, appImpl.V); - Assert.Equal("Hello", interfaceConfig.Value); - // Interface and concrete can be different instances; assert value equality instead of reference equality - Assert.Equal(testConfig.Value, interfaceConfig.Value); - } - - // Test classes from existing tests - private interface IApp { int V { get; } } - private record AppImpl(int V) : IApp; -} - - - +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.DI.Extensions; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; + +namespace Cocoar.Configuration.DI.Tests; + +public class BuilderPatternApiTests +{ + public interface ITestConfig { string Value { get; } } + public record TestConfig(string Value) : ITestConfig; + + [Fact] + public void Builder_Pattern_Works_With_Multiple_Types() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestConfig("Hello"))).Required(), + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new AppImpl(42))).Required() + ], setup => [ + setup.ConcreteType().ExposeAs(), + setup.ConcreteType() + ])); + + var sp = services.BuildServiceProvider(); + + // Test concrete registrations + var testConfig = sp.GetRequiredService(); + var appImpl = sp.GetRequiredService(); + + // Test interface exposure + var interfaceConfig = sp.GetRequiredService(); + + Assert.Equal("Hello", testConfig.Value); + Assert.Equal(42, appImpl.V); + Assert.Equal("Hello", interfaceConfig.Value); + // Interface and concrete can be different instances; assert value equality instead of reference equality + Assert.Equal(testConfig.Value, interfaceConfig.Value); + } + + // Test classes from existing tests + private interface IApp { int V { get; } } + private record AppImpl(int V) : IApp; +} + + + diff --git a/src/tests/Cocoar.Configuration.DI.Tests/Cocoar.Configuration.DI.Tests.csproj b/src/tests/Cocoar.Configuration.DI.Tests/Cocoar.Configuration.DI.Tests.csproj index 1a22ba1..4056fab 100644 --- a/src/tests/Cocoar.Configuration.DI.Tests/Cocoar.Configuration.DI.Tests.csproj +++ b/src/tests/Cocoar.Configuration.DI.Tests/Cocoar.Configuration.DI.Tests.csproj @@ -1,26 +1,26 @@ - - - net9.0 - enable - enable - true - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - + + + net9.0 + enable + enable + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.DI.Tests/ConfigureLifetimeAndKeyTests.cs b/src/tests/Cocoar.Configuration.DI.Tests/ConfigureLifetimeAndKeyTests.cs index b2bdf8d..62aa906 100644 --- a/src/tests/Cocoar.Configuration.DI.Tests/ConfigureLifetimeAndKeyTests.cs +++ b/src/tests/Cocoar.Configuration.DI.Tests/ConfigureLifetimeAndKeyTests.cs @@ -1,30 +1,30 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using Cocoar.Configuration.Reactive; - -namespace Cocoar.Configuration.DI.Tests; - -public class ConfigureLifetimeAndKeyTests -{ - private record App(int Value); - private interface IApp { int Value { get; } } - private record AppImpl(int Value) : IApp; - - [Fact] - public void Reactive_Config_Registered_When_Opted_In() - { - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new AppImpl(5))).Required() - ], setup => [ - setup.ConcreteType() // reactive always available - ])); - var sp = services.BuildServiceProvider(); - var mgr = sp.GetRequiredService(); - var reactive = sp.GetRequiredService>(); - Assert.Equal(5, reactive.CurrentValue.Value); - } -} +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Cocoar.Configuration.Reactive; + +namespace Cocoar.Configuration.DI.Tests; + +public class ConfigureLifetimeAndKeyTests +{ + private record App(int Value); + private interface IApp { int Value { get; } } + private record AppImpl(int Value) : IApp; + + [Fact] + public void Reactive_Config_Registered_When_Opted_In() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new AppImpl(5))).Required() + ], setup => [ + setup.ConcreteType() // reactive always available + ])); + var sp = services.BuildServiceProvider(); + var mgr = sp.GetRequiredService(); + var reactive = sp.GetRequiredService>(); + Assert.Equal(5, reactive.CurrentValue.Value); + } +} diff --git a/src/tests/Cocoar.Configuration.DI.Tests/ExposedTypeRegistrationTests.cs b/src/tests/Cocoar.Configuration.DI.Tests/ExposedTypeRegistrationTests.cs index 3272812..e3086bc 100644 --- a/src/tests/Cocoar.Configuration.DI.Tests/ExposedTypeRegistrationTests.cs +++ b/src/tests/Cocoar.Configuration.DI.Tests/ExposedTypeRegistrationTests.cs @@ -1,80 +1,80 @@ -using Cocoar.Configuration.DI; -using Cocoar.Configuration.DI.Extensions; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Cocoar.Configuration.DI.Tests; - -/// -/// Tests for exposed type registration. -/// -/// IMPORTANT: With the Master Backplane architecture (v5.0+), configuration instances -/// are cached globally. All resolved services return the same cached instance. -/// -public class ExposedTypeRegistrationTests -{ - public interface ITestConfig { string Value { get; } } - public record TestConfig(string Value) : ITestConfig; - - [Fact] - public void ExposedType_Default_Returns_Same_Cached_Instance() - { - // With Master Backplane architecture, all scopes receive the same cached instance - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestConfig("Hello"))).Required() - ], setup => [ - // Provide the concrete mapping so ConfigManager can resolve the interface - setup.ConcreteType().ExposeAs(), - // No lifetime capability specified for the exposed type => defaults to Scoped - setup.ExposedType() - ])); - - using var sp = services.BuildServiceProvider(); - using var scope1 = sp.CreateScope(); - var scoped1a = scope1.ServiceProvider.GetRequiredService(); - var scoped1b = scope1.ServiceProvider.GetRequiredService(); - - Assert.Same(scoped1a, scoped1b); // same within scope - - using var scope2 = sp.CreateScope(); - var scoped2 = scope2.ServiceProvider.GetRequiredService(); - - // With Master Backplane, all instances are the same cached object - Assert.Same(scoped1a, scoped2); - Assert.Equal("Hello", scoped1a.Value); - Assert.Equal("Hello", scoped2.Value); - } - - [Fact] - public void ExposedType_Can_Override_Lifetime_And_Add_Keyed_Registrations() - { - // With Master Backplane architecture, all instances are the same cached object - // regardless of DI lifetime settings - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestConfig("Hello"))).Required() - ], setup => [ - setup.ConcreteType().ExposeAs(), - // Override default (non-keyed) to Singleton, disable default (redundant when overriding), and add a keyed transient - setup.ExposedType().AsSingleton().DisableAutoRegistration().AsTransient("my-key") - ])); - - using var sp = services.BuildServiceProvider(); - - // Non-keyed should be singleton - var def1 = sp.GetRequiredService(); - var def2 = sp.GetRequiredService(); - Assert.Same(def1, def2); - Assert.Equal("Hello", def1.Value); - - // Keyed transient - but with Master Backplane, still returns same cached instance - var k1a = sp.GetRequiredKeyedService("my-key"); - var k1b = sp.GetRequiredKeyedService("my-key"); - Assert.Same(k1a, k1b); // Same cached instance - Assert.Equal("Hello", k1a.Value); - Assert.Equal("Hello", k1b.Value); - } -} +using Cocoar.Configuration.DI; +using Cocoar.Configuration.DI.Extensions; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.DI.Tests; + +/// +/// Tests for exposed type registration. +/// +/// IMPORTANT: With the Master Backplane architecture (v5.0+), configuration instances +/// are cached globally. All resolved services return the same cached instance. +/// +public class ExposedTypeRegistrationTests +{ + public interface ITestConfig { string Value { get; } } + public record TestConfig(string Value) : ITestConfig; + + [Fact] + public void ExposedType_Default_Returns_Same_Cached_Instance() + { + // With Master Backplane architecture, all scopes receive the same cached instance + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestConfig("Hello"))).Required() + ], setup => [ + // Provide the concrete mapping so ConfigManager can resolve the interface + setup.ConcreteType().ExposeAs(), + // No lifetime capability specified for the exposed type => defaults to Scoped + setup.ExposedType() + ])); + + using var sp = services.BuildServiceProvider(); + using var scope1 = sp.CreateScope(); + var scoped1a = scope1.ServiceProvider.GetRequiredService(); + var scoped1b = scope1.ServiceProvider.GetRequiredService(); + + Assert.Same(scoped1a, scoped1b); // same within scope + + using var scope2 = sp.CreateScope(); + var scoped2 = scope2.ServiceProvider.GetRequiredService(); + + // With Master Backplane, all instances are the same cached object + Assert.Same(scoped1a, scoped2); + Assert.Equal("Hello", scoped1a.Value); + Assert.Equal("Hello", scoped2.Value); + } + + [Fact] + public void ExposedType_Can_Override_Lifetime_And_Add_Keyed_Registrations() + { + // With Master Backplane architecture, all instances are the same cached object + // regardless of DI lifetime settings + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestConfig("Hello"))).Required() + ], setup => [ + setup.ConcreteType().ExposeAs(), + // Override default (non-keyed) to Singleton, disable default (redundant when overriding), and add a keyed transient + setup.ExposedType().AsSingleton().DisableAutoRegistration().AsTransient("my-key") + ])); + + using var sp = services.BuildServiceProvider(); + + // Non-keyed should be singleton + var def1 = sp.GetRequiredService(); + var def2 = sp.GetRequiredService(); + Assert.Same(def1, def2); + Assert.Equal("Hello", def1.Value); + + // Keyed transient - but with Master Backplane, still returns same cached instance + var k1a = sp.GetRequiredKeyedService("my-key"); + var k1b = sp.GetRequiredKeyedService("my-key"); + Assert.Same(k1a, k1b); // Same cached instance + Assert.Equal("Hello", k1a.Value); + Assert.Equal("Hello", k1b.Value); + } +} diff --git a/src/tests/Cocoar.Configuration.DI.Tests/ReactiveConfigAbsenceTests.cs b/src/tests/Cocoar.Configuration.DI.Tests/ReactiveConfigAbsenceTests.cs index 88359d3..263114a 100644 --- a/src/tests/Cocoar.Configuration.DI.Tests/ReactiveConfigAbsenceTests.cs +++ b/src/tests/Cocoar.Configuration.DI.Tests/ReactiveConfigAbsenceTests.cs @@ -1,31 +1,31 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Reactive; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Cocoar.Configuration.DI.Tests; - -public class ReactiveConfigAbsenceTests -{ - private record Foo(int Number); - - [Fact] - public void Reactive_Not_Registered_Unless_Opted_In() - { - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new Foo(9))).Required() - ], setup => [ - setup.ConcreteType() - ])); - var sp = services.BuildServiceProvider(); - // Now always registered - var reactive = sp.GetRequiredService>(); - Assert.Equal(9, reactive.CurrentValue.Number); - } -} - - - +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Reactive; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.DI.Tests; + +public class ReactiveConfigAbsenceTests +{ + private record Foo(int Number); + + [Fact] + public void Reactive_Not_Registered_Unless_Opted_In() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new Foo(9))).Required() + ], setup => [ + setup.ConcreteType() + ])); + var sp = services.BuildServiceProvider(); + // Now always registered + var reactive = sp.GetRequiredService>(); + Assert.Equal(9, reactive.CurrentValue.Number); + } +} + + + diff --git a/src/tests/Cocoar.Configuration.DI.Tests/ServiceLifetimeCapabilityTests.cs b/src/tests/Cocoar.Configuration.DI.Tests/ServiceLifetimeCapabilityTests.cs index 49da972..c5adffb 100644 --- a/src/tests/Cocoar.Configuration.DI.Tests/ServiceLifetimeCapabilityTests.cs +++ b/src/tests/Cocoar.Configuration.DI.Tests/ServiceLifetimeCapabilityTests.cs @@ -1,180 +1,180 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.DI; -using Cocoar.Configuration.DI.Extensions; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Cocoar.Configuration.DI.Tests; - -/// -/// Tests for service lifetime capabilities. -/// -/// IMPORTANT: With the Master Backplane architecture (v5.0+), configuration instances -/// are cached globally. This means: -/// - GetConfig always returns the same cached instance -/// - DI lifetime settings (Scoped/Transient) don't create new configuration instances -/// - The instance only changes when the configuration is recomputed (e.g., file change) -/// -/// The DI lifetime still affects when the service is resolved within the container, -/// but the underlying configuration instance is always the same cached object. -/// -public class ServiceLifetimeCapabilityTests -{ - private record TestService(int Value); - private interface ITestService { int Value { get; } } - - [Fact] - public void AsSingleton_Should_Create_Same_Instance() - { - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(42))).Required() - ], setup => [ - setup.ConcreteType().AsSingleton() - ])); - - var sp = services.BuildServiceProvider(); - var instance1 = sp.GetRequiredService(); - var instance2 = sp.GetRequiredService(); - - Assert.Same(instance1, instance2); - Assert.Equal(42, instance1.Value); - } - - [Fact] - public void RegisterAs_Singleton_Should_Create_Same_Instance() - { - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(42))).Required() - ], setup => [ - setup.ConcreteType().RegisterAs(ServiceLifetime.Singleton) - ])); - - var sp = services.BuildServiceProvider(); - var instance1 = sp.GetRequiredService(); - var instance2 = sp.GetRequiredService(); - - Assert.Same(instance1, instance2); - Assert.Equal(42, instance1.Value); - } - - [Fact] - public void AsTransient_Returns_Same_Cached_Instance() - { - // With Master Backplane architecture, configuration instances are cached globally. - // AsTransient affects DI container behavior but doesn't create new config instances. - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(123))).Required() - ], setup => [ - setup.ConcreteType().AsTransient() - ])); - - var sp = services.BuildServiceProvider(); - var instance1 = sp.GetRequiredService(); - var instance2 = sp.GetRequiredService(); - - // Both return the same cached instance from the backplane - Assert.Same(instance1, instance2); - Assert.Equal(123, instance1.Value); - } - - [Fact] - public void RegisterAs_Transient_Returns_Same_Cached_Instance() - { - // With Master Backplane architecture, configuration instances are cached globally. - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(123))).Required() - ], setup => [ - setup.ConcreteType().RegisterAs(ServiceLifetime.Transient) - ])); - - var sp = services.BuildServiceProvider(); - var instance1 = sp.GetRequiredService(); - var instance2 = sp.GetRequiredService(); - - // Both return the same cached instance from the backplane - Assert.Same(instance1, instance2); - Assert.Equal(123, instance1.Value); - } - - [Fact] - public void WithKey_Should_Register_Keyed_Service() - { - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(999))).Required() - ], setup => [ - setup.ConcreteType().RegisterAs(ServiceLifetime.Scoped, "my-key") - ])); - - var sp = services.BuildServiceProvider(); - var instance = sp.GetRequiredKeyedService("my-key"); - - Assert.NotNull(instance); - Assert.Equal(999, instance.Value); - } - - [Fact] - public void Default_Registration_Returns_Same_Cached_Instance() - { - // With Master Backplane architecture, configuration instances are cached globally. - // All scopes receive the same cached instance. - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(555))).Required() - ], setup => [ - setup.ConcreteType() // No explicit lifetime specified - ])); - - var sp = services.BuildServiceProvider(); - using var scope1 = sp.CreateScope(); - using var scope2 = sp.CreateScope(); - - var instance1a = scope1.ServiceProvider.GetRequiredService(); - var instance1b = scope1.ServiceProvider.GetRequiredService(); - var instance2 = scope2.ServiceProvider.GetRequiredService(); - - // All instances are the same cached object from the backplane - Assert.Same(instance1a, instance1b); - Assert.Same(instance1a, instance2); - } - - [Fact] - public void AsSingletonWithKey_Should_Register_Singleton_Keyed_Service() - { - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(777))).Required() - ], setup => [ - setup.ConcreteType().AsSingleton("singleton-key") - ])); - - var sp = services.BuildServiceProvider(); - var instance1 = sp.GetRequiredKeyedService("singleton-key"); - var instance2 = sp.GetRequiredKeyedService("singleton-key"); - - Assert.Same(instance1, instance2); // Should be same instance (singleton) - Assert.Equal(777, instance1.Value); - } - - [Fact] - public void Skip_Should_Prevent_Service_Registration() - { - var services = new ServiceCollection(); - services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ - rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(888))).Required() - ], setup => [ - setup.ConcreteType().DisableAutoRegistration() - ])); - - var sp = services.BuildServiceProvider(); - - // Service should not be registered - Assert.Throws(() => sp.GetRequiredService()); - } -} +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.DI.Extensions; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.DI.Tests; + +/// +/// Tests for service lifetime capabilities. +/// +/// IMPORTANT: With the Master Backplane architecture (v5.0+), configuration instances +/// are cached globally. This means: +/// - GetConfig always returns the same cached instance +/// - DI lifetime settings (Scoped/Transient) don't create new configuration instances +/// - The instance only changes when the configuration is recomputed (e.g., file change) +/// +/// The DI lifetime still affects when the service is resolved within the container, +/// but the underlying configuration instance is always the same cached object. +/// +public class ServiceLifetimeCapabilityTests +{ + private record TestService(int Value); + private interface ITestService { int Value { get; } } + + [Fact] + public void AsSingleton_Should_Create_Same_Instance() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(42))).Required() + ], setup => [ + setup.ConcreteType().AsSingleton() + ])); + + var sp = services.BuildServiceProvider(); + var instance1 = sp.GetRequiredService(); + var instance2 = sp.GetRequiredService(); + + Assert.Same(instance1, instance2); + Assert.Equal(42, instance1.Value); + } + + [Fact] + public void RegisterAs_Singleton_Should_Create_Same_Instance() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(42))).Required() + ], setup => [ + setup.ConcreteType().RegisterAs(ServiceLifetime.Singleton) + ])); + + var sp = services.BuildServiceProvider(); + var instance1 = sp.GetRequiredService(); + var instance2 = sp.GetRequiredService(); + + Assert.Same(instance1, instance2); + Assert.Equal(42, instance1.Value); + } + + [Fact] + public void AsTransient_Returns_Same_Cached_Instance() + { + // With Master Backplane architecture, configuration instances are cached globally. + // AsTransient affects DI container behavior but doesn't create new config instances. + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(123))).Required() + ], setup => [ + setup.ConcreteType().AsTransient() + ])); + + var sp = services.BuildServiceProvider(); + var instance1 = sp.GetRequiredService(); + var instance2 = sp.GetRequiredService(); + + // Both return the same cached instance from the backplane + Assert.Same(instance1, instance2); + Assert.Equal(123, instance1.Value); + } + + [Fact] + public void RegisterAs_Transient_Returns_Same_Cached_Instance() + { + // With Master Backplane architecture, configuration instances are cached globally. + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(123))).Required() + ], setup => [ + setup.ConcreteType().RegisterAs(ServiceLifetime.Transient) + ])); + + var sp = services.BuildServiceProvider(); + var instance1 = sp.GetRequiredService(); + var instance2 = sp.GetRequiredService(); + + // Both return the same cached instance from the backplane + Assert.Same(instance1, instance2); + Assert.Equal(123, instance1.Value); + } + + [Fact] + public void WithKey_Should_Register_Keyed_Service() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(999))).Required() + ], setup => [ + setup.ConcreteType().RegisterAs(ServiceLifetime.Scoped, "my-key") + ])); + + var sp = services.BuildServiceProvider(); + var instance = sp.GetRequiredKeyedService("my-key"); + + Assert.NotNull(instance); + Assert.Equal(999, instance.Value); + } + + [Fact] + public void Default_Registration_Returns_Same_Cached_Instance() + { + // With Master Backplane architecture, configuration instances are cached globally. + // All scopes receive the same cached instance. + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(555))).Required() + ], setup => [ + setup.ConcreteType() // No explicit lifetime specified + ])); + + var sp = services.BuildServiceProvider(); + using var scope1 = sp.CreateScope(); + using var scope2 = sp.CreateScope(); + + var instance1a = scope1.ServiceProvider.GetRequiredService(); + var instance1b = scope1.ServiceProvider.GetRequiredService(); + var instance2 = scope2.ServiceProvider.GetRequiredService(); + + // All instances are the same cached object from the backplane + Assert.Same(instance1a, instance1b); + Assert.Same(instance1a, instance2); + } + + [Fact] + public void AsSingletonWithKey_Should_Register_Singleton_Keyed_Service() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(777))).Required() + ], setup => [ + setup.ConcreteType().AsSingleton("singleton-key") + ])); + + var sp = services.BuildServiceProvider(); + var instance1 = sp.GetRequiredKeyedService("singleton-key"); + var instance2 = sp.GetRequiredKeyedService("singleton-key"); + + Assert.Same(instance1, instance2); // Should be same instance (singleton) + Assert.Equal(777, instance1.Value); + } + + [Fact] + public void Skip_Should_Prevent_Service_Registration() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ + rules.For().FromStaticJson(System.Text.Json.JsonSerializer.Serialize(new TestService(888))).Required() + ], setup => [ + setup.ConcreteType().DisableAutoRegistration() + ])); + + var sp = services.BuildServiceProvider(); + + // Service should not be registered + Assert.Throws(() => sp.GetRequiredService()); + } +} diff --git a/src/tests/Cocoar.Configuration.Flags.Tests/Cocoar.Configuration.Flags.Tests.csproj b/src/tests/Cocoar.Configuration.Flags.Tests/Cocoar.Configuration.Flags.Tests.csproj index e899bd9..dc66e7b 100644 --- a/src/tests/Cocoar.Configuration.Flags.Tests/Cocoar.Configuration.Flags.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Flags.Tests/Cocoar.Configuration.Flags.Tests.csproj @@ -1,34 +1,34 @@ - - - - net9.0 - enable - enable - true - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - + + + + net9.0 + enable + enable + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.Flags.Tests/ReactiveIntegrationTests.cs b/src/tests/Cocoar.Configuration.Flags.Tests/ReactiveIntegrationTests.cs index 9cd814d..84bbe17 100644 --- a/src/tests/Cocoar.Configuration.Flags.Tests/ReactiveIntegrationTests.cs +++ b/src/tests/Cocoar.Configuration.Flags.Tests/ReactiveIntegrationTests.cs @@ -1,277 +1,277 @@ -using Cocoar.Configuration.Reactive; -using NSubstitute; - -namespace Cocoar.Configuration.Flags.Tests; - -/// -/// Integration tests demonstrating how FeatureFlags and Entitlements -/// work with IReactiveConfig for reactive configuration updates. -/// -public class ReactiveIntegrationTests -{ - [Fact] - public void FeatureFlags_WithReactiveConfig_ReflectsCurrentValue() - { - var config = Substitute.For>(); - config.CurrentValue.Returns(new BillingConfig { NewFlowEnabled = true, FlowVersion = 2 }); - - var flags = new BillingFeatureFlags(config); - - Assert.True(flags.NewFlowEnabled()); - Assert.Equal(2, flags.FlowVersion()); - } - - [Fact] - public void FeatureFlags_WhenConfigChanges_ReturnsNewValue() - { - var currentConfig = new BillingConfig { NewFlowEnabled = false, FlowVersion = 1 }; - var config = Substitute.For>(); - config.CurrentValue.Returns(_ => currentConfig); - - var flags = new BillingFeatureFlags(config); - - Assert.False(flags.NewFlowEnabled()); - Assert.Equal(1, flags.FlowVersion()); - - currentConfig = new BillingConfig { NewFlowEnabled = true, FlowVersion = 2 }; - - Assert.True(flags.NewFlowEnabled()); - Assert.Equal(2, flags.FlowVersion()); - } - - [Fact] - public void ContextualFeatureFlag_WithReactiveConfig_EvaluatesWithCurrentConfig() - { - var currentConfig = new BillingConfig - { - NewFlowEnabled = true, - BetaUsers = new List { "alice", "bob" } - }; - var config = Substitute.For>(); - config.CurrentValue.Returns(_ => currentConfig); - - var flags = new BillingFeatureFlags(config); - - Assert.True(flags.EnabledForUser(new UserContext { Id = "alice" })); - Assert.True(flags.EnabledForUser(new UserContext { Id = "bob" })); - Assert.False(flags.EnabledForUser(new UserContext { Id = "charlie" })); - } - - [Fact] - public void ContextualFeatureFlag_WhenConfigChanges_UsesNewConfig() - { - var currentConfig = new BillingConfig - { - NewFlowEnabled = true, - BetaUsers = new List { "alice" } - }; - var config = Substitute.For>(); - config.CurrentValue.Returns(_ => currentConfig); - - var flags = new BillingFeatureFlags(config); - - Assert.False(flags.EnabledForUser(new UserContext { Id = "charlie" })); - - currentConfig = new BillingConfig - { - NewFlowEnabled = true, - BetaUsers = new List { "alice", "charlie" } - }; - - Assert.True(flags.EnabledForUser(new UserContext { Id = "charlie" })); - } - - [Fact] - public void Entitlements_WithReactiveConfig_ReflectsCurrentValue() - { - var config = Substitute.For>(); - config.CurrentValue.Returns(new PlanConfig { Tier = "premium", UserLimit = 100 }); - - var entitlements = new PlanEntitlements(config); - - Assert.True(entitlements.CanExport()); - Assert.Equal(100, entitlements.MaxUsers()); - } - - [Fact] - public void Entitlements_WhenPlanDowngrades_ReflectsNewRestrictions() - { - var currentConfig = new PlanConfig { Tier = "premium", UserLimit = 100 }; - var config = Substitute.For>(); - config.CurrentValue.Returns(_ => currentConfig); - - var entitlements = new PlanEntitlements(config); - - Assert.True(entitlements.CanExport()); - - currentConfig = new PlanConfig { Tier = "free", UserLimit = 5 }; - - Assert.False(entitlements.CanExport()); - Assert.Equal(5, entitlements.MaxUsers()); - } - - [Fact] - public void ContextualEntitlement_EvaluatesWithCurrentConfig() - { - var currentConfig = new PlanConfig - { - EnabledFeatures = new List { "export", "reports" } - }; - var config = Substitute.For>(); - config.CurrentValue.Returns(_ => currentConfig); - - var entitlements = new PlanEntitlements(config); - - Assert.True(entitlements.HasFeature(new TenantContext { Feature = "export" })); - Assert.True(entitlements.HasFeature(new TenantContext { Feature = "reports" })); - Assert.False(entitlements.HasFeature(new TenantContext { Feature = "api-access" })); - } - - [Fact] - public void FeatureFlags_CanUseWithEntitlements_ForCompositeCheck() - { - var billingConfig = Substitute.For>(); - billingConfig.CurrentValue.Returns(new BillingConfig - { - NewFlowEnabled = true, - BetaUsers = new List { "alice" } - }); - - var planConfig = Substitute.For>(); - planConfig.CurrentValue.Returns(new PlanConfig { Tier = "premium" }); - - var features = new BillingFeatureFlags(billingConfig); - var entitlements = new PlanEntitlements(planConfig); - - var user = new UserContext { Id = "alice" }; - bool canUseNewBillingFlow = features.NewFlowEnabled() && - features.EnabledForUser(user) && - entitlements.CanExport(); - - Assert.True(canUseNewBillingFlow); - } - - [Fact] - [Trait("Type", "Concurrency")] - public async Task FeatureFlags_ConcurrentRecomputeAndEvaluation_DoesNotCorrupt() - { - var currentConfig = new BillingConfig { NewFlowEnabled = true, FlowVersion = 1 }; - var config = Substitute.For>(); - config.CurrentValue.Returns(_ => currentConfig); - - var flags = new BillingFeatureFlags(config); - - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var exceptions = new System.Collections.Concurrent.ConcurrentBag(); - - // Writer: simulates reactive config recompute - var writer = Task.Run(() => - { - int i = 0; - while (!cts.Token.IsCancellationRequested) - { - currentConfig = new BillingConfig - { - NewFlowEnabled = i % 2 == 0, - FlowVersion = i, - BetaUsers = new List { $"user_{i}" } - }; - i++; - } - }); - - // Reader: simulates concurrent flag evaluation - var reader = Task.Run(() => - { - while (!cts.Token.IsCancellationRequested) - { - try - { - _ = flags.NewFlowEnabled(); - _ = flags.FlowVersion(); - _ = flags.EnabledForUser(new UserContext { Id = "alice" }); - } - catch (Exception ex) - { - exceptions.Add(ex); - } - } - }); - - await Task.WhenAll(writer, reader); - Assert.Empty(exceptions); - } - - [Fact] - public void FeatureFlags_WhenCurrentValueIsNull_ThrowsNullReferenceException() - { - var config = Substitute.For>(); - config.CurrentValue.Returns((BillingConfig)null!); - - var flags = new BillingFeatureFlags(config); - - // Accessing a property of null CurrentValue throws NRE — - // this documents the expected behavior before first recompute completes - Assert.Throws(() => flags.NewFlowEnabled()); - } - - #region Test Classes - - public class BillingConfig - { - public bool NewFlowEnabled { get; init; } - public int FlowVersion { get; init; } - public List BetaUsers { get; init; } = new(); - } - - public class PlanConfig - { - public string Tier { get; init; } = "free"; - public int UserLimit { get; init; } - public List EnabledFeatures { get; init; } = new(); - } - - public class UserContext - { - public string Id { get; init; } = string.Empty; - } - - public class TenantContext - { - public string Feature { get; init; } = string.Empty; - } - - public class BillingFeatureFlags - { - public DateTimeOffset ExpiresAt => new(2025, 6, 1, 0, 0, 0, TimeSpan.Zero); - public bool IsExpired => DateTimeOffset.UtcNow > ExpiresAt; - - public FeatureFlag NewFlowEnabled { get; } - public FeatureFlag FlowVersion { get; } - public FeatureFlag EnabledForUser { get; } - - public BillingFeatureFlags(IReactiveConfig config) - { - NewFlowEnabled = () => config.CurrentValue.NewFlowEnabled; - FlowVersion = () => config.CurrentValue.FlowVersion; - EnabledForUser = user => config.CurrentValue.NewFlowEnabled && - config.CurrentValue.BetaUsers.Contains(user.Id); - } - } - - public class PlanEntitlements - { - public Entitlement CanExport { get; } - public Entitlement MaxUsers { get; } - public Entitlement HasFeature { get; } - - public PlanEntitlements(IReactiveConfig config) - { - CanExport = () => config.CurrentValue.Tier != "free"; - MaxUsers = () => config.CurrentValue.UserLimit; - HasFeature = ctx => config.CurrentValue.EnabledFeatures.Contains(ctx.Feature); - } - } - - #endregion -} +using Cocoar.Configuration.Reactive; +using NSubstitute; + +namespace Cocoar.Configuration.Flags.Tests; + +/// +/// Integration tests demonstrating how FeatureFlags and Entitlements +/// work with IReactiveConfig for reactive configuration updates. +/// +public class ReactiveIntegrationTests +{ + [Fact] + public void FeatureFlags_WithReactiveConfig_ReflectsCurrentValue() + { + var config = Substitute.For>(); + config.CurrentValue.Returns(new BillingConfig { NewFlowEnabled = true, FlowVersion = 2 }); + + var flags = new BillingFeatureFlags(config); + + Assert.True(flags.NewFlowEnabled()); + Assert.Equal(2, flags.FlowVersion()); + } + + [Fact] + public void FeatureFlags_WhenConfigChanges_ReturnsNewValue() + { + var currentConfig = new BillingConfig { NewFlowEnabled = false, FlowVersion = 1 }; + var config = Substitute.For>(); + config.CurrentValue.Returns(_ => currentConfig); + + var flags = new BillingFeatureFlags(config); + + Assert.False(flags.NewFlowEnabled()); + Assert.Equal(1, flags.FlowVersion()); + + currentConfig = new BillingConfig { NewFlowEnabled = true, FlowVersion = 2 }; + + Assert.True(flags.NewFlowEnabled()); + Assert.Equal(2, flags.FlowVersion()); + } + + [Fact] + public void ContextualFeatureFlag_WithReactiveConfig_EvaluatesWithCurrentConfig() + { + var currentConfig = new BillingConfig + { + NewFlowEnabled = true, + BetaUsers = new List { "alice", "bob" } + }; + var config = Substitute.For>(); + config.CurrentValue.Returns(_ => currentConfig); + + var flags = new BillingFeatureFlags(config); + + Assert.True(flags.EnabledForUser(new UserContext { Id = "alice" })); + Assert.True(flags.EnabledForUser(new UserContext { Id = "bob" })); + Assert.False(flags.EnabledForUser(new UserContext { Id = "charlie" })); + } + + [Fact] + public void ContextualFeatureFlag_WhenConfigChanges_UsesNewConfig() + { + var currentConfig = new BillingConfig + { + NewFlowEnabled = true, + BetaUsers = new List { "alice" } + }; + var config = Substitute.For>(); + config.CurrentValue.Returns(_ => currentConfig); + + var flags = new BillingFeatureFlags(config); + + Assert.False(flags.EnabledForUser(new UserContext { Id = "charlie" })); + + currentConfig = new BillingConfig + { + NewFlowEnabled = true, + BetaUsers = new List { "alice", "charlie" } + }; + + Assert.True(flags.EnabledForUser(new UserContext { Id = "charlie" })); + } + + [Fact] + public void Entitlements_WithReactiveConfig_ReflectsCurrentValue() + { + var config = Substitute.For>(); + config.CurrentValue.Returns(new PlanConfig { Tier = "premium", UserLimit = 100 }); + + var entitlements = new PlanEntitlements(config); + + Assert.True(entitlements.CanExport()); + Assert.Equal(100, entitlements.MaxUsers()); + } + + [Fact] + public void Entitlements_WhenPlanDowngrades_ReflectsNewRestrictions() + { + var currentConfig = new PlanConfig { Tier = "premium", UserLimit = 100 }; + var config = Substitute.For>(); + config.CurrentValue.Returns(_ => currentConfig); + + var entitlements = new PlanEntitlements(config); + + Assert.True(entitlements.CanExport()); + + currentConfig = new PlanConfig { Tier = "free", UserLimit = 5 }; + + Assert.False(entitlements.CanExport()); + Assert.Equal(5, entitlements.MaxUsers()); + } + + [Fact] + public void ContextualEntitlement_EvaluatesWithCurrentConfig() + { + var currentConfig = new PlanConfig + { + EnabledFeatures = new List { "export", "reports" } + }; + var config = Substitute.For>(); + config.CurrentValue.Returns(_ => currentConfig); + + var entitlements = new PlanEntitlements(config); + + Assert.True(entitlements.HasFeature(new TenantContext { Feature = "export" })); + Assert.True(entitlements.HasFeature(new TenantContext { Feature = "reports" })); + Assert.False(entitlements.HasFeature(new TenantContext { Feature = "api-access" })); + } + + [Fact] + public void FeatureFlags_CanUseWithEntitlements_ForCompositeCheck() + { + var billingConfig = Substitute.For>(); + billingConfig.CurrentValue.Returns(new BillingConfig + { + NewFlowEnabled = true, + BetaUsers = new List { "alice" } + }); + + var planConfig = Substitute.For>(); + planConfig.CurrentValue.Returns(new PlanConfig { Tier = "premium" }); + + var features = new BillingFeatureFlags(billingConfig); + var entitlements = new PlanEntitlements(planConfig); + + var user = new UserContext { Id = "alice" }; + bool canUseNewBillingFlow = features.NewFlowEnabled() && + features.EnabledForUser(user) && + entitlements.CanExport(); + + Assert.True(canUseNewBillingFlow); + } + + [Fact] + [Trait("Type", "Concurrency")] + public async Task FeatureFlags_ConcurrentRecomputeAndEvaluation_DoesNotCorrupt() + { + var currentConfig = new BillingConfig { NewFlowEnabled = true, FlowVersion = 1 }; + var config = Substitute.For>(); + config.CurrentValue.Returns(_ => currentConfig); + + var flags = new BillingFeatureFlags(config); + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var exceptions = new System.Collections.Concurrent.ConcurrentBag(); + + // Writer: simulates reactive config recompute + var writer = Task.Run(() => + { + int i = 0; + while (!cts.Token.IsCancellationRequested) + { + currentConfig = new BillingConfig + { + NewFlowEnabled = i % 2 == 0, + FlowVersion = i, + BetaUsers = new List { $"user_{i}" } + }; + i++; + } + }); + + // Reader: simulates concurrent flag evaluation + var reader = Task.Run(() => + { + while (!cts.Token.IsCancellationRequested) + { + try + { + _ = flags.NewFlowEnabled(); + _ = flags.FlowVersion(); + _ = flags.EnabledForUser(new UserContext { Id = "alice" }); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + }); + + await Task.WhenAll(writer, reader); + Assert.Empty(exceptions); + } + + [Fact] + public void FeatureFlags_WhenCurrentValueIsNull_ThrowsNullReferenceException() + { + var config = Substitute.For>(); + config.CurrentValue.Returns((BillingConfig)null!); + + var flags = new BillingFeatureFlags(config); + + // Accessing a property of null CurrentValue throws NRE — + // this documents the expected behavior before first recompute completes + Assert.Throws(() => flags.NewFlowEnabled()); + } + + #region Test Classes + + public class BillingConfig + { + public bool NewFlowEnabled { get; init; } + public int FlowVersion { get; init; } + public List BetaUsers { get; init; } = new(); + } + + public class PlanConfig + { + public string Tier { get; init; } = "free"; + public int UserLimit { get; init; } + public List EnabledFeatures { get; init; } = new(); + } + + public class UserContext + { + public string Id { get; init; } = string.Empty; + } + + public class TenantContext + { + public string Feature { get; init; } = string.Empty; + } + + public class BillingFeatureFlags + { + public DateTimeOffset ExpiresAt => new(2025, 6, 1, 0, 0, 0, TimeSpan.Zero); + public bool IsExpired => DateTimeOffset.UtcNow > ExpiresAt; + + public FeatureFlag NewFlowEnabled { get; } + public FeatureFlag FlowVersion { get; } + public FeatureFlag EnabledForUser { get; } + + public BillingFeatureFlags(IReactiveConfig config) + { + NewFlowEnabled = () => config.CurrentValue.NewFlowEnabled; + FlowVersion = () => config.CurrentValue.FlowVersion; + EnabledForUser = user => config.CurrentValue.NewFlowEnabled && + config.CurrentValue.BetaUsers.Contains(user.Id); + } + } + + public class PlanEntitlements + { + public Entitlement CanExport { get; } + public Entitlement MaxUsers { get; } + public Entitlement HasFeature { get; } + + public PlanEntitlements(IReactiveConfig config) + { + CanExport = () => config.CurrentValue.Tier != "free"; + MaxUsers = () => config.CurrentValue.UserLimit; + HasFeature = ctx => config.CurrentValue.EnabledFeatures.Contains(ctx.Feature); + } + } + + #endregion +} diff --git a/src/tests/Cocoar.Configuration.Flags.Tests/RegistryTests.cs b/src/tests/Cocoar.Configuration.Flags.Tests/RegistryTests.cs index 166aab1..ef725b5 100644 --- a/src/tests/Cocoar.Configuration.Flags.Tests/RegistryTests.cs +++ b/src/tests/Cocoar.Configuration.Flags.Tests/RegistryTests.cs @@ -1,96 +1,96 @@ -namespace Cocoar.Configuration.Flags.Tests; - -public class FeatureFlagsDescriptorsTests -{ - private static readonly DateTimeOffset FutureExpiry = new(2099, 12, 31, 0, 0, 0, TimeSpan.Zero); - private static readonly DateTimeOffset PastExpiry = new(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); - - [Fact] - public void All_ReturnsAllDescriptors() - { - var d1 = MakeFlagsDescriptor(typeof(TestFlags), FutureExpiry); - var d2 = MakeFlagsDescriptor(typeof(AnotherTestFlags), FutureExpiry); - - var descriptors = new FeatureFlagsDescriptors([d1, d2]); - - Assert.Equal(2, descriptors.All.Count); - Assert.Contains(d1, descriptors.All); - Assert.Contains(d2, descriptors.All); - } - - [Fact] - public void Expired_ReturnsOnlyExpiredDescriptors() - { - var expired = MakeFlagsDescriptor(typeof(TestFlags), PastExpiry); - var valid = MakeFlagsDescriptor(typeof(AnotherTestFlags), FutureExpiry); - - var descriptors = new FeatureFlagsDescriptors([expired, valid]); - - Assert.Single(descriptors.Expired); - Assert.Contains(expired, descriptors.Expired); - Assert.DoesNotContain(valid, descriptors.Expired); - } - - [Fact] - public void Expired_WhenNoneExpired_ReturnsEmpty() - { - var d1 = MakeFlagsDescriptor(typeof(TestFlags), FutureExpiry); - var d2 = MakeFlagsDescriptor(typeof(AnotherTestFlags), FutureExpiry); - - var descriptors = new FeatureFlagsDescriptors([d1, d2]); - - Assert.Empty(descriptors.Expired); - } - - [Fact] - public void All_WhenEmpty_ReturnsEmpty() - { - var descriptors = new FeatureFlagsDescriptors([]); - - Assert.Empty(descriptors.All); - Assert.Empty(descriptors.Expired); - } - - private static FeatureFlagClassDescriptor MakeFlagsDescriptor(Type type, DateTimeOffset expiresAt) - => new(type, expiresAt, []); - - private class TestFlags - { - public DateTimeOffset ExpiresAt => new(2099, 12, 31, 0, 0, 0, TimeSpan.Zero); - } - - private class AnotherTestFlags - { - public DateTimeOffset ExpiresAt => new(2099, 6, 1, 0, 0, 0, TimeSpan.Zero); - } -} - -public class EntitlementsDescriptorsTests -{ - [Fact] - public void All_ReturnsAllDescriptors() - { - var d1 = MakeEntitlementDescriptor(typeof(TestEntitlements)); - var d2 = MakeEntitlementDescriptor(typeof(AnotherEntitlements)); - - var descriptors = new EntitlementsDescriptors([d1, d2]); - - Assert.Equal(2, descriptors.All.Count); - Assert.Contains(d1, descriptors.All); - Assert.Contains(d2, descriptors.All); - } - - [Fact] - public void All_WhenEmpty_ReturnsEmpty() - { - var descriptors = new EntitlementsDescriptors([]); - - Assert.Empty(descriptors.All); - } - - private static EntitlementClassDescriptor MakeEntitlementDescriptor(Type type) - => new(type, []); - - private class TestEntitlements { } - private class AnotherEntitlements { } -} +namespace Cocoar.Configuration.Flags.Tests; + +public class FeatureFlagsDescriptorsTests +{ + private static readonly DateTimeOffset FutureExpiry = new(2099, 12, 31, 0, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset PastExpiry = new(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public void All_ReturnsAllDescriptors() + { + var d1 = MakeFlagsDescriptor(typeof(TestFlags), FutureExpiry); + var d2 = MakeFlagsDescriptor(typeof(AnotherTestFlags), FutureExpiry); + + var descriptors = new FeatureFlagsDescriptors([d1, d2]); + + Assert.Equal(2, descriptors.All.Count); + Assert.Contains(d1, descriptors.All); + Assert.Contains(d2, descriptors.All); + } + + [Fact] + public void Expired_ReturnsOnlyExpiredDescriptors() + { + var expired = MakeFlagsDescriptor(typeof(TestFlags), PastExpiry); + var valid = MakeFlagsDescriptor(typeof(AnotherTestFlags), FutureExpiry); + + var descriptors = new FeatureFlagsDescriptors([expired, valid]); + + Assert.Single(descriptors.Expired); + Assert.Contains(expired, descriptors.Expired); + Assert.DoesNotContain(valid, descriptors.Expired); + } + + [Fact] + public void Expired_WhenNoneExpired_ReturnsEmpty() + { + var d1 = MakeFlagsDescriptor(typeof(TestFlags), FutureExpiry); + var d2 = MakeFlagsDescriptor(typeof(AnotherTestFlags), FutureExpiry); + + var descriptors = new FeatureFlagsDescriptors([d1, d2]); + + Assert.Empty(descriptors.Expired); + } + + [Fact] + public void All_WhenEmpty_ReturnsEmpty() + { + var descriptors = new FeatureFlagsDescriptors([]); + + Assert.Empty(descriptors.All); + Assert.Empty(descriptors.Expired); + } + + private static FeatureFlagClassDescriptor MakeFlagsDescriptor(Type type, DateTimeOffset expiresAt) + => new(type, expiresAt, []); + + private class TestFlags + { + public DateTimeOffset ExpiresAt => new(2099, 12, 31, 0, 0, 0, TimeSpan.Zero); + } + + private class AnotherTestFlags + { + public DateTimeOffset ExpiresAt => new(2099, 6, 1, 0, 0, 0, TimeSpan.Zero); + } +} + +public class EntitlementsDescriptorsTests +{ + [Fact] + public void All_ReturnsAllDescriptors() + { + var d1 = MakeEntitlementDescriptor(typeof(TestEntitlements)); + var d2 = MakeEntitlementDescriptor(typeof(AnotherEntitlements)); + + var descriptors = new EntitlementsDescriptors([d1, d2]); + + Assert.Equal(2, descriptors.All.Count); + Assert.Contains(d1, descriptors.All); + Assert.Contains(d2, descriptors.All); + } + + [Fact] + public void All_WhenEmpty_ReturnsEmpty() + { + var descriptors = new EntitlementsDescriptors([]); + + Assert.Empty(descriptors.All); + } + + private static EntitlementClassDescriptor MakeEntitlementDescriptor(Type type) + => new(type, []); + + private class TestEntitlements { } + private class AnotherEntitlements { } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj b/src/tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj index b698d3a..9f0d413 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj @@ -1,28 +1,28 @@ - - - net9.0 - enable - enable - Cocoar.Configuration.Providers.Tests - Cocoar.Configuration.Providers.Tests - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - + + + net9.0 + enable + enable + Cocoar.Configuration.Providers.Tests + Cocoar.Configuration.Providers.Tests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/CommandLine/CommandLineArgumentProviderUnitTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/CommandLine/CommandLineArgumentProviderUnitTests.cs index 811b9f2..fb0446c 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/CommandLine/CommandLineArgumentProviderUnitTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/CommandLine/CommandLineArgumentProviderUnitTests.cs @@ -1,394 +1,394 @@ -using Xunit; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Rules; - -namespace Cocoar.Configuration.Providers.Tests.CommandLine; - -public class CommandLineArgumentProviderUnitTests -{ - private static readonly RulesBuilder Builder = new(); - - private sealed class SimpleConfig { public string? Host { get; set; } public int Port { get; set; } } - private sealed class DatabaseConfig { public string? Host { get; set; } public int Port { get; set; } public string? Name { get; set; } } - private sealed class NestedConfig { public DatabaseConfig? Database { get; set; } } - private sealed class FlagsConfig { public bool Debug { get; set; } public bool Verbose { get; set; } } - private sealed class MixedConfig { public string? Host { get; set; } public int Port { get; set; } public bool Debug { get; set; } } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void EmptyArgs_ReturnsEmptyConfiguration() - { - var args = Array.Empty(); - - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Null(cfg!.Host); - Assert.Equal(0, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void EqualsFormat_ParsesCorrectly() - { - var args = new[] { "--host=localhost", "--port=8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void SpaceFormat_ParsesCorrectly() - { - var args = new[] { "--host", "localhost", "--port", "8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void MixedFormats_ParsesCorrectly() - { - var args = new[] { "--host=localhost", "--port", "8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void BooleanFlags_ParseAsTrue() - { - var args = new[] { "--debug", "--verbose" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.True(cfg!.Debug); - Assert.True(cfg.Verbose); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void NestedConfiguration_WithColon_ParsesCorrectly() - { - var args = new[] { "--database:host=localhost", "--database:port=5432", "--database:name=mydb" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.NotNull(cfg!.Database); - Assert.Equal("localhost", cfg.Database!.Host); - Assert.Equal(5432, cfg.Database.Port); - Assert.Equal("mydb", cfg.Database.Name); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void NestedConfiguration_WithDoubleUnderscore_ParsesCorrectly() - { - var args = new[] { "--database__host=localhost", "--database__port=5432" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.NotNull(cfg!.Database); - Assert.Equal("localhost", cfg.Database!.Host); - Assert.Equal(5432, cfg.Database.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void SingleDashPrefix_ParsesCorrectly() - { - var args = new[] { "-host=localhost", "-port=8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions - { - Args = args, - SwitchPrefixes = ["-"] - })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void NonSwitchArguments_AreIgnored() - { - var args = new[] { "somecommand", "--host=localhost", "anotherarg", "--port=8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void CaseInsensitive_ParsesCorrectly() - { - var args = new[] { "--HOST=localhost", "--Port=8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void FluentAPI_WithArgs_Works() - { - var args = new[] { "--host=localhost", "--port=8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void EmptyValueAfterEquals_ParsesAsEmptyString() - { - var args = new[] { "--host=", "--port=8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void ValueWithSpaces_InEqualsFormat_ParsesCorrectly() - { - var args = new[] { "--host=my server name", "--port=8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("my server name", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void LastValueWins_WhenDuplicateKeys() - { - var args = new[] { "--host=server1", "--host=server2", "--port=8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("server2", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void RealWorldScenario_DockerStyle() - { - // Simulating: docker run myapp --host=localhost --port=8080 --debug - var args = new[] { "--host=localhost", "--port=8080", "--debug" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - Assert.True(cfg.Debug); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void Prefix_FiltersAndStripsPrefix() - { - var args = new[] { "--app_host=localhost", "--app_port=8080", "--db_host=dbserver" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args, Prefix = "app_" })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void Prefix_WithMultipleTypes_MapsCorrectly() - { - var args = new[] { "--app_host=localhost", "--app_port=8080", "--db_host=dbserver", "--db_port=5432" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ - rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args, Prefix = "app_" }), - rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args, Prefix = "db_" }) - ])); - - var appCfg = manager.GetConfig(); - Assert.NotNull(appCfg); - Assert.Equal("localhost", appCfg!.Host); - Assert.Equal(8080, appCfg.Port); - - var dbCfg = manager.GetConfig(); - Assert.NotNull(dbCfg); - Assert.Equal("dbserver", dbCfg!.Host); - Assert.Equal(5432, dbCfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void Prefix_WithNestedKeys_WorksCorrectly() - { - var args = new[] { "--app_database:host=localhost", "--app_database:port=5432" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args, Prefix = "app_" })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.NotNull(cfg!.Database); - Assert.Equal("localhost", cfg.Database!.Host); - Assert.Equal(5432, cfg.Database.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void Prefix_CaseInsensitive() - { - var args = new[] { "--APP_host=localhost", "--app_port=8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args, Prefix = "app_" })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void NoPrefix_ReturnsAllArguments() - { - var args = new[] { "--host=localhost", "--port=8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void Prefix_NoMatchingArgs_ReturnsEmptyConfig() - { - var args = new[] { "--other_host=localhost", "--other_port=8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args, Prefix = "app_" })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Null(cfg!.Host); - Assert.Equal(0, cfg.Port); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void MultipleSwitchPrefixes_ParsesCorrectly() - { - var args = new[] { "--host=localhost", "-port=8080", "/debug=true" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions - { - Args = args, - SwitchPrefixes = ["--", "-", "/"] - })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - Assert.True(cfg.Debug); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void MultipleSwitchPrefixes_WithSpaceFormat_ParsesCorrectly() - { - var args = new[] { "--host", "localhost", "-port", "8080", "/debug" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions - { - Args = args, - SwitchPrefixes = ["--", "-", "/"] - })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); - Assert.Equal(8080, cfg.Port); - Assert.True(cfg.Debug); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "CommandLineArgumentProvider")] - public void MultipleSwitchPrefixes_LongestMatchFirst_ParsesCorrectly() - { - // Ensure "--" matches before "-" even if array order is reversed - var args = new[] { "--host=localhost", "-port=8080" }; - using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions - { - Args = args, - SwitchPrefixes = ["-", "--"] // Note: shorter prefix first in array - })])); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("localhost", cfg!.Host); // Should match "--", not "-" (which would give "-host") - Assert.Equal(8080, cfg.Port); - } -} - - - - - - - +using Xunit; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Rules; + +namespace Cocoar.Configuration.Providers.Tests.CommandLine; + +public class CommandLineArgumentProviderUnitTests +{ + private static readonly RulesBuilder Builder = new(); + + private sealed class SimpleConfig { public string? Host { get; set; } public int Port { get; set; } } + private sealed class DatabaseConfig { public string? Host { get; set; } public int Port { get; set; } public string? Name { get; set; } } + private sealed class NestedConfig { public DatabaseConfig? Database { get; set; } } + private sealed class FlagsConfig { public bool Debug { get; set; } public bool Verbose { get; set; } } + private sealed class MixedConfig { public string? Host { get; set; } public int Port { get; set; } public bool Debug { get; set; } } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void EmptyArgs_ReturnsEmptyConfiguration() + { + var args = Array.Empty(); + + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Null(cfg!.Host); + Assert.Equal(0, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void EqualsFormat_ParsesCorrectly() + { + var args = new[] { "--host=localhost", "--port=8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void SpaceFormat_ParsesCorrectly() + { + var args = new[] { "--host", "localhost", "--port", "8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void MixedFormats_ParsesCorrectly() + { + var args = new[] { "--host=localhost", "--port", "8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void BooleanFlags_ParseAsTrue() + { + var args = new[] { "--debug", "--verbose" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.True(cfg!.Debug); + Assert.True(cfg.Verbose); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void NestedConfiguration_WithColon_ParsesCorrectly() + { + var args = new[] { "--database:host=localhost", "--database:port=5432", "--database:name=mydb" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.NotNull(cfg!.Database); + Assert.Equal("localhost", cfg.Database!.Host); + Assert.Equal(5432, cfg.Database.Port); + Assert.Equal("mydb", cfg.Database.Name); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void NestedConfiguration_WithDoubleUnderscore_ParsesCorrectly() + { + var args = new[] { "--database__host=localhost", "--database__port=5432" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.NotNull(cfg!.Database); + Assert.Equal("localhost", cfg.Database!.Host); + Assert.Equal(5432, cfg.Database.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void SingleDashPrefix_ParsesCorrectly() + { + var args = new[] { "-host=localhost", "-port=8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions + { + Args = args, + SwitchPrefixes = ["-"] + })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void NonSwitchArguments_AreIgnored() + { + var args = new[] { "somecommand", "--host=localhost", "anotherarg", "--port=8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void CaseInsensitive_ParsesCorrectly() + { + var args = new[] { "--HOST=localhost", "--Port=8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void FluentAPI_WithArgs_Works() + { + var args = new[] { "--host=localhost", "--port=8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void EmptyValueAfterEquals_ParsesAsEmptyString() + { + var args = new[] { "--host=", "--port=8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void ValueWithSpaces_InEqualsFormat_ParsesCorrectly() + { + var args = new[] { "--host=my server name", "--port=8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("my server name", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void LastValueWins_WhenDuplicateKeys() + { + var args = new[] { "--host=server1", "--host=server2", "--port=8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("server2", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void RealWorldScenario_DockerStyle() + { + // Simulating: docker run myapp --host=localhost --port=8080 --debug + var args = new[] { "--host=localhost", "--port=8080", "--debug" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + Assert.True(cfg.Debug); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void Prefix_FiltersAndStripsPrefix() + { + var args = new[] { "--app_host=localhost", "--app_port=8080", "--db_host=dbserver" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args, Prefix = "app_" })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void Prefix_WithMultipleTypes_MapsCorrectly() + { + var args = new[] { "--app_host=localhost", "--app_port=8080", "--db_host=dbserver", "--db_port=5432" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [ + rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args, Prefix = "app_" }), + rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args, Prefix = "db_" }) + ])); + + var appCfg = manager.GetConfig(); + Assert.NotNull(appCfg); + Assert.Equal("localhost", appCfg!.Host); + Assert.Equal(8080, appCfg.Port); + + var dbCfg = manager.GetConfig(); + Assert.NotNull(dbCfg); + Assert.Equal("dbserver", dbCfg!.Host); + Assert.Equal(5432, dbCfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void Prefix_WithNestedKeys_WorksCorrectly() + { + var args = new[] { "--app_database:host=localhost", "--app_database:port=5432" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args, Prefix = "app_" })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.NotNull(cfg!.Database); + Assert.Equal("localhost", cfg.Database!.Host); + Assert.Equal(5432, cfg.Database.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void Prefix_CaseInsensitive() + { + var args = new[] { "--APP_host=localhost", "--app_port=8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args, Prefix = "app_" })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void NoPrefix_ReturnsAllArguments() + { + var args = new[] { "--host=localhost", "--port=8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void Prefix_NoMatchingArgs_ReturnsEmptyConfig() + { + var args = new[] { "--other_host=localhost", "--other_port=8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions { Args = args, Prefix = "app_" })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Null(cfg!.Host); + Assert.Equal(0, cfg.Port); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void MultipleSwitchPrefixes_ParsesCorrectly() + { + var args = new[] { "--host=localhost", "-port=8080", "/debug=true" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions + { + Args = args, + SwitchPrefixes = ["--", "-", "/"] + })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + Assert.True(cfg.Debug); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void MultipleSwitchPrefixes_WithSpaceFormat_ParsesCorrectly() + { + var args = new[] { "--host", "localhost", "-port", "8080", "/debug" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions + { + Args = args, + SwitchPrefixes = ["--", "-", "/"] + })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); + Assert.Equal(8080, cfg.Port); + Assert.True(cfg.Debug); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "CommandLineArgumentProvider")] + public void MultipleSwitchPrefixes_LongestMatchFirst_ParsesCorrectly() + { + // Ensure "--" matches before "-" even if array order is reversed + var args = new[] { "--host=localhost", "-port=8080" }; + using var manager = ConfigManager.Create(c => c.UseConfiguration(rule => [rule.For().FromCommandLine(cm => new CommandLineRuleOptions + { + Args = args, + SwitchPrefixes = ["-", "--"] // Note: shorter prefix first in array + })])); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("localhost", cfg!.Host); // Should match "--", not "-" (which would give "-host") + Assert.Equal(8080, cfg.Port); + } +} + + + + + + + diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/CommandLine/CommandLineParserDebugTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/CommandLine/CommandLineParserDebugTests.cs index 085556e..c396eba 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/CommandLine/CommandLineParserDebugTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/CommandLine/CommandLineParserDebugTests.cs @@ -1,23 +1,23 @@ -using System.Text.Json; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Providers.Tests.Helpers; -using Xunit; - -namespace Cocoar.Configuration.Providers.Tests.CommandLine; - -public class CommandLineParserDebugTests -{ - [Fact] - public async Task DirectProviderCall_ParsesCorrectly() - { - var args = new[] { "--host=localhost", "--port=8080" }; - var options = new CommandLineProviderOptions(); - var provider = new CommandLineArgumentProvider(options); - var queryOptions = new CommandLineProviderQueryOptions(args, null, null); - - var result = await provider.FetchConfigurationBytesAsync(queryOptions); - - Assert.True(result.ToJsonElement().TryGetProperty("host", out var hostProp) || result.ToJsonElement().TryGetProperty("Host", out hostProp)); - Assert.Equal("localhost", hostProp.GetString()); - } -} +using System.Text.Json; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Tests.Helpers; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.CommandLine; + +public class CommandLineParserDebugTests +{ + [Fact] + public async Task DirectProviderCall_ParsesCorrectly() + { + var args = new[] { "--host=localhost", "--port=8080" }; + var options = new CommandLineProviderOptions(); + var provider = new CommandLineArgumentProvider(options); + var queryOptions = new CommandLineProviderQueryOptions(args, null, null); + + var result = await provider.FetchConfigurationBytesAsync(queryOptions); + + Assert.True(result.ToJsonElement().TryGetProperty("host", out var hostProp) || result.ToJsonElement().TryGetProperty("Host", out hostProp)); + Assert.Equal("localhost", hostProp.GetString()); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/Environment/EnvironmentProviderUnitTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/Environment/EnvironmentProviderUnitTests.cs index 93d9ac2..7634b5b 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/Environment/EnvironmentProviderUnitTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/Environment/EnvironmentProviderUnitTests.cs @@ -1,198 +1,198 @@ -using Xunit; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers.Tests.TestUtilities; - -namespace Cocoar.Configuration.Providers.Tests.Environment; - -public class EnvironmentProviderUnitTests -{ - private sealed class SimpleValueConfig { public int Value { get; set; } } - private sealed class AppSettings { public LoggingSettings Logging { get; set; } = new(); public string? Feature_Flag { get; set; } } - private sealed class LoggingSettings { public string? Level { get; set; } } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "EnvironmentVariableProvider")] - public void MissingExpectedVariable_DoesNotDegradeRule() - { - var prefix = "COCOAR_TEST_APP_" + Guid.NewGuid().ToString("N") + "_"; // unique prefix with no variables - var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - Assert.Equal(Health.HealthStatus.Healthy, manager.HealthStatus); // Fetch succeeded with empty object - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "EnvironmentVariableProvider")] - public void RequiredVariablePresent_BindsScalar_Healthy() - { - var prefix = "MYAPP_"; - var variableName = prefix + "Value"; - using var scope = EnvScope.Set(variableName, "200"); - - var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal(200, cfg!.Value); - Assert.Equal(Health.HealthStatus.Healthy, manager.HealthStatus); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "EnvironmentVariableProvider")] - public void NestedMapping_DoubleUnderscore_SplitsIntoHierarchy() - { - var prefix = "APP"; // no trailing underscore to exercise trimming logic - using var s1 = EnvScope.Set(prefix + "__Logging__Level", "Debug"); - - var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("Debug", cfg!.Logging.Level); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "EnvironmentVariableProvider")] - public void SingleUnderscore_IsLiteral_NoHierarchySplit() - { - var prefix = "APP"; - using var s1 = EnvScope.Set(prefix + "_Feature_Flag", "enabled"); - - var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("enabled", cfg!.Feature_Flag); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "EnvironmentVariableProvider")] - public void ColonSeparator_AlsoCreatesHierarchy() - { - var prefix = "APP_"; - using var s1 = EnvScope.Set(prefix + "Logging:Level", "Info"); - - var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("Info", cfg!.Logging.Level); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "EnvironmentVariableProvider")] - public void TripleUnderscore_StillSeparates() - { - var prefix = "APP_"; - using var s1 = EnvScope.Set(prefix + "Logging___Level", "Warn"); - - var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var cfg = manager.GetConfig(); - Assert.NotNull(cfg); - Assert.Equal("Warn", cfg!.Logging.Level); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "EnvironmentVariableProvider")] - public void EmptyPrefix_LoadsAllVariables() - { - var testVar = "COCOAR_UNIT_TEST_" + Guid.NewGuid().ToString("N"); - using var s1 = EnvScope.Set(testVar, "global-value"); - - var rule = EnvironmentVariableProvider.CreateRule>(null, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var cfg = manager.GetConfig>(); - Assert.NotNull(cfg); - Assert.True(cfg!.ContainsKey(testVar)); // Should include our test variable - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "EnvironmentVariableProvider")] - public void NonDelimiterPrefixCharacter_Works() - { - var prefix = "Marten@"; - var key = prefix + "ConnectionString"; - using var scope = EnvScope.Set(key, "CS"); - - var rule = EnvironmentVariableProvider.CreateRule>(prefix, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var cfg = manager.GetConfig>(); - Assert.NotNull(cfg); - Assert.True(cfg!.TryGetValue("ConnectionString", out var cs)); - Assert.Equal("CS", cs.ToString()); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "EnvironmentVariableProvider")] - public void SingleLeadingUnderscore_IsTrimmed() - { - var prefix = "MYAPP"; - var key = prefix + "_FOO"; // will become FOO after trimming the single leading separator - using var scope = EnvScope.Set(key, "x"); - - var rule = EnvironmentVariableProvider.CreateRule>(prefix, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var cfg = manager.GetConfig>(); - Assert.NotNull(cfg); - Assert.True(cfg!.TryGetValue("FOO", out var val)); - Assert.Equal("x", val.ToString()); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "EnvironmentVariableProvider")] - public void ComplexDelimiterCombinations_AllWork() - { - var prefix = "MYAPP"; - // Literal single underscore remains in key part - using var s1 = EnvScope.Set("MYAPP_FOO_BAR", "x"); - // Double underscore => nesting - using var s2 = EnvScope.Set("MYAPP__Logging__Level", "Debug"); - // Colon separator => nesting - using var s3 = EnvScope.Set("MYAPP:Data:ConnectionString", "cs"); - - var rule = EnvironmentVariableProvider.CreateRule>(prefix, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var cfg = manager.GetConfig>(); - Assert.NotNull(cfg); - - // Single underscore remains literal - Assert.True(cfg!.TryGetValue("FOO_BAR", out var fooBar)); - Assert.Equal("x", fooBar.ToString()); - - // Double underscore creates nesting - JsonElement handling - Assert.True(cfg.TryGetValue("Logging", out var logging)); - if (logging is System.Text.Json.JsonElement loggingJson) - { - Assert.True(loggingJson.TryGetProperty("Level", out var lvl)); - Assert.Equal("Debug", lvl.GetString()); - } - - // Colon creates nesting - JsonElement handling - Assert.True(cfg.TryGetValue("Data", out var data)); - if (data is System.Text.Json.JsonElement dataJson) - { - Assert.True(dataJson.TryGetProperty("ConnectionString", out var cs)); - Assert.Equal("cs", cs.GetString()); - } - } -} +using Xunit; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers.Tests.TestUtilities; + +namespace Cocoar.Configuration.Providers.Tests.Environment; + +public class EnvironmentProviderUnitTests +{ + private sealed class SimpleValueConfig { public int Value { get; set; } } + private sealed class AppSettings { public LoggingSettings Logging { get; set; } = new(); public string? Feature_Flag { get; set; } } + private sealed class LoggingSettings { public string? Level { get; set; } } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void MissingExpectedVariable_DoesNotDegradeRule() + { + var prefix = "COCOAR_TEST_APP_" + Guid.NewGuid().ToString("N") + "_"; // unique prefix with no variables + var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + Assert.Equal(Health.HealthStatus.Healthy, manager.HealthStatus); // Fetch succeeded with empty object + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void RequiredVariablePresent_BindsScalar_Healthy() + { + var prefix = "MYAPP_"; + var variableName = prefix + "Value"; + using var scope = EnvScope.Set(variableName, "200"); + + var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal(200, cfg!.Value); + Assert.Equal(Health.HealthStatus.Healthy, manager.HealthStatus); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void NestedMapping_DoubleUnderscore_SplitsIntoHierarchy() + { + var prefix = "APP"; // no trailing underscore to exercise trimming logic + using var s1 = EnvScope.Set(prefix + "__Logging__Level", "Debug"); + + var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("Debug", cfg!.Logging.Level); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void SingleUnderscore_IsLiteral_NoHierarchySplit() + { + var prefix = "APP"; + using var s1 = EnvScope.Set(prefix + "_Feature_Flag", "enabled"); + + var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("enabled", cfg!.Feature_Flag); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void ColonSeparator_AlsoCreatesHierarchy() + { + var prefix = "APP_"; + using var s1 = EnvScope.Set(prefix + "Logging:Level", "Info"); + + var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("Info", cfg!.Logging.Level); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void TripleUnderscore_StillSeparates() + { + var prefix = "APP_"; + using var s1 = EnvScope.Set(prefix + "Logging___Level", "Warn"); + + var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var cfg = manager.GetConfig(); + Assert.NotNull(cfg); + Assert.Equal("Warn", cfg!.Logging.Level); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void EmptyPrefix_LoadsAllVariables() + { + var testVar = "COCOAR_UNIT_TEST_" + Guid.NewGuid().ToString("N"); + using var s1 = EnvScope.Set(testVar, "global-value"); + + var rule = EnvironmentVariableProvider.CreateRule>(null, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var cfg = manager.GetConfig>(); + Assert.NotNull(cfg); + Assert.True(cfg!.ContainsKey(testVar)); // Should include our test variable + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void NonDelimiterPrefixCharacter_Works() + { + var prefix = "Marten@"; + var key = prefix + "ConnectionString"; + using var scope = EnvScope.Set(key, "CS"); + + var rule = EnvironmentVariableProvider.CreateRule>(prefix, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var cfg = manager.GetConfig>(); + Assert.NotNull(cfg); + Assert.True(cfg!.TryGetValue("ConnectionString", out var cs)); + Assert.Equal("CS", cs.ToString()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void SingleLeadingUnderscore_IsTrimmed() + { + var prefix = "MYAPP"; + var key = prefix + "_FOO"; // will become FOO after trimming the single leading separator + using var scope = EnvScope.Set(key, "x"); + + var rule = EnvironmentVariableProvider.CreateRule>(prefix, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var cfg = manager.GetConfig>(); + Assert.NotNull(cfg); + Assert.True(cfg!.TryGetValue("FOO", out var val)); + Assert.Equal("x", val.ToString()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void ComplexDelimiterCombinations_AllWork() + { + var prefix = "MYAPP"; + // Literal single underscore remains in key part + using var s1 = EnvScope.Set("MYAPP_FOO_BAR", "x"); + // Double underscore => nesting + using var s2 = EnvScope.Set("MYAPP__Logging__Level", "Debug"); + // Colon separator => nesting + using var s3 = EnvScope.Set("MYAPP:Data:ConnectionString", "cs"); + + var rule = EnvironmentVariableProvider.CreateRule>(prefix, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var cfg = manager.GetConfig>(); + Assert.NotNull(cfg); + + // Single underscore remains literal + Assert.True(cfg!.TryGetValue("FOO_BAR", out var fooBar)); + Assert.Equal("x", fooBar.ToString()); + + // Double underscore creates nesting - JsonElement handling + Assert.True(cfg.TryGetValue("Logging", out var logging)); + if (logging is System.Text.Json.JsonElement loggingJson) + { + Assert.True(loggingJson.TryGetProperty("Level", out var lvl)); + Assert.Equal("Debug", lvl.GetString()); + } + + // Colon creates nesting - JsonElement handling + Assert.True(cfg.TryGetValue("Data", out var data)); + if (data is System.Text.Json.JsonElement dataJson) + { + Assert.True(dataJson.TryGetProperty("ConnectionString", out var cs)); + Assert.Equal("cs", cs.GetString()); + } + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/File/ConfigurablePollingIntervalTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/File/ConfigurablePollingIntervalTests.cs index 0eccdcf..3e3143f 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/File/ConfigurablePollingIntervalTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/File/ConfigurablePollingIntervalTests.cs @@ -1,198 +1,198 @@ -using Cocoar.Configuration.Providers.Tests.Helpers; -using Cocoar.Configuration.Providers.Tests.TestUtilities; -using Xunit; -using Xunit.Abstractions; - -namespace Cocoar.Configuration.Providers.Tests.File; - -/// -/// Tests for configurable polling interval functionality in FileSourceProvider. -/// Validates that different polling intervals work correctly for testing scenarios. -/// -public class ConfigurablePollingIntervalTests -{ - private readonly ITestOutputHelper _output; - - public ConfigurablePollingIntervalTests(ITestOutputHelper output) - { - _output = output; - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - [Trait("Category", "PollingConfiguration")] - public void FileSourceProviderOptions_CustomPollingInterval_ConfiguresCorrectly() - { - - var testInterval = TimeSpan.FromMilliseconds(500); - - - var options = new FileSourceProviderOptions( - directory: "test-dir", - pollingInterval: testInterval - ); - - - Assert.Equal(testInterval, options.PollingInterval); - _output.WriteLine($"Configured polling interval: {options.PollingInterval.TotalMilliseconds}ms"); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - [Trait("Category", "PollingConfiguration")] - public void FileSourceProviderOptions_DefaultPollingInterval_IsTenSeconds() - { - - var options = new FileSourceProviderOptions(directory: "test-dir"); - - - Assert.Equal(TimeSpan.FromSeconds(10), options.PollingInterval); - _output.WriteLine($"Default polling interval: {options.PollingInterval.TotalSeconds} seconds"); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - [Trait("Category", "PollingConfiguration")] - public void FileSourceProviderOptions_DifferentPollingIntervals_GenerateDifferentProviderKeys() - { - - var options1 = new FileSourceProviderOptions( - directory: "test-dir", - pollingInterval: TimeSpan.FromSeconds(1) - ); - var options2 = new FileSourceProviderOptions( - directory: "test-dir", - pollingInterval: TimeSpan.FromSeconds(5) - ); - var options3 = new FileSourceProviderOptions( - directory: "test-dir", - pollingInterval: TimeSpan.FromSeconds(1) // Same as options1 - ); - - - var key1 = options1.GenerateProviderKey(); - var key2 = options2.GenerateProviderKey(); - var key3 = options3.GenerateProviderKey(); - - - Assert.NotEqual(key1, key2); // Different intervals should create different keys - Assert.Equal(key1, key3); // Same intervals should create same keys - - _output.WriteLine($"Provider key 1 (1s): {key1}"); - _output.WriteLine($"Provider key 2 (5s): {key2}"); - _output.WriteLine($"Provider key 3 (1s): {key3}"); - - // Keys should include both directory and polling interval - Assert.Contains("test-dir", key1); - Assert.Contains("test-dir", key2); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - [Trait("Category", "PollingConfiguration")] - public void FileSourceRuleOptions_CustomPollingInterval_PassedToProviderOptions() - { - - var testInterval = TimeSpan.FromMilliseconds(200); - - - var ruleOptions = new FileSourceRuleOptions( - directory: "configs", - filename: "app.json", - debounceTime: TimeSpan.FromMilliseconds(100), - pollingInterval: testInterval - ); - - var providerOptions = ruleOptions.ToProviderOptions(); - - - Assert.Equal(testInterval, providerOptions.PollingInterval); - _output.WriteLine($"Rule options polling interval: {ruleOptions.PollingInterval?.TotalMilliseconds}ms"); - _output.WriteLine($"Provider options polling interval: {providerOptions.PollingInterval.TotalMilliseconds}ms"); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - [Trait("Category", "PollingConfiguration")] - public void FileSourceRuleOptions_FromFilePath_CustomPollingInterval_ConfiguresCorrectly() - { - - var testInterval = TimeSpan.FromMilliseconds(750); - - - var ruleOptions = FileSourceRuleOptions.FromFilePath( - filePath: "configs/app.json", - debounceTime: TimeSpan.FromMilliseconds(100), - pollingInterval: testInterval - ); - - var providerOptions = ruleOptions.ToProviderOptions(); - - - Assert.Equal("configs", ruleOptions.Directory); - Assert.Equal("app.json", ruleOptions.Filename); - Assert.Equal(testInterval, ruleOptions.PollingInterval); - Assert.Equal(testInterval, providerOptions.PollingInterval); - - _output.WriteLine($"File path: configs/app.json"); - _output.WriteLine($"Split directory: {ruleOptions.Directory}"); - _output.WriteLine($"Split filename: {ruleOptions.Filename}"); - _output.WriteLine($"Configured polling interval: {providerOptions.PollingInterval.TotalMilliseconds}ms"); - } - - [Fact] - [Trait("Type", "Integration")] - [Trait("Provider", "FileSourceProvider")] - [Trait("Category", "PollingConfiguration")] - public async Task FastPollingInterval_EnablesQuickTesting() - { - - using var tempDir = TempDirectoryHelper.Create(); - var configFile = Path.Combine(tempDir.Path, "test.json"); - - // Use very fast polling for testing (100ms instead of 10 seconds) - var options = new FileSourceProviderOptions( - directory: tempDir.Path, - pollingInterval: TimeSpan.FromMilliseconds(100) - ); - var query = new FileSourceProviderQueryOptions("test.json"); - - using var provider = new FileSourceProvider(options); - - _output.WriteLine($"Testing with fast polling interval: {options.PollingInterval.TotalMilliseconds}ms"); - - // Initial state - file doesn't exist, should throw - var initialException = await Record.ExceptionAsync(() => provider.FetchConfigurationBytesAsync(query)); - Assert.NotNull(initialException); - _output.WriteLine("Initial fetch failed as expected (file doesn't exist)"); - - // Create file and test that it's detected quickly - var testContent = """{"test": "value", "timestamp": "initial"}"""; - System.IO.File.WriteAllText(configFile, testContent); - _output.WriteLine("Created config file"); - - // Actively fetch until file appears (more deterministic than relying on change observable) - byte[]? resultBytes = null; - await ActiveWaitHelpers.WaitUntilAsync( - () => { - try { - resultBytes = provider.FetchConfigurationBytesAsync(query).GetAwaiter().GetResult(); - var el = resultBytes.ToJsonElement(); - return el.TryGetProperty("test", out var tp) && tp.GetString() == "value"; - } catch { return false; } - }, - timeout: TimeSpan.FromSeconds(5), - description: "file detection via fetch"); - var result = resultBytes!; - Assert.True(result.ToJsonElement().TryGetProperty("test", out var testProp)); - Assert.Equal("value", testProp.GetString()); - - _output.WriteLine($"Successfully fetched config: {result}"); - _output.WriteLine("✅ Fast polling interval enables quick testing!"); - } -} +using Cocoar.Configuration.Providers.Tests.Helpers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Xunit; +using Xunit.Abstractions; + +namespace Cocoar.Configuration.Providers.Tests.File; + +/// +/// Tests for configurable polling interval functionality in FileSourceProvider. +/// Validates that different polling intervals work correctly for testing scenarios. +/// +public class ConfigurablePollingIntervalTests +{ + private readonly ITestOutputHelper _output; + + public ConfigurablePollingIntervalTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + [Trait("Category", "PollingConfiguration")] + public void FileSourceProviderOptions_CustomPollingInterval_ConfiguresCorrectly() + { + + var testInterval = TimeSpan.FromMilliseconds(500); + + + var options = new FileSourceProviderOptions( + directory: "test-dir", + pollingInterval: testInterval + ); + + + Assert.Equal(testInterval, options.PollingInterval); + _output.WriteLine($"Configured polling interval: {options.PollingInterval.TotalMilliseconds}ms"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + [Trait("Category", "PollingConfiguration")] + public void FileSourceProviderOptions_DefaultPollingInterval_IsTenSeconds() + { + + var options = new FileSourceProviderOptions(directory: "test-dir"); + + + Assert.Equal(TimeSpan.FromSeconds(10), options.PollingInterval); + _output.WriteLine($"Default polling interval: {options.PollingInterval.TotalSeconds} seconds"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + [Trait("Category", "PollingConfiguration")] + public void FileSourceProviderOptions_DifferentPollingIntervals_GenerateDifferentProviderKeys() + { + + var options1 = new FileSourceProviderOptions( + directory: "test-dir", + pollingInterval: TimeSpan.FromSeconds(1) + ); + var options2 = new FileSourceProviderOptions( + directory: "test-dir", + pollingInterval: TimeSpan.FromSeconds(5) + ); + var options3 = new FileSourceProviderOptions( + directory: "test-dir", + pollingInterval: TimeSpan.FromSeconds(1) // Same as options1 + ); + + + var key1 = options1.GenerateProviderKey(); + var key2 = options2.GenerateProviderKey(); + var key3 = options3.GenerateProviderKey(); + + + Assert.NotEqual(key1, key2); // Different intervals should create different keys + Assert.Equal(key1, key3); // Same intervals should create same keys + + _output.WriteLine($"Provider key 1 (1s): {key1}"); + _output.WriteLine($"Provider key 2 (5s): {key2}"); + _output.WriteLine($"Provider key 3 (1s): {key3}"); + + // Keys should include both directory and polling interval + Assert.Contains("test-dir", key1); + Assert.Contains("test-dir", key2); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + [Trait("Category", "PollingConfiguration")] + public void FileSourceRuleOptions_CustomPollingInterval_PassedToProviderOptions() + { + + var testInterval = TimeSpan.FromMilliseconds(200); + + + var ruleOptions = new FileSourceRuleOptions( + directory: "configs", + filename: "app.json", + debounceTime: TimeSpan.FromMilliseconds(100), + pollingInterval: testInterval + ); + + var providerOptions = ruleOptions.ToProviderOptions(); + + + Assert.Equal(testInterval, providerOptions.PollingInterval); + _output.WriteLine($"Rule options polling interval: {ruleOptions.PollingInterval?.TotalMilliseconds}ms"); + _output.WriteLine($"Provider options polling interval: {providerOptions.PollingInterval.TotalMilliseconds}ms"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + [Trait("Category", "PollingConfiguration")] + public void FileSourceRuleOptions_FromFilePath_CustomPollingInterval_ConfiguresCorrectly() + { + + var testInterval = TimeSpan.FromMilliseconds(750); + + + var ruleOptions = FileSourceRuleOptions.FromFilePath( + filePath: "configs/app.json", + debounceTime: TimeSpan.FromMilliseconds(100), + pollingInterval: testInterval + ); + + var providerOptions = ruleOptions.ToProviderOptions(); + + + Assert.Equal("configs", ruleOptions.Directory); + Assert.Equal("app.json", ruleOptions.Filename); + Assert.Equal(testInterval, ruleOptions.PollingInterval); + Assert.Equal(testInterval, providerOptions.PollingInterval); + + _output.WriteLine($"File path: configs/app.json"); + _output.WriteLine($"Split directory: {ruleOptions.Directory}"); + _output.WriteLine($"Split filename: {ruleOptions.Filename}"); + _output.WriteLine($"Configured polling interval: {providerOptions.PollingInterval.TotalMilliseconds}ms"); + } + + [Fact] + [Trait("Type", "Integration")] + [Trait("Provider", "FileSourceProvider")] + [Trait("Category", "PollingConfiguration")] + public async Task FastPollingInterval_EnablesQuickTesting() + { + + using var tempDir = TempDirectoryHelper.Create(); + var configFile = Path.Combine(tempDir.Path, "test.json"); + + // Use very fast polling for testing (100ms instead of 10 seconds) + var options = new FileSourceProviderOptions( + directory: tempDir.Path, + pollingInterval: TimeSpan.FromMilliseconds(100) + ); + var query = new FileSourceProviderQueryOptions("test.json"); + + using var provider = new FileSourceProvider(options); + + _output.WriteLine($"Testing with fast polling interval: {options.PollingInterval.TotalMilliseconds}ms"); + + // Initial state - file doesn't exist, should throw + var initialException = await Record.ExceptionAsync(() => provider.FetchConfigurationBytesAsync(query)); + Assert.NotNull(initialException); + _output.WriteLine("Initial fetch failed as expected (file doesn't exist)"); + + // Create file and test that it's detected quickly + var testContent = """{"test": "value", "timestamp": "initial"}"""; + System.IO.File.WriteAllText(configFile, testContent); + _output.WriteLine("Created config file"); + + // Actively fetch until file appears (more deterministic than relying on change observable) + byte[]? resultBytes = null; + await ActiveWaitHelpers.WaitUntilAsync( + () => { + try { + resultBytes = provider.FetchConfigurationBytesAsync(query).GetAwaiter().GetResult(); + var el = resultBytes.ToJsonElement(); + return el.TryGetProperty("test", out var tp) && tp.GetString() == "value"; + } catch { return false; } + }, + timeout: TimeSpan.FromSeconds(5), + description: "file detection via fetch"); + var result = resultBytes!; + Assert.True(result.ToJsonElement().TryGetProperty("test", out var testProp)); + Assert.Equal("value", testProp.GetString()); + + _output.WriteLine($"Successfully fetched config: {result}"); + _output.WriteLine("✅ Fast polling interval enables quick testing!"); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderDirectoryTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderDirectoryTests.cs index 39e6c3d..7fd602f 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderDirectoryTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderDirectoryTests.cs @@ -1,162 +1,162 @@ -using System.Text.Json; -using Cocoar.Configuration.Providers.Tests.Helpers; -using Cocoar.Configuration.Providers.Tests.TestUtilities; -using Xunit; -using Xunit.Abstractions; - -namespace Cocoar.Configuration.Providers.Tests.File; - -/// -/// Tests for FileSourceProvider behavior with non-existent directory structures. -/// Validates how the provider handles scenarios where parent directories don't exist initially. -/// -public class FileProviderDirectoryTests -{ - private readonly ITestOutputHelper _output; - - public FileProviderDirectoryTests(ITestOutputHelper output) - { - _output = output; - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task NonExistentDirectory_ProviderCreation_ShouldHandleGracefully() - { - using var tempDir = TempDirectoryHelper.Create(); - var nonExistentPath = Path.Combine(tempDir.Path, "nested", "deep", "config"); - - // This should test what happens when we create a provider for a non-existent directory - var options = new FileSourceProviderOptions(nonExistentPath); - - Exception? constructorException = null; - FileSourceProvider? provider = null; - - try - { - provider = new(options); - _output.WriteLine("Provider created successfully for non-existent directory"); - } - catch (Exception ex) - { - constructorException = ex; - _output.WriteLine($"Provider creation failed: {ex.GetType().Name}: {ex.Message}"); - } - - // Let's see what happens... - if (constructorException != null) - { - _output.WriteLine("Provider creation failed as expected - FileSystemWatcher can't watch non-existent directory"); - Assert.IsType(constructorException); - } - else if (provider != null) - { - // If provider was created, let's test fetching from non-existent file - var query = new FileSourceProviderQueryOptions("config.json"); - - var fetchException = await Record.ExceptionAsync(async () => - { - await provider.FetchConfigurationBytesAsync(query); - }); - - _output.WriteLine($"Fetch attempt result: {(fetchException != null ? $"Failed with {fetchException.GetType().Name}" : "Succeeded")}"); - - // This should throw DirectoryNotFoundException since the directory doesn't exist - // (This is better behavior than the old FileNotFoundException) - Assert.IsType(fetchException); - } - } - - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "FileSourceProvider")] - public async Task DirectoryCreatedLater_WithFile_ShouldDetectChanges() - { - using var tempDir = TempDirectoryHelper.Create(); - var nestedPath = Path.Combine(tempDir.Path, "level1", "level2"); - var configFile = Path.Combine(nestedPath, "config.json"); - - // First, let's test if we can even create the provider when directory doesn't exist - FileSourceProvider? provider = null; - Exception? providerException = null; - - try - { - var options = new FileSourceProviderOptions(nestedPath); - provider = new(options); - } - catch (Exception ex) - { - providerException = ex; - _output.WriteLine($"Cannot create provider for non-existent directory: {ex.GetType().Name}"); - } - - if (providerException != null) - { - // This is the expected behavior - FileSystemWatcher can't watch non-existent directories - _output.WriteLine("Confirmed: FileProvider cannot watch non-existent directories"); - Assert.True(true, "FileSystemWatcher correctly rejects non-existent directories"); - return; - } - - // If we get here, the provider was created somehow - var query = new FileSourceProviderQueryOptions("config.json"); - var emissions = new List(); - var changeStreamException = await Record.ExceptionAsync(() => - { - var subscription = provider!.ChangesAsBytes(query).Subscribe( - onNext: e => emissions.Add(e.ToJsonElement()), - onError: ex => _output.WriteLine($"Change stream error: {ex}")); - return Task.Delay(100); - }); - - if (changeStreamException != null) - { - _output.WriteLine($"Change stream failed: {changeStreamException}"); - } - - // Now create the directory and file - Directory.CreateDirectory(nestedPath); - System.IO.File.WriteAllText(configFile, """{"created": "later", "value": 42}"""); - - // Wait for file system events to propagate - await ActiveWaitHelpers.WaitUntilAsync( - () => System.IO.File.Exists(configFile), - timeout: TimeSpan.FromSeconds(2), - description: "file creation detection"); - - _output.WriteLine($"After creating directory and file: {emissions.Count} emissions"); - - // If the provider was working, it should have detected the file creation - // But if the directory didn't exist initially, it likely won't work - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task ExistingDirectory_FileInSubdirectory_Works() - { - using var tempDir = TempDirectoryHelper.Create(); - - // Create a nested directory structure - var subDir = Path.Combine(tempDir.Path, "configs"); - Directory.CreateDirectory(subDir); - - var configFile = Path.Combine(subDir, "app.json"); - System.IO.File.WriteAllText(configFile, """{"app": "test", "env": "development"}"""); - - // Provider points to root, file is in subdirectory - var options = new FileSourceProviderOptions(tempDir.Path); - var provider = new FileSourceProvider(options); - - // Query should include the subdirectory path in filename - var query = new FileSourceProviderQueryOptions(Path.Combine("configs", "app.json")); - - var config = await provider.FetchConfigurationBytesAsync(query); - - Assert.Equal("test", config.ToJsonElement().GetProperty("app").GetString()); - Assert.Equal("development", config.ToJsonElement().GetProperty("env").GetString()); - _output.WriteLine("Successfully loaded file from subdirectory"); - } -} +using System.Text.Json; +using Cocoar.Configuration.Providers.Tests.Helpers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Xunit; +using Xunit.Abstractions; + +namespace Cocoar.Configuration.Providers.Tests.File; + +/// +/// Tests for FileSourceProvider behavior with non-existent directory structures. +/// Validates how the provider handles scenarios where parent directories don't exist initially. +/// +public class FileProviderDirectoryTests +{ + private readonly ITestOutputHelper _output; + + public FileProviderDirectoryTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task NonExistentDirectory_ProviderCreation_ShouldHandleGracefully() + { + using var tempDir = TempDirectoryHelper.Create(); + var nonExistentPath = Path.Combine(tempDir.Path, "nested", "deep", "config"); + + // This should test what happens when we create a provider for a non-existent directory + var options = new FileSourceProviderOptions(nonExistentPath); + + Exception? constructorException = null; + FileSourceProvider? provider = null; + + try + { + provider = new(options); + _output.WriteLine("Provider created successfully for non-existent directory"); + } + catch (Exception ex) + { + constructorException = ex; + _output.WriteLine($"Provider creation failed: {ex.GetType().Name}: {ex.Message}"); + } + + // Let's see what happens... + if (constructorException != null) + { + _output.WriteLine("Provider creation failed as expected - FileSystemWatcher can't watch non-existent directory"); + Assert.IsType(constructorException); + } + else if (provider != null) + { + // If provider was created, let's test fetching from non-existent file + var query = new FileSourceProviderQueryOptions("config.json"); + + var fetchException = await Record.ExceptionAsync(async () => + { + await provider.FetchConfigurationBytesAsync(query); + }); + + _output.WriteLine($"Fetch attempt result: {(fetchException != null ? $"Failed with {fetchException.GetType().Name}" : "Succeeded")}"); + + // This should throw DirectoryNotFoundException since the directory doesn't exist + // (This is better behavior than the old FileNotFoundException) + Assert.IsType(fetchException); + } + } + + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "FileSourceProvider")] + public async Task DirectoryCreatedLater_WithFile_ShouldDetectChanges() + { + using var tempDir = TempDirectoryHelper.Create(); + var nestedPath = Path.Combine(tempDir.Path, "level1", "level2"); + var configFile = Path.Combine(nestedPath, "config.json"); + + // First, let's test if we can even create the provider when directory doesn't exist + FileSourceProvider? provider = null; + Exception? providerException = null; + + try + { + var options = new FileSourceProviderOptions(nestedPath); + provider = new(options); + } + catch (Exception ex) + { + providerException = ex; + _output.WriteLine($"Cannot create provider for non-existent directory: {ex.GetType().Name}"); + } + + if (providerException != null) + { + // This is the expected behavior - FileSystemWatcher can't watch non-existent directories + _output.WriteLine("Confirmed: FileProvider cannot watch non-existent directories"); + Assert.True(true, "FileSystemWatcher correctly rejects non-existent directories"); + return; + } + + // If we get here, the provider was created somehow + var query = new FileSourceProviderQueryOptions("config.json"); + var emissions = new List(); + var changeStreamException = await Record.ExceptionAsync(() => + { + var subscription = provider!.ChangesAsBytes(query).Subscribe( + onNext: e => emissions.Add(e.ToJsonElement()), + onError: ex => _output.WriteLine($"Change stream error: {ex}")); + return Task.Delay(100); + }); + + if (changeStreamException != null) + { + _output.WriteLine($"Change stream failed: {changeStreamException}"); + } + + // Now create the directory and file + Directory.CreateDirectory(nestedPath); + System.IO.File.WriteAllText(configFile, """{"created": "later", "value": 42}"""); + + // Wait for file system events to propagate + await ActiveWaitHelpers.WaitUntilAsync( + () => System.IO.File.Exists(configFile), + timeout: TimeSpan.FromSeconds(2), + description: "file creation detection"); + + _output.WriteLine($"After creating directory and file: {emissions.Count} emissions"); + + // If the provider was working, it should have detected the file creation + // But if the directory didn't exist initially, it likely won't work + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task ExistingDirectory_FileInSubdirectory_Works() + { + using var tempDir = TempDirectoryHelper.Create(); + + // Create a nested directory structure + var subDir = Path.Combine(tempDir.Path, "configs"); + Directory.CreateDirectory(subDir); + + var configFile = Path.Combine(subDir, "app.json"); + System.IO.File.WriteAllText(configFile, """{"app": "test", "env": "development"}"""); + + // Provider points to root, file is in subdirectory + var options = new FileSourceProviderOptions(tempDir.Path); + var provider = new FileSourceProvider(options); + + // Query should include the subdirectory path in filename + var query = new FileSourceProviderQueryOptions(Path.Combine("configs", "app.json")); + + var config = await provider.FetchConfigurationBytesAsync(query); + + Assert.Equal("test", config.ToJsonElement().GetProperty("app").GetString()); + Assert.Equal("development", config.ToJsonElement().GetProperty("env").GetString()); + _output.WriteLine("Successfully loaded file from subdirectory"); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderEdgeCaseTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderEdgeCaseTests.cs index a25fcd8..bbf5050 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderEdgeCaseTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderEdgeCaseTests.cs @@ -1,281 +1,281 @@ -using System.Text; -using System.Text.Json; -using Cocoar.Configuration.Providers.Tests.Helpers; -using Cocoar.Configuration.Providers.Tests.TestUtilities; -using Xunit; -using Xunit.Abstractions; - -namespace Cocoar.Configuration.Providers.Tests.File; - -/// -/// Tests for FileSourceProvider edge cases that might occur in production environments. -/// Focuses on scenarios like file permissions, Unicode handling, large files, and external file conflicts. -/// -public class FileProviderEdgeCaseTests -{ - private readonly ITestOutputHelper _output; - - public FileProviderEdgeCaseTests(ITestOutputHelper output) - { - _output = output; - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task UnicodeFilename_NonAsciiCharacters_LoadsCorrectly() - { - using var tempDir = TempDirectoryHelper.Create(); - // Unicode filename with various characters - var unicodeFilename = "配置文件_测试_ñáéíóú.json"; - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, unicodeFilename, """{"unicode": "支持中文", "value": 42}"""); - - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions(unicodeFilename); - var provider = new FileSourceProvider(options); - - var config = await provider.FetchConfigurationBytesAsync(query); - - Assert.Equal("支持中文", config.ToJsonElement().GetProperty("unicode").GetString()); - Assert.Equal(42, config.ToJsonElement().GetProperty("value").GetInt32()); - _output.WriteLine($"Successfully loaded config from Unicode filename: {unicodeFilename}"); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task UnicodeContent_WithBOM_ParsesCorrectly() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "bom-test.json"); - - // Write JSON with UTF-8 BOM - var jsonContent = """{"message": "Ελληνικά 中文 русский العربية", "emoji": "🚀✨"}"""; - var utf8WithBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); - System.IO.File.WriteAllText(file.FilePath, jsonContent, utf8WithBom); - - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions("bom-test.json"); - var provider = new FileSourceProvider(options); - - var config = await provider.FetchConfigurationBytesAsync(query); - - Assert.Equal("Ελληνικά 中文 русский العربية", config.ToJsonElement().GetProperty("message").GetString()); - Assert.Equal("🚀✨", config.ToJsonElement().GetProperty("emoji").GetString()); - _output.WriteLine("Successfully parsed UTF-8 with BOM"); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task LargeJsonFile_MegabyteSize_LoadsEfficiently() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "large.json"); - - // Generate a large JSON object (~2MB) - var largeData = new Dictionary - { - ["metadata"] = new { size = "large", purpose = "stress-test" } - }; - - // Add many properties to make it large - for (var i = 0; i < 10000; i++) - { - largeData[$"property_{i:D5}"] = new - { - id = i, - name = $"Item {i}", - description = $"This is a description for item {i} which is part of a large configuration file used for testing the FileSourceProvider's ability to handle large JSON files efficiently.", - tags = new[] { $"tag1_{i}", $"tag2_{i}", $"category_{i % 100}" }, - nested = new - { - level1 = new { level2 = new { value = i * 2 } } - } - }; - } - - file.WriteJson(largeData); - - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions("large.json"); - var provider = new FileSourceProvider(options); - - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - var config = await provider.FetchConfigurationBytesAsync(query); - stopwatch.Stop(); - - // Verify content - Assert.Equal("large", config.ToJsonElement().GetProperty("metadata").GetProperty("size").GetString()); - Assert.Equal(2468, config.ToJsonElement().GetProperty("property_01234").GetProperty("nested").GetProperty("level1").GetProperty("level2").GetProperty("value").GetInt32()); - Assert.Equal(9999, config.ToJsonElement().GetProperty("property_09999").GetProperty("id").GetInt32()); - - _output.WriteLine($"Large file loaded in {stopwatch.ElapsedMilliseconds}ms"); - Assert.True(stopwatch.ElapsedMilliseconds < 1000, "Large file should load within 1 second"); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task ReadOnlyFile_CanReadButNotWrite_LoadsCorrectly() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "readonly.json", """{"readonly": true, "value": "immutable"}"""); - - // Set file to read-only - System.IO.File.SetAttributes(file.FilePath, FileAttributes.ReadOnly); - - try - { - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions("readonly.json"); - var provider = new FileSourceProvider(options); - - var config = await provider.FetchConfigurationBytesAsync(query); - - Assert.True(config.ToJsonElement().GetProperty("readonly").GetBoolean()); - Assert.Equal("immutable", config.ToJsonElement().GetProperty("value").GetString()); - _output.WriteLine("Successfully loaded read-only file"); - } - finally - { - // Remove read-only attribute for cleanup - if (System.IO.File.Exists(file.FilePath)) - { - System.IO.File.SetAttributes(file.FilePath, FileAttributes.Normal); - } - } - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task FileWithoutExtension_LoadsCorrectly() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "config", """{"extension": false, "type": "json"}"""); - - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions("config"); - var provider = new FileSourceProvider(options); - - var config = await provider.FetchConfigurationBytesAsync(query); - - Assert.False(config.ToJsonElement().GetProperty("extension").GetBoolean()); - Assert.Equal("json", config.ToJsonElement().GetProperty("type").GetString()); - _output.WriteLine("Successfully loaded file without extension"); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task EmptyJsonObject_LoadsAsEmptyObject() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "empty.json", "{}"); - - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions("empty.json"); - var provider = new FileSourceProvider(options); - - var config = await provider.FetchConfigurationBytesAsync(query); - - Assert.Equal(JsonValueKind.Object, config.ToJsonElement().ValueKind); - Assert.False(config.ToJsonElement().EnumerateObject().Any(), "Empty JSON should have no properties"); - _output.WriteLine("Successfully loaded empty JSON object"); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task JsonWithComments_StrictJsonParser_ReturnsEmptyObject() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "comments.json", """{"valid": true}"""); - - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions("comments.json"); - var provider = new FileSourceProvider(options); - - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - - try - { - // JSON with comments (not valid JSON, but common mistake) - var jsonWithComments = """ - { - // This is a comment - "value": 42, - /* Multi-line - comment */ - "name": "test" - } - """; - - // Write the invalid JSON to trigger file change - file.WriteContent(jsonWithComments); - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count > 0, - timeout: TimeSpan.FromSeconds(3), - description: "emission after invalid JSON write"); - - _output.WriteLine($"Received {emissions.Count} emissions for JSON with comments"); - - // Should receive one emission (empty object due to JSON parse error) - Assert.True(emissions.Count > 0, "Should receive at least one emission for file change"); - var emission = emissions[^1]; // Get last emission - Assert.Equal(JsonValueKind.Object, emission.ValueKind); - - // Should be empty object due to malformed JSON error handling - Assert.False(emission.EnumerateObject().Any(), "Comments in JSON should result in empty object"); - _output.WriteLine("JSON with comments handled gracefully (returned empty object)"); - } - finally - { - subscription.Dispose(); - } - } - - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "FileSourceProvider")] - public async Task DirectoryDeletion_WhileWatching_HandlesGracefully() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "dir-test.json", """{"before": "deletion"}"""); - - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions("dir-test.json"); - var provider = new FileSourceProvider(options); - - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe( - onNext: e => emissions.Add(e.ToJsonElement()), - onError: ex => _output.WriteLine($"Change stream error: {ex.GetType().Name}: {ex.Message}"), - onCompleted: () => _output.WriteLine("Change stream completed")); - - try - { - // Initial file change - file.WriteJson(new { before = "deletion", step = 1 }); - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count > 0, - timeout: TimeSpan.FromSeconds(3), - description: "initial file change emission"); - - _output.WriteLine($"Before directory deletion: {emissions.Count} emissions"); - - // Note: This test might not fully work because TempDirectoryHelper disposal might interfere - // But it validates that the provider doesn't crash on directory deletion - var initialEmissions = emissions.Count; - Assert.True(initialEmissions > 0, "Should have received initial emissions"); - - _output.WriteLine("Directory deletion scenario handled (no crashes)"); - } - finally - { - subscription.Dispose(); - } - } -} +using System.Text; +using System.Text.Json; +using Cocoar.Configuration.Providers.Tests.Helpers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Xunit; +using Xunit.Abstractions; + +namespace Cocoar.Configuration.Providers.Tests.File; + +/// +/// Tests for FileSourceProvider edge cases that might occur in production environments. +/// Focuses on scenarios like file permissions, Unicode handling, large files, and external file conflicts. +/// +public class FileProviderEdgeCaseTests +{ + private readonly ITestOutputHelper _output; + + public FileProviderEdgeCaseTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task UnicodeFilename_NonAsciiCharacters_LoadsCorrectly() + { + using var tempDir = TempDirectoryHelper.Create(); + // Unicode filename with various characters + var unicodeFilename = "配置文件_测试_ñáéíóú.json"; + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, unicodeFilename, """{"unicode": "支持中文", "value": 42}"""); + + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions(unicodeFilename); + var provider = new FileSourceProvider(options); + + var config = await provider.FetchConfigurationBytesAsync(query); + + Assert.Equal("支持中文", config.ToJsonElement().GetProperty("unicode").GetString()); + Assert.Equal(42, config.ToJsonElement().GetProperty("value").GetInt32()); + _output.WriteLine($"Successfully loaded config from Unicode filename: {unicodeFilename}"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task UnicodeContent_WithBOM_ParsesCorrectly() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "bom-test.json"); + + // Write JSON with UTF-8 BOM + var jsonContent = """{"message": "Ελληνικά 中文 русский العربية", "emoji": "🚀✨"}"""; + var utf8WithBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); + System.IO.File.WriteAllText(file.FilePath, jsonContent, utf8WithBom); + + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions("bom-test.json"); + var provider = new FileSourceProvider(options); + + var config = await provider.FetchConfigurationBytesAsync(query); + + Assert.Equal("Ελληνικά 中文 русский العربية", config.ToJsonElement().GetProperty("message").GetString()); + Assert.Equal("🚀✨", config.ToJsonElement().GetProperty("emoji").GetString()); + _output.WriteLine("Successfully parsed UTF-8 with BOM"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task LargeJsonFile_MegabyteSize_LoadsEfficiently() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "large.json"); + + // Generate a large JSON object (~2MB) + var largeData = new Dictionary + { + ["metadata"] = new { size = "large", purpose = "stress-test" } + }; + + // Add many properties to make it large + for (var i = 0; i < 10000; i++) + { + largeData[$"property_{i:D5}"] = new + { + id = i, + name = $"Item {i}", + description = $"This is a description for item {i} which is part of a large configuration file used for testing the FileSourceProvider's ability to handle large JSON files efficiently.", + tags = new[] { $"tag1_{i}", $"tag2_{i}", $"category_{i % 100}" }, + nested = new + { + level1 = new { level2 = new { value = i * 2 } } + } + }; + } + + file.WriteJson(largeData); + + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions("large.json"); + var provider = new FileSourceProvider(options); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var config = await provider.FetchConfigurationBytesAsync(query); + stopwatch.Stop(); + + // Verify content + Assert.Equal("large", config.ToJsonElement().GetProperty("metadata").GetProperty("size").GetString()); + Assert.Equal(2468, config.ToJsonElement().GetProperty("property_01234").GetProperty("nested").GetProperty("level1").GetProperty("level2").GetProperty("value").GetInt32()); + Assert.Equal(9999, config.ToJsonElement().GetProperty("property_09999").GetProperty("id").GetInt32()); + + _output.WriteLine($"Large file loaded in {stopwatch.ElapsedMilliseconds}ms"); + Assert.True(stopwatch.ElapsedMilliseconds < 1000, "Large file should load within 1 second"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task ReadOnlyFile_CanReadButNotWrite_LoadsCorrectly() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "readonly.json", """{"readonly": true, "value": "immutable"}"""); + + // Set file to read-only + System.IO.File.SetAttributes(file.FilePath, FileAttributes.ReadOnly); + + try + { + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions("readonly.json"); + var provider = new FileSourceProvider(options); + + var config = await provider.FetchConfigurationBytesAsync(query); + + Assert.True(config.ToJsonElement().GetProperty("readonly").GetBoolean()); + Assert.Equal("immutable", config.ToJsonElement().GetProperty("value").GetString()); + _output.WriteLine("Successfully loaded read-only file"); + } + finally + { + // Remove read-only attribute for cleanup + if (System.IO.File.Exists(file.FilePath)) + { + System.IO.File.SetAttributes(file.FilePath, FileAttributes.Normal); + } + } + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task FileWithoutExtension_LoadsCorrectly() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "config", """{"extension": false, "type": "json"}"""); + + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions("config"); + var provider = new FileSourceProvider(options); + + var config = await provider.FetchConfigurationBytesAsync(query); + + Assert.False(config.ToJsonElement().GetProperty("extension").GetBoolean()); + Assert.Equal("json", config.ToJsonElement().GetProperty("type").GetString()); + _output.WriteLine("Successfully loaded file without extension"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task EmptyJsonObject_LoadsAsEmptyObject() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "empty.json", "{}"); + + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions("empty.json"); + var provider = new FileSourceProvider(options); + + var config = await provider.FetchConfigurationBytesAsync(query); + + Assert.Equal(JsonValueKind.Object, config.ToJsonElement().ValueKind); + Assert.False(config.ToJsonElement().EnumerateObject().Any(), "Empty JSON should have no properties"); + _output.WriteLine("Successfully loaded empty JSON object"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task JsonWithComments_StrictJsonParser_ReturnsEmptyObject() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "comments.json", """{"valid": true}"""); + + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions("comments.json"); + var provider = new FileSourceProvider(options); + + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + + try + { + // JSON with comments (not valid JSON, but common mistake) + var jsonWithComments = """ + { + // This is a comment + "value": 42, + /* Multi-line + comment */ + "name": "test" + } + """; + + // Write the invalid JSON to trigger file change + file.WriteContent(jsonWithComments); + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count > 0, + timeout: TimeSpan.FromSeconds(3), + description: "emission after invalid JSON write"); + + _output.WriteLine($"Received {emissions.Count} emissions for JSON with comments"); + + // Should receive one emission (empty object due to JSON parse error) + Assert.True(emissions.Count > 0, "Should receive at least one emission for file change"); + var emission = emissions[^1]; // Get last emission + Assert.Equal(JsonValueKind.Object, emission.ValueKind); + + // Should be empty object due to malformed JSON error handling + Assert.False(emission.EnumerateObject().Any(), "Comments in JSON should result in empty object"); + _output.WriteLine("JSON with comments handled gracefully (returned empty object)"); + } + finally + { + subscription.Dispose(); + } + } + + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "FileSourceProvider")] + public async Task DirectoryDeletion_WhileWatching_HandlesGracefully() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "dir-test.json", """{"before": "deletion"}"""); + + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions("dir-test.json"); + var provider = new FileSourceProvider(options); + + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe( + onNext: e => emissions.Add(e.ToJsonElement()), + onError: ex => _output.WriteLine($"Change stream error: {ex.GetType().Name}: {ex.Message}"), + onCompleted: () => _output.WriteLine("Change stream completed")); + + try + { + // Initial file change + file.WriteJson(new { before = "deletion", step = 1 }); + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count > 0, + timeout: TimeSpan.FromSeconds(3), + description: "initial file change emission"); + + _output.WriteLine($"Before directory deletion: {emissions.Count} emissions"); + + // Note: This test might not fully work because TempDirectoryHelper disposal might interfere + // But it validates that the provider doesn't crash on directory deletion + var initialEmissions = emissions.Count; + Assert.True(initialEmissions > 0, "Should have received initial emissions"); + + _output.WriteLine("Directory deletion scenario handled (no crashes)"); + } + finally + { + subscription.Dispose(); + } + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderMultiQueryTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderMultiQueryTests.cs index 163997b..021c79d 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderMultiQueryTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderMultiQueryTests.cs @@ -1,221 +1,221 @@ -using System.Text.Json; -using Cocoar.Configuration.Providers.Tests.Helpers; -using Cocoar.Configuration.Providers.Tests.TestUtilities; -using Xunit; -using Xunit.Abstractions; - -namespace Cocoar.Configuration.Providers.Tests.File; - -/// -/// Tests for FileSourceProvider behavior with multiple queries (multiple files from same provider instance) -/// Validates that debouncing happens per-file, not per-directory, and provider sharing works correctly. -/// -public class FileProviderMultiQueryTests -{ - private readonly ITestOutputHelper _output; - - public FileProviderMultiQueryTests(ITestOutputHelper output) - { - _output = output; - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task SingleProvider_MultipleFiles_DebounceIndependently() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file1 = TempFileHelper.CreateInDirectory(tempDir.Path, "config1.json", """{"file": 1, "value": 0}"""); - using var file2 = TempFileHelper.CreateInDirectory(tempDir.Path, "config2.json", """{"file": 2, "value": 0}"""); - using var file3 = TempFileHelper.CreateInDirectory(tempDir.Path, "config3.json", """{"file": 3, "value": 0}"""); - - // Single provider instance for the directory - var options = new FileSourceProviderOptions(tempDir.Path); - var provider = new FileSourceProvider(options); - - // Three separate queries for different files - var query1 = new FileSourceProviderQueryOptions("config1.json", DebounceTime: TimeSpan.FromMilliseconds(50)); - var query2 = new FileSourceProviderQueryOptions("config2.json", DebounceTime: TimeSpan.FromMilliseconds(50)); - var query3 = new FileSourceProviderQueryOptions("config3.json", DebounceTime: TimeSpan.FromMilliseconds(50)); - - var emissions1 = new List(); - var emissions2 = new List(); - var emissions3 = new List(); - - var subscription1 = provider.ChangesAsBytes(query1).Subscribe(e => emissions1.Add(e.ToJsonElement())); - var subscription2 = provider.ChangesAsBytes(query2).Subscribe(e => emissions2.Add(e.ToJsonElement())); - var subscription3 = provider.ChangesAsBytes(query3).Subscribe(e => emissions3.Add(e.ToJsonElement())); - - try - { - // Rapid changes to all 3 files simultaneously - var changeCount = 10; - for (var i = 1; i <= changeCount; i++) - { - // Change all files at nearly the same time - file1.WriteJson(new { file = 1, value = i }); - file2.WriteJson(new { file = 2, value = i }); - file3.WriteJson(new { file = 3, value = i }); - await Task.Delay(10); // Rapid writes to test debouncing - } - - // Wait for all file changes to be detected and for final debounced values to arrive - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions1.Count > 0 && emissions2.Count > 0 && emissions3.Count > 0 && - emissions1[^1].GetProperty("value").GetInt32() == changeCount && - emissions2[^1].GetProperty("value").GetInt32() == changeCount && - emissions3[^1].GetProperty("value").GetInt32() == changeCount, - timeout: TimeSpan.FromSeconds(3), - description: "final debounced values for all files"); - - _output.WriteLine($"File 1: made {changeCount} changes, received {emissions1.Count} emissions"); - _output.WriteLine($"File 2: made {changeCount} changes, received {emissions2.Count} emissions"); - _output.WriteLine($"File 3: made {changeCount} changes, received {emissions3.Count} emissions"); - - // Each file should have independent debouncing - Assert.True(emissions1.Count < changeCount, $"File 1 should be debounced: expected < {changeCount}, got {emissions1.Count}"); - Assert.True(emissions2.Count < changeCount, $"File 2 should be debounced: expected < {changeCount}, got {emissions2.Count}"); - Assert.True(emissions3.Count < changeCount, $"File 3 should be debounced: expected < {changeCount}, got {emissions3.Count}"); - - // All files should have at least one emission - Assert.True(emissions1.Count > 0, "File 1 should have at least one emission"); - Assert.True(emissions2.Count > 0, "File 2 should have at least one emission"); - Assert.True(emissions3.Count > 0, "File 3 should have at least one emission"); - - // Final emissions should reflect the last change for each file - Assert.Equal(changeCount, emissions1[^1].GetProperty("value").GetInt32()); - Assert.Equal(changeCount, emissions2[^1].GetProperty("value").GetInt32()); - Assert.Equal(changeCount, emissions3[^1].GetProperty("value").GetInt32()); - - // Validate file identity is preserved - Assert.Equal(1, emissions1[^1].GetProperty("file").GetInt32()); - Assert.Equal(2, emissions2[^1].GetProperty("file").GetInt32()); - Assert.Equal(3, emissions3[^1].GetProperty("file").GetInt32()); - } - finally - { - subscription1.Dispose(); - subscription2.Dispose(); - subscription3.Dispose(); - } - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task SingleProvider_MultipleQueries_SameFile_ShareChangeStream() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "shared.json", """{"shared": true, "value": 0}"""); - - var options = new FileSourceProviderOptions(tempDir.Path); - var provider = new FileSourceProvider(options); - - // Two queries for the same file - should share the change stream - var query1 = new FileSourceProviderQueryOptions("shared.json"); - var query2 = new FileSourceProviderQueryOptions("shared.json"); - - var emissions1 = new List(); - var emissions2 = new List(); - - var subscription1 = provider.ChangesAsBytes(query1).Subscribe(e => emissions1.Add(e.ToJsonElement())); - var subscription2 = provider.ChangesAsBytes(query2).Subscribe(e => emissions2.Add(e.ToJsonElement())); - - try - { - // Make changes to the shared file - for (var i = 1; i <= 5; i++) - { - file.WriteJson(new { shared = true, value = i }); - await Task.Delay(50); // Spaced writes - } - - // Wait for both queries to have their final value - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions1.Count > 0 && emissions2.Count > 0 && - emissions1[^1].GetProperty("value").GetInt32() == 5 && - emissions2[^1].GetProperty("value").GetInt32() == 5, - timeout: TimeSpan.FromSeconds(2), - description: "shared file final value for both queries"); - - _output.WriteLine($"Query 1: {emissions1.Count} emissions"); - _output.WriteLine($"Query 2: {emissions2.Count} emissions"); - - // Both queries should receive the same number of emissions - Assert.Equal(emissions1.Count, emissions2.Count); - Assert.True(emissions1.Count > 0, "Should have received emissions"); - - // Both should have the same final value - var final1 = emissions1[^1].GetProperty("value").GetInt32(); - var final2 = emissions2[^1].GetProperty("value").GetInt32(); - Assert.Equal(final1, final2); - Assert.Equal(5, final1); - } - finally - { - subscription1.Dispose(); - subscription2.Dispose(); - } - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task SingleProvider_DifferentDebouncePerQuery_IndependentThrottling() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "throttle.json", """{"value": 0}"""); - - var options = new FileSourceProviderOptions(tempDir.Path); // No provider-level debouncing - var provider = new FileSourceProvider(options); - - // Same file, different per-query debounce settings - var queryFast = new FileSourceProviderQueryOptions("throttle.json", DebounceTime: TimeSpan.FromMilliseconds(20)); - var querySlow = new FileSourceProviderQueryOptions("throttle.json", DebounceTime: TimeSpan.FromMilliseconds(100)); - - var emissionsFast = new List(); - var emissionsSlow = new List(); - - var subscriptionFast = provider.ChangesAsBytes(queryFast).Subscribe(e => emissionsFast.Add(e.ToJsonElement())); - var subscriptionSlow = provider.ChangesAsBytes(querySlow).Subscribe(e => emissionsSlow.Add(e.ToJsonElement())); - - try - { - // Rapid changes - for (var i = 1; i <= 10; i++) - { - file.WriteJson(new { value = i }); - await Task.Delay(10); // Rapid writes to test differential debouncing - } - - // Wait for final debounced values to arrive - await ActiveWaitHelpers.WaitUntilAsync( - () => emissionsFast.Count > 0 && emissionsSlow.Count > 0 && - emissionsFast[^1].GetProperty("value").GetInt32() == 10 && - emissionsSlow[^1].GetProperty("value").GetInt32() == 10, - timeout: TimeSpan.FromSeconds(2), - description: "final debounced values for both queries"); - - _output.WriteLine($"Fast query (20ms debounce): {emissionsFast.Count} emissions"); - _output.WriteLine($"Slow query (100ms debounce): {emissionsSlow.Count} emissions"); - - // Fast query should have more emissions than slow query - // Both should be debounced but fast should be less aggressive - Assert.True(emissionsFast.Count >= emissionsSlow.Count, - "Fast query should have >= emissions than slow query due to different debounce windows"); - - // Both should have at least one emission - Assert.True(emissionsFast.Count > 0, "Fast query should have emissions"); - Assert.True(emissionsSlow.Count > 0, "Slow query should have emissions"); - - // Both should have the final value - Assert.Equal(10, emissionsFast[^1].GetProperty("value").GetInt32()); - Assert.Equal(10, emissionsSlow[^1].GetProperty("value").GetInt32()); - } - finally - { - subscriptionFast.Dispose(); - subscriptionSlow.Dispose(); - } - } -} +using System.Text.Json; +using Cocoar.Configuration.Providers.Tests.Helpers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Xunit; +using Xunit.Abstractions; + +namespace Cocoar.Configuration.Providers.Tests.File; + +/// +/// Tests for FileSourceProvider behavior with multiple queries (multiple files from same provider instance) +/// Validates that debouncing happens per-file, not per-directory, and provider sharing works correctly. +/// +public class FileProviderMultiQueryTests +{ + private readonly ITestOutputHelper _output; + + public FileProviderMultiQueryTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task SingleProvider_MultipleFiles_DebounceIndependently() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file1 = TempFileHelper.CreateInDirectory(tempDir.Path, "config1.json", """{"file": 1, "value": 0}"""); + using var file2 = TempFileHelper.CreateInDirectory(tempDir.Path, "config2.json", """{"file": 2, "value": 0}"""); + using var file3 = TempFileHelper.CreateInDirectory(tempDir.Path, "config3.json", """{"file": 3, "value": 0}"""); + + // Single provider instance for the directory + var options = new FileSourceProviderOptions(tempDir.Path); + var provider = new FileSourceProvider(options); + + // Three separate queries for different files + var query1 = new FileSourceProviderQueryOptions("config1.json", DebounceTime: TimeSpan.FromMilliseconds(50)); + var query2 = new FileSourceProviderQueryOptions("config2.json", DebounceTime: TimeSpan.FromMilliseconds(50)); + var query3 = new FileSourceProviderQueryOptions("config3.json", DebounceTime: TimeSpan.FromMilliseconds(50)); + + var emissions1 = new List(); + var emissions2 = new List(); + var emissions3 = new List(); + + var subscription1 = provider.ChangesAsBytes(query1).Subscribe(e => emissions1.Add(e.ToJsonElement())); + var subscription2 = provider.ChangesAsBytes(query2).Subscribe(e => emissions2.Add(e.ToJsonElement())); + var subscription3 = provider.ChangesAsBytes(query3).Subscribe(e => emissions3.Add(e.ToJsonElement())); + + try + { + // Rapid changes to all 3 files simultaneously + var changeCount = 10; + for (var i = 1; i <= changeCount; i++) + { + // Change all files at nearly the same time + file1.WriteJson(new { file = 1, value = i }); + file2.WriteJson(new { file = 2, value = i }); + file3.WriteJson(new { file = 3, value = i }); + await Task.Delay(10); // Rapid writes to test debouncing + } + + // Wait for all file changes to be detected and for final debounced values to arrive + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions1.Count > 0 && emissions2.Count > 0 && emissions3.Count > 0 && + emissions1[^1].GetProperty("value").GetInt32() == changeCount && + emissions2[^1].GetProperty("value").GetInt32() == changeCount && + emissions3[^1].GetProperty("value").GetInt32() == changeCount, + timeout: TimeSpan.FromSeconds(3), + description: "final debounced values for all files"); + + _output.WriteLine($"File 1: made {changeCount} changes, received {emissions1.Count} emissions"); + _output.WriteLine($"File 2: made {changeCount} changes, received {emissions2.Count} emissions"); + _output.WriteLine($"File 3: made {changeCount} changes, received {emissions3.Count} emissions"); + + // Each file should have independent debouncing + Assert.True(emissions1.Count < changeCount, $"File 1 should be debounced: expected < {changeCount}, got {emissions1.Count}"); + Assert.True(emissions2.Count < changeCount, $"File 2 should be debounced: expected < {changeCount}, got {emissions2.Count}"); + Assert.True(emissions3.Count < changeCount, $"File 3 should be debounced: expected < {changeCount}, got {emissions3.Count}"); + + // All files should have at least one emission + Assert.True(emissions1.Count > 0, "File 1 should have at least one emission"); + Assert.True(emissions2.Count > 0, "File 2 should have at least one emission"); + Assert.True(emissions3.Count > 0, "File 3 should have at least one emission"); + + // Final emissions should reflect the last change for each file + Assert.Equal(changeCount, emissions1[^1].GetProperty("value").GetInt32()); + Assert.Equal(changeCount, emissions2[^1].GetProperty("value").GetInt32()); + Assert.Equal(changeCount, emissions3[^1].GetProperty("value").GetInt32()); + + // Validate file identity is preserved + Assert.Equal(1, emissions1[^1].GetProperty("file").GetInt32()); + Assert.Equal(2, emissions2[^1].GetProperty("file").GetInt32()); + Assert.Equal(3, emissions3[^1].GetProperty("file").GetInt32()); + } + finally + { + subscription1.Dispose(); + subscription2.Dispose(); + subscription3.Dispose(); + } + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task SingleProvider_MultipleQueries_SameFile_ShareChangeStream() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "shared.json", """{"shared": true, "value": 0}"""); + + var options = new FileSourceProviderOptions(tempDir.Path); + var provider = new FileSourceProvider(options); + + // Two queries for the same file - should share the change stream + var query1 = new FileSourceProviderQueryOptions("shared.json"); + var query2 = new FileSourceProviderQueryOptions("shared.json"); + + var emissions1 = new List(); + var emissions2 = new List(); + + var subscription1 = provider.ChangesAsBytes(query1).Subscribe(e => emissions1.Add(e.ToJsonElement())); + var subscription2 = provider.ChangesAsBytes(query2).Subscribe(e => emissions2.Add(e.ToJsonElement())); + + try + { + // Make changes to the shared file + for (var i = 1; i <= 5; i++) + { + file.WriteJson(new { shared = true, value = i }); + await Task.Delay(50); // Spaced writes + } + + // Wait for both queries to have their final value + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions1.Count > 0 && emissions2.Count > 0 && + emissions1[^1].GetProperty("value").GetInt32() == 5 && + emissions2[^1].GetProperty("value").GetInt32() == 5, + timeout: TimeSpan.FromSeconds(2), + description: "shared file final value for both queries"); + + _output.WriteLine($"Query 1: {emissions1.Count} emissions"); + _output.WriteLine($"Query 2: {emissions2.Count} emissions"); + + // Both queries should receive the same number of emissions + Assert.Equal(emissions1.Count, emissions2.Count); + Assert.True(emissions1.Count > 0, "Should have received emissions"); + + // Both should have the same final value + var final1 = emissions1[^1].GetProperty("value").GetInt32(); + var final2 = emissions2[^1].GetProperty("value").GetInt32(); + Assert.Equal(final1, final2); + Assert.Equal(5, final1); + } + finally + { + subscription1.Dispose(); + subscription2.Dispose(); + } + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task SingleProvider_DifferentDebouncePerQuery_IndependentThrottling() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "throttle.json", """{"value": 0}"""); + + var options = new FileSourceProviderOptions(tempDir.Path); // No provider-level debouncing + var provider = new FileSourceProvider(options); + + // Same file, different per-query debounce settings + var queryFast = new FileSourceProviderQueryOptions("throttle.json", DebounceTime: TimeSpan.FromMilliseconds(20)); + var querySlow = new FileSourceProviderQueryOptions("throttle.json", DebounceTime: TimeSpan.FromMilliseconds(100)); + + var emissionsFast = new List(); + var emissionsSlow = new List(); + + var subscriptionFast = provider.ChangesAsBytes(queryFast).Subscribe(e => emissionsFast.Add(e.ToJsonElement())); + var subscriptionSlow = provider.ChangesAsBytes(querySlow).Subscribe(e => emissionsSlow.Add(e.ToJsonElement())); + + try + { + // Rapid changes + for (var i = 1; i <= 10; i++) + { + file.WriteJson(new { value = i }); + await Task.Delay(10); // Rapid writes to test differential debouncing + } + + // Wait for final debounced values to arrive + await ActiveWaitHelpers.WaitUntilAsync( + () => emissionsFast.Count > 0 && emissionsSlow.Count > 0 && + emissionsFast[^1].GetProperty("value").GetInt32() == 10 && + emissionsSlow[^1].GetProperty("value").GetInt32() == 10, + timeout: TimeSpan.FromSeconds(2), + description: "final debounced values for both queries"); + + _output.WriteLine($"Fast query (20ms debounce): {emissionsFast.Count} emissions"); + _output.WriteLine($"Slow query (100ms debounce): {emissionsSlow.Count} emissions"); + + // Fast query should have more emissions than slow query + // Both should be debounced but fast should be less aggressive + Assert.True(emissionsFast.Count >= emissionsSlow.Count, + "Fast query should have >= emissions than slow query due to different debounce windows"); + + // Both should have at least one emission + Assert.True(emissionsFast.Count > 0, "Fast query should have emissions"); + Assert.True(emissionsSlow.Count > 0, "Slow query should have emissions"); + + // Both should have the final value + Assert.Equal(10, emissionsFast[^1].GetProperty("value").GetInt32()); + Assert.Equal(10, emissionsSlow[^1].GetProperty("value").GetInt32()); + } + finally + { + subscriptionFast.Dispose(); + subscriptionSlow.Dispose(); + } + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderSecurityTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderSecurityTests.cs index 394ffd8..66679f5 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderSecurityTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderSecurityTests.cs @@ -1,221 +1,221 @@ -using Cocoar.Configuration.Providers.Tests.TestUtilities; -using Xunit; -using Xunit.Abstractions; - -namespace Cocoar.Configuration.Providers.Tests.File; - -/// -/// Security tests for FileSourceProvider: path traversal (S-01) and symlink rejection (S-02). -/// These validate that the provider refuses to read files outside its configured directory. -/// -public class FileProviderSecurityTests -{ - private readonly ITestOutputHelper _output; - - public FileProviderSecurityTests(ITestOutputHelper output) - { - _output = output; - } - - // ────────────────────────────────────────────── - // S-01: Path traversal - // ────────────────────────────────────────────── - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task PathTraversal_DotDotSlash_ThrowsUnauthorizedAccess() - { - using var tempDir = TempDirectoryHelper.Create(); - // Create a file in the parent directory (outside the configured base) - var parentDir = Path.GetDirectoryName(tempDir.Path)!; - var secretFile = Path.Combine(parentDir, "secret.json"); - System.IO.File.WriteAllText(secretFile, """{"leaked": true}"""); - - try - { - var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path)); - var query = new FileSourceProviderQueryOptions("../secret.json"); - - var ex = await Assert.ThrowsAsync( - () => provider.FetchConfigurationBytesAsync(query)); - - Assert.Contains("Path traversal detected", ex.Message); - _output.WriteLine($"Correctly blocked: {ex.Message}"); - } - finally - { - System.IO.File.Delete(secretFile); - } - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task PathTraversal_DeepDotDot_ThrowsUnauthorizedAccess() - { - using var tempDir = TempDirectoryHelper.Create(); - - var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path)); - var query = new FileSourceProviderQueryOptions("sub/../../outside.json"); - - await Assert.ThrowsAsync( - () => provider.FetchConfigurationBytesAsync(query)); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task PathTraversal_SimilarPrefixDirectory_ThrowsUnauthorizedAccess() - { - // Regression: "config_backup" starts with "config" — without trailing separator - // check, a base dir "config" would match "config_backup/../secret.json". - using var parentDir = TempDirectoryHelper.Create(); - var configDir = Path.Combine(parentDir.Path, "config"); - var configBackupDir = Path.Combine(parentDir.Path, "config_backup"); - Directory.CreateDirectory(configDir); - Directory.CreateDirectory(configBackupDir); - - var secretPath = Path.Combine(configBackupDir, "secret.json"); - System.IO.File.WriteAllText(secretPath, """{"leaked": true}"""); - - try - { - var provider = new FileSourceProvider(new FileSourceProviderOptions(configDir)); - // This path resolves to config_backup/secret.json — outside "config/" - var query = new FileSourceProviderQueryOptions("../config_backup/secret.json"); - - var ex = await Assert.ThrowsAsync( - () => provider.FetchConfigurationBytesAsync(query)); - - Assert.Contains("Path traversal detected", ex.Message); - _output.WriteLine($"Similar-prefix attack blocked: {ex.Message}"); - } - finally - { - System.IO.File.Delete(secretPath); - } - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task NormalFile_InsideConfiguredDirectory_Succeeds() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "app.json", """{"ok": true}"""); - - var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path)); - var query = new FileSourceProviderQueryOptions("app.json"); - - var bytes = await provider.FetchConfigurationBytesAsync(query); - Assert.True(bytes.Length > 0); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task Subdirectory_InsideConfiguredDirectory_Succeeds() - { - using var tempDir = TempDirectoryHelper.Create(); - var subDir = Path.Combine(tempDir.Path, "sub"); - Directory.CreateDirectory(subDir); - System.IO.File.WriteAllText(Path.Combine(subDir, "nested.json"), """{"nested": true}"""); - - var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path)); - var query = new FileSourceProviderQueryOptions("sub/nested.json"); - - var bytes = await provider.FetchConfigurationBytesAsync(query); - Assert.True(bytes.Length > 0); - } - - // ────────────────────────────────────────────── - // S-02: Symlink rejection - // ────────────────────────────────────────────── - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task Symlink_ToFileOutsideDirectory_ThrowsUnauthorizedAccess() - { - if (!CanCreateSymlinks()) - { - _output.WriteLine("Skipping: symlink creation requires elevated privileges on this OS"); - return; - } - - using var tempDir = TempDirectoryHelper.Create(); - using var outsideDir = TempDirectoryHelper.Create(); - - // Create a real file outside the config directory - var outsideFile = Path.Combine(outsideDir.Path, "secret.json"); - System.IO.File.WriteAllText(outsideFile, """{"leaked": true}"""); - - // Create a symlink inside the config directory pointing to the outside file - var symlinkPath = Path.Combine(tempDir.Path, "linked.json"); - System.IO.File.CreateSymbolicLink(symlinkPath, outsideFile); - - var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path)); - var query = new FileSourceProviderQueryOptions("linked.json"); - - var ex = await Assert.ThrowsAsync( - () => provider.FetchConfigurationBytesAsync(query)); - - Assert.Contains("Symlinks are not allowed", ex.Message); - _output.WriteLine($"Symlink attack blocked: {ex.Message}"); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public async Task Symlink_ToFileInsideDirectory_StillRejected() - { - // Symlinks are rejected regardless of target — defense in depth - if (!CanCreateSymlinks()) - { - _output.WriteLine("Skipping: symlink creation requires elevated privileges on this OS"); - return; - } - - using var tempDir = TempDirectoryHelper.Create(); - var realFile = Path.Combine(tempDir.Path, "real.json"); - System.IO.File.WriteAllText(realFile, """{"real": true}"""); - - var symlinkPath = Path.Combine(tempDir.Path, "link.json"); - System.IO.File.CreateSymbolicLink(symlinkPath, realFile); - - var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path)); - var query = new FileSourceProviderQueryOptions("link.json"); - - var ex = await Assert.ThrowsAsync( - () => provider.FetchConfigurationBytesAsync(query)); - - Assert.Contains("Symlinks are not allowed", ex.Message); - } - - private static bool CanCreateSymlinks() - { - var testDir = Path.Combine(Path.GetTempPath(), "cocoar_symlink_test_" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(testDir); - try - { - var target = Path.Combine(testDir, "target.txt"); - System.IO.File.WriteAllText(target, "test"); - var link = Path.Combine(testDir, "link.txt"); - - try - { - System.IO.File.CreateSymbolicLink(link, target); - return System.IO.File.Exists(link); - } - catch - { - return false; - } - } - finally - { - try { Directory.Delete(testDir, recursive: true); } catch { } - } - } -} +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Xunit; +using Xunit.Abstractions; + +namespace Cocoar.Configuration.Providers.Tests.File; + +/// +/// Security tests for FileSourceProvider: path traversal (S-01) and symlink rejection (S-02). +/// These validate that the provider refuses to read files outside its configured directory. +/// +public class FileProviderSecurityTests +{ + private readonly ITestOutputHelper _output; + + public FileProviderSecurityTests(ITestOutputHelper output) + { + _output = output; + } + + // ────────────────────────────────────────────── + // S-01: Path traversal + // ────────────────────────────────────────────── + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task PathTraversal_DotDotSlash_ThrowsUnauthorizedAccess() + { + using var tempDir = TempDirectoryHelper.Create(); + // Create a file in the parent directory (outside the configured base) + var parentDir = Path.GetDirectoryName(tempDir.Path)!; + var secretFile = Path.Combine(parentDir, "secret.json"); + System.IO.File.WriteAllText(secretFile, """{"leaked": true}"""); + + try + { + var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path)); + var query = new FileSourceProviderQueryOptions("../secret.json"); + + var ex = await Assert.ThrowsAsync( + () => provider.FetchConfigurationBytesAsync(query)); + + Assert.Contains("Path traversal detected", ex.Message); + _output.WriteLine($"Correctly blocked: {ex.Message}"); + } + finally + { + System.IO.File.Delete(secretFile); + } + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task PathTraversal_DeepDotDot_ThrowsUnauthorizedAccess() + { + using var tempDir = TempDirectoryHelper.Create(); + + var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path)); + var query = new FileSourceProviderQueryOptions("sub/../../outside.json"); + + await Assert.ThrowsAsync( + () => provider.FetchConfigurationBytesAsync(query)); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task PathTraversal_SimilarPrefixDirectory_ThrowsUnauthorizedAccess() + { + // Regression: "config_backup" starts with "config" — without trailing separator + // check, a base dir "config" would match "config_backup/../secret.json". + using var parentDir = TempDirectoryHelper.Create(); + var configDir = Path.Combine(parentDir.Path, "config"); + var configBackupDir = Path.Combine(parentDir.Path, "config_backup"); + Directory.CreateDirectory(configDir); + Directory.CreateDirectory(configBackupDir); + + var secretPath = Path.Combine(configBackupDir, "secret.json"); + System.IO.File.WriteAllText(secretPath, """{"leaked": true}"""); + + try + { + var provider = new FileSourceProvider(new FileSourceProviderOptions(configDir)); + // This path resolves to config_backup/secret.json — outside "config/" + var query = new FileSourceProviderQueryOptions("../config_backup/secret.json"); + + var ex = await Assert.ThrowsAsync( + () => provider.FetchConfigurationBytesAsync(query)); + + Assert.Contains("Path traversal detected", ex.Message); + _output.WriteLine($"Similar-prefix attack blocked: {ex.Message}"); + } + finally + { + System.IO.File.Delete(secretPath); + } + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task NormalFile_InsideConfiguredDirectory_Succeeds() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "app.json", """{"ok": true}"""); + + var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path)); + var query = new FileSourceProviderQueryOptions("app.json"); + + var bytes = await provider.FetchConfigurationBytesAsync(query); + Assert.True(bytes.Length > 0); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task Subdirectory_InsideConfiguredDirectory_Succeeds() + { + using var tempDir = TempDirectoryHelper.Create(); + var subDir = Path.Combine(tempDir.Path, "sub"); + Directory.CreateDirectory(subDir); + System.IO.File.WriteAllText(Path.Combine(subDir, "nested.json"), """{"nested": true}"""); + + var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path)); + var query = new FileSourceProviderQueryOptions("sub/nested.json"); + + var bytes = await provider.FetchConfigurationBytesAsync(query); + Assert.True(bytes.Length > 0); + } + + // ────────────────────────────────────────────── + // S-02: Symlink rejection + // ────────────────────────────────────────────── + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task Symlink_ToFileOutsideDirectory_ThrowsUnauthorizedAccess() + { + if (!CanCreateSymlinks()) + { + _output.WriteLine("Skipping: symlink creation requires elevated privileges on this OS"); + return; + } + + using var tempDir = TempDirectoryHelper.Create(); + using var outsideDir = TempDirectoryHelper.Create(); + + // Create a real file outside the config directory + var outsideFile = Path.Combine(outsideDir.Path, "secret.json"); + System.IO.File.WriteAllText(outsideFile, """{"leaked": true}"""); + + // Create a symlink inside the config directory pointing to the outside file + var symlinkPath = Path.Combine(tempDir.Path, "linked.json"); + System.IO.File.CreateSymbolicLink(symlinkPath, outsideFile); + + var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path)); + var query = new FileSourceProviderQueryOptions("linked.json"); + + var ex = await Assert.ThrowsAsync( + () => provider.FetchConfigurationBytesAsync(query)); + + Assert.Contains("Symlinks are not allowed", ex.Message); + _output.WriteLine($"Symlink attack blocked: {ex.Message}"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task Symlink_ToFileInsideDirectory_StillRejected() + { + // Symlinks are rejected regardless of target — defense in depth + if (!CanCreateSymlinks()) + { + _output.WriteLine("Skipping: symlink creation requires elevated privileges on this OS"); + return; + } + + using var tempDir = TempDirectoryHelper.Create(); + var realFile = Path.Combine(tempDir.Path, "real.json"); + System.IO.File.WriteAllText(realFile, """{"real": true}"""); + + var symlinkPath = Path.Combine(tempDir.Path, "link.json"); + System.IO.File.CreateSymbolicLink(symlinkPath, realFile); + + var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path)); + var query = new FileSourceProviderQueryOptions("link.json"); + + var ex = await Assert.ThrowsAsync( + () => provider.FetchConfigurationBytesAsync(query)); + + Assert.Contains("Symlinks are not allowed", ex.Message); + } + + private static bool CanCreateSymlinks() + { + var testDir = Path.Combine(Path.GetTempPath(), "cocoar_symlink_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(testDir); + try + { + var target = Path.Combine(testDir, "target.txt"); + System.IO.File.WriteAllText(target, "test"); + var link = Path.Combine(testDir, "link.txt"); + + try + { + System.IO.File.CreateSymbolicLink(link, target); + return System.IO.File.Exists(link); + } + catch + { + return false; + } + } + finally + { + try { Directory.Delete(testDir, recursive: true); } catch { } + } + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderStressTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderStressTests.cs index 903138c..f886ce9 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderStressTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderStressTests.cs @@ -1,310 +1,310 @@ -using System.Text.Json; -using Cocoar.Configuration.Providers.Tests.Helpers; -using Cocoar.Configuration.Providers.Tests.TestUtilities; -using Xunit; -using Xunit.Abstractions; - -namespace Cocoar.Configuration.Providers.Tests.File; - -/// -/// Stress tests for FileSourceProvider in isolation (no ConfigManager). -/// Focus areas: debouncing, rapid file changes, concurrent access, file locking, change detection reliability. -/// -public class FileProviderStressTests -{ - private readonly ITestOutputHelper _output; - - public FileProviderStressTests(ITestOutputHelper output) - { - _output = output; - } - - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "FileSourceProvider")] - public async Task RapidFileChanges_WithDebouncing_CoalescesCorrectly() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "config.json", """{"value": 0}"""); - - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions("config.json", DebounceTime: TimeSpan.FromMilliseconds(50)); - // Using declaration ensures provider (file watchers) are disposed before temp file - using var provider = new FileSourceProvider(options); - - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - - try - { - // Rapid-fire 20 changes in quick succession - var changeCount = 20; - for (var i = 1; i <= changeCount; i++) - { - file.WriteJson(new { value = i }); - await Task.Delay(10); // Faster than debounce window to test coalescing - } - - // Wait for debouncing to settle and final emission - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count > 0 && emissions[^1].GetProperty("value").GetInt32() == changeCount, - timeout: TimeSpan.FromSeconds(3), - description: "rapid file changes debouncing"); - - _output.WriteLine($"Made {changeCount} rapid changes, received {emissions.Count} emissions"); - - // Debouncing should significantly reduce emissions - Assert.True(emissions.Count < changeCount, $"Expected fewer than {changeCount} emissions due to debouncing, got {emissions.Count}"); - Assert.True(emissions.Count > 0, "Should have received at least one emission"); - - // Final emission should reflect the last change - var finalValue = emissions[^1].GetProperty("value").GetInt32(); - Assert.Equal(changeCount, finalValue); - } - finally - { - subscription.Dispose(); - } - } - - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "FileSourceProvider")] - public async Task HighFrequencyWrites_FileShareHandling_NoLockingErrors() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "highfreq.json", """{"counter": 0}"""); - - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions("highfreq.json"); - // Using declaration: dispose provider before file cleanup to release read handles (avoids delete locking on Windows) - using var provider = new FileSourceProvider(options); - - var readErrors = 0; - var successfulReads = 0; - var emissions = new List(); - - var subscription = provider.ChangesAsBytes(query) - .Subscribe( - onNext: json => - { - emissions.Add(json.ToJsonElement()); - Interlocked.Increment(ref successfulReads); - }, - onError: ex => - { - _output.WriteLine($"Change stream error: {ex}"); - Interlocked.Increment(ref readErrors); - }); - - try - { - // Simulate high-frequency writes from multiple "processes" - var writeCount = 50; - var writeTasks = new List(); - - for (var i = 0; i < writeCount; i++) - { - var value = i; - writeTasks.Add(Task.Run(async () => - { - await Task.Delay(Random.Shared.Next(5, 25)); // Stagger writes - try - { - file.WriteJson(new { counter = value, timestamp = DateTime.UtcNow }); - } - catch (IOException ex) - { - _output.WriteLine($"Write {value} failed: {ex.Message}"); - } - })); - } - - await Task.WhenAll(writeTasks); - - // Wait for file system events to settle - await ActiveWaitHelpers.WaitUntilAsync( - () => successfulReads > 0, - timeout: TimeSpan.FromSeconds(3), - description: "concurrent file writes completion"); - - _output.WriteLine($"Completed {writeCount} writes, {successfulReads} successful reads, {readErrors} read errors, {emissions.Count} emissions"); - - // FileShare.ReadWrite should prevent locking errors - Assert.Equal(0, readErrors); - Assert.True(successfulReads > 0, "Should have successfully read file changes"); - Assert.True(emissions.Count > 0, "Should have received change emissions"); - } - finally - { - subscription.Dispose(); - } - } - - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "FileSourceProvider")] - public async Task ConcurrentFileAccess_MultipleProviders_SameFile() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "shared.json", """{"shared": true}"""); - - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions("shared.json"); - - // Create 5 providers accessing the same file concurrently - var providerCount = 5; - var providers = new List(); - var subscriptions = new List(); - var allEmissions = new List>(); - - try - { - for (var i = 0; i < providerCount; i++) - { - var provider = new FileSourceProvider(options); - providers.Add(provider); - - var emissions = new List(); - allEmissions.Add(emissions); - - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - subscriptions.Add(subscription); - } - - // Make several changes while all providers are watching - var changes = 10; - for (var i = 1; i <= changes; i++) - { - file.WriteJson(new { shared = true, iteration = i, timestamp = DateTime.UtcNow.Ticks }); - await Task.Delay(50); // Deliberate spacing between writes - } - - // Wait for all providers to receive emissions - await ActiveWaitHelpers.WaitUntilAsync( - () => allEmissions.All(e => e.Count > 0), - timeout: TimeSpan.FromSeconds(3), - description: "concurrent providers receiving file changes"); - - _output.WriteLine($"Made {changes} changes across {providerCount} concurrent providers"); - - for (var i = 0; i < providerCount; i++) - { - var emissions = allEmissions[i]; - _output.WriteLine($"Provider {i}: {emissions.Count} emissions"); - Assert.True(emissions.Count > 0, $"Provider {i} should have received emissions"); - - // Each provider should receive the changes independently - if (emissions.Count > 0) - { - var lastValue = emissions[^1].GetProperty("iteration").GetInt32(); - Assert.True(lastValue > 0 && lastValue <= changes, - $"Provider {i} final value {lastValue} should be within expected range"); - } - } - } - finally - { - subscriptions.ForEach(s => s.Dispose()); - } - } - - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "FileSourceProvider")] - public async Task FileRecreation_DeleteAndRecreate_EmitsCorrectly() - { - using var tempDir = TempDirectoryHelper.Create(); - var fileName = "recreate.json"; - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, fileName, """{"state": "initial"}"""); - - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions(fileName); - // Dispose provider earlier than file to avoid lingering watchers during delete/recreate cycle - using var provider = new FileSourceProvider(options); - - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - - try - { - // Initial modification - file.WriteJson(new { state = "modified" }); - await Task.Delay(100); // File system propagation delay - - // Delete the file - file.Delete(); - await Task.Delay(100); // File system propagation delay - - // Recreate with different content - file.WriteJson(new { state = "recreated", newField = "added" }); - - // Wait for recreation to be detected - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Any(e => e.TryGetProperty("state", out var state) && - state.GetString() == "recreated"), - timeout: TimeSpan.FromSeconds(2), - description: "file recreation detection"); - - _output.WriteLine($"File recreation cycle completed, received {emissions.Count} emissions"); - - Assert.True(emissions.Count >= 2, "Should detect both modification and recreation"); - - // Find the last emission that contains the "state" property (skip any empty/deletion emissions) - var finalEmission = emissions.LastOrDefault(e => e.TryGetProperty("state", out _)); - Assert.NotEqual(default, finalEmission); - Assert.Equal("recreated", finalEmission.GetProperty("state").GetString()); - Assert.True(finalEmission.TryGetProperty("newField", out _), "Should contain new field after recreation"); - } - finally - { - subscription.Dispose(); - } - } - - [Fact] - [Trait("Type", "Stress")] - [Trait("Provider", "FileSourceProvider")] - public async Task MalformedJsonRecovery_BadThenGoodJson_HandlesgGracefully() - { - using var tempDir = TempDirectoryHelper.Create(); - using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "recovery.json", """{"valid": true}"""); - - var options = new FileSourceProviderOptions(tempDir.Path); - var query = new FileSourceProviderQueryOptions("recovery.json"); - // Ensure provider disposed before temp file to reduce chance of locked handle during malformed/valid transitions - using var provider = new FileSourceProvider(options); - - var emissions = new List(); - var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); - - try - { - // Write malformed JSON - should emit empty object - file.WriteContent("{ invalid json here }"); - await Task.Delay(100); - - // Write valid JSON again - file.WriteJson(new { recovered = true, value = 42 }); - await Task.Delay(100); - - _output.WriteLine($"JSON recovery test completed, received {emissions.Count} emissions"); - - Assert.True(emissions.Count >= 2, "Should have emissions for both malformed and recovered JSON"); - - // Check that malformed JSON resulted in empty object - var malformedEmission = emissions[0]; - Assert.Equal(JsonValueKind.Object, malformedEmission.ValueKind); - Assert.True(malformedEmission.EnumerateObject().MoveNext() == false, "Malformed JSON should result in empty object"); - - // Check that recovery worked - var recoveredEmission = emissions[^1]; - Assert.True(recoveredEmission.GetProperty("recovered").GetBoolean()); - Assert.Equal(42, recoveredEmission.GetProperty("value").GetInt32()); - } - finally - { - subscription.Dispose(); - } - } -} +using System.Text.Json; +using Cocoar.Configuration.Providers.Tests.Helpers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Xunit; +using Xunit.Abstractions; + +namespace Cocoar.Configuration.Providers.Tests.File; + +/// +/// Stress tests for FileSourceProvider in isolation (no ConfigManager). +/// Focus areas: debouncing, rapid file changes, concurrent access, file locking, change detection reliability. +/// +public class FileProviderStressTests +{ + private readonly ITestOutputHelper _output; + + public FileProviderStressTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "FileSourceProvider")] + public async Task RapidFileChanges_WithDebouncing_CoalescesCorrectly() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "config.json", """{"value": 0}"""); + + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions("config.json", DebounceTime: TimeSpan.FromMilliseconds(50)); + // Using declaration ensures provider (file watchers) are disposed before temp file + using var provider = new FileSourceProvider(options); + + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + + try + { + // Rapid-fire 20 changes in quick succession + var changeCount = 20; + for (var i = 1; i <= changeCount; i++) + { + file.WriteJson(new { value = i }); + await Task.Delay(10); // Faster than debounce window to test coalescing + } + + // Wait for debouncing to settle and final emission + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count > 0 && emissions[^1].GetProperty("value").GetInt32() == changeCount, + timeout: TimeSpan.FromSeconds(3), + description: "rapid file changes debouncing"); + + _output.WriteLine($"Made {changeCount} rapid changes, received {emissions.Count} emissions"); + + // Debouncing should significantly reduce emissions + Assert.True(emissions.Count < changeCount, $"Expected fewer than {changeCount} emissions due to debouncing, got {emissions.Count}"); + Assert.True(emissions.Count > 0, "Should have received at least one emission"); + + // Final emission should reflect the last change + var finalValue = emissions[^1].GetProperty("value").GetInt32(); + Assert.Equal(changeCount, finalValue); + } + finally + { + subscription.Dispose(); + } + } + + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "FileSourceProvider")] + public async Task HighFrequencyWrites_FileShareHandling_NoLockingErrors() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "highfreq.json", """{"counter": 0}"""); + + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions("highfreq.json"); + // Using declaration: dispose provider before file cleanup to release read handles (avoids delete locking on Windows) + using var provider = new FileSourceProvider(options); + + var readErrors = 0; + var successfulReads = 0; + var emissions = new List(); + + var subscription = provider.ChangesAsBytes(query) + .Subscribe( + onNext: json => + { + emissions.Add(json.ToJsonElement()); + Interlocked.Increment(ref successfulReads); + }, + onError: ex => + { + _output.WriteLine($"Change stream error: {ex}"); + Interlocked.Increment(ref readErrors); + }); + + try + { + // Simulate high-frequency writes from multiple "processes" + var writeCount = 50; + var writeTasks = new List(); + + for (var i = 0; i < writeCount; i++) + { + var value = i; + writeTasks.Add(Task.Run(async () => + { + await Task.Delay(Random.Shared.Next(5, 25)); // Stagger writes + try + { + file.WriteJson(new { counter = value, timestamp = DateTime.UtcNow }); + } + catch (IOException ex) + { + _output.WriteLine($"Write {value} failed: {ex.Message}"); + } + })); + } + + await Task.WhenAll(writeTasks); + + // Wait for file system events to settle + await ActiveWaitHelpers.WaitUntilAsync( + () => successfulReads > 0, + timeout: TimeSpan.FromSeconds(3), + description: "concurrent file writes completion"); + + _output.WriteLine($"Completed {writeCount} writes, {successfulReads} successful reads, {readErrors} read errors, {emissions.Count} emissions"); + + // FileShare.ReadWrite should prevent locking errors + Assert.Equal(0, readErrors); + Assert.True(successfulReads > 0, "Should have successfully read file changes"); + Assert.True(emissions.Count > 0, "Should have received change emissions"); + } + finally + { + subscription.Dispose(); + } + } + + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "FileSourceProvider")] + public async Task ConcurrentFileAccess_MultipleProviders_SameFile() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "shared.json", """{"shared": true}"""); + + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions("shared.json"); + + // Create 5 providers accessing the same file concurrently + var providerCount = 5; + var providers = new List(); + var subscriptions = new List(); + var allEmissions = new List>(); + + try + { + for (var i = 0; i < providerCount; i++) + { + var provider = new FileSourceProvider(options); + providers.Add(provider); + + var emissions = new List(); + allEmissions.Add(emissions); + + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + subscriptions.Add(subscription); + } + + // Make several changes while all providers are watching + var changes = 10; + for (var i = 1; i <= changes; i++) + { + file.WriteJson(new { shared = true, iteration = i, timestamp = DateTime.UtcNow.Ticks }); + await Task.Delay(50); // Deliberate spacing between writes + } + + // Wait for all providers to receive emissions + await ActiveWaitHelpers.WaitUntilAsync( + () => allEmissions.All(e => e.Count > 0), + timeout: TimeSpan.FromSeconds(3), + description: "concurrent providers receiving file changes"); + + _output.WriteLine($"Made {changes} changes across {providerCount} concurrent providers"); + + for (var i = 0; i < providerCount; i++) + { + var emissions = allEmissions[i]; + _output.WriteLine($"Provider {i}: {emissions.Count} emissions"); + Assert.True(emissions.Count > 0, $"Provider {i} should have received emissions"); + + // Each provider should receive the changes independently + if (emissions.Count > 0) + { + var lastValue = emissions[^1].GetProperty("iteration").GetInt32(); + Assert.True(lastValue > 0 && lastValue <= changes, + $"Provider {i} final value {lastValue} should be within expected range"); + } + } + } + finally + { + subscriptions.ForEach(s => s.Dispose()); + } + } + + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "FileSourceProvider")] + public async Task FileRecreation_DeleteAndRecreate_EmitsCorrectly() + { + using var tempDir = TempDirectoryHelper.Create(); + var fileName = "recreate.json"; + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, fileName, """{"state": "initial"}"""); + + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions(fileName); + // Dispose provider earlier than file to avoid lingering watchers during delete/recreate cycle + using var provider = new FileSourceProvider(options); + + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + + try + { + // Initial modification + file.WriteJson(new { state = "modified" }); + await Task.Delay(100); // File system propagation delay + + // Delete the file + file.Delete(); + await Task.Delay(100); // File system propagation delay + + // Recreate with different content + file.WriteJson(new { state = "recreated", newField = "added" }); + + // Wait for recreation to be detected + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Any(e => e.TryGetProperty("state", out var state) && + state.GetString() == "recreated"), + timeout: TimeSpan.FromSeconds(2), + description: "file recreation detection"); + + _output.WriteLine($"File recreation cycle completed, received {emissions.Count} emissions"); + + Assert.True(emissions.Count >= 2, "Should detect both modification and recreation"); + + // Find the last emission that contains the "state" property (skip any empty/deletion emissions) + var finalEmission = emissions.LastOrDefault(e => e.TryGetProperty("state", out _)); + Assert.NotEqual(default, finalEmission); + Assert.Equal("recreated", finalEmission.GetProperty("state").GetString()); + Assert.True(finalEmission.TryGetProperty("newField", out _), "Should contain new field after recreation"); + } + finally + { + subscription.Dispose(); + } + } + + [Fact] + [Trait("Type", "Stress")] + [Trait("Provider", "FileSourceProvider")] + public async Task MalformedJsonRecovery_BadThenGoodJson_HandlesgGracefully() + { + using var tempDir = TempDirectoryHelper.Create(); + using var file = TempFileHelper.CreateInDirectory(tempDir.Path, "recovery.json", """{"valid": true}"""); + + var options = new FileSourceProviderOptions(tempDir.Path); + var query = new FileSourceProviderQueryOptions("recovery.json"); + // Ensure provider disposed before temp file to reduce chance of locked handle during malformed/valid transitions + using var provider = new FileSourceProvider(options); + + var emissions = new List(); + var subscription = provider.ChangesAsBytes(query).Subscribe(e => emissions.Add(e.ToJsonElement())); + + try + { + // Write malformed JSON - should emit empty object + file.WriteContent("{ invalid json here }"); + await Task.Delay(100); + + // Write valid JSON again + file.WriteJson(new { recovered = true, value = 42 }); + await Task.Delay(100); + + _output.WriteLine($"JSON recovery test completed, received {emissions.Count} emissions"); + + Assert.True(emissions.Count >= 2, "Should have emissions for both malformed and recovered JSON"); + + // Check that malformed JSON resulted in empty object + var malformedEmission = emissions[0]; + Assert.Equal(JsonValueKind.Object, malformedEmission.ValueKind); + Assert.True(malformedEmission.EnumerateObject().MoveNext() == false, "Malformed JSON should result in empty object"); + + // Check that recovery worked + var recoveredEmission = emissions[^1]; + Assert.True(recoveredEmission.GetProperty("recovered").GetBoolean()); + Assert.Equal(42, recoveredEmission.GetProperty("value").GetInt32()); + } + finally + { + subscription.Dispose(); + } + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderUnitTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderUnitTests.cs index 5125cb7..6c53691 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderUnitTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderUnitTests.cs @@ -1,140 +1,140 @@ -using System.Text.Json; -using Xunit; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers.Tests.TestUtilities; -using Cocoar.Configuration.Rules; - -namespace Cocoar.Configuration.Providers.Tests.File; - -// File provider unit tests (deterministic file operations with TempFileHelper) -public class FileProviderUnitTests -{ - private sealed class AppConfig { public string? Name { get; set; } public int Value { get; set; } } - private sealed class NestedConfig { public AppConfig App { get; set; } = new(); } - - private static ConfigRule CreateFileRule(string filePath, string? selectPath = null, bool required = false) where T : class - { - var rulesBuilder = new RulesBuilder(); - var builder = rulesBuilder.For().FromFile(filePath); - if (selectPath != null) builder = builder.Select(selectPath); - if (required) builder = builder.Required(); - return builder; - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public void MissingFile_OptionalRule_SkipsHealthy() - { - var path = Path.Combine(Path.GetTempPath(), "cocoar_missing_" + Guid.NewGuid().ToString("N") + ".json"); - var rule = CreateFileRule(path, required: false); // explicitly optional - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - // Optional rule fails but doesn't make the system unhealthy - Degraded - Assert.Equal(Health.HealthStatus.Degraded, manager.HealthStatus); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public void MissingFile_RequiredRule_Degrades() - { - var path = Path.Combine(Path.GetTempPath(), "cocoar_missing_req_" + Guid.NewGuid().ToString("N") + ".json"); - var rule = CreateFileRule(path, required: true); - // Should throw during initialization for required missing file (wrapped in InvalidOperationException) - var ex = Assert.Throws(() => ConfigManager.Create(c => c.UseConfiguration(new[]{rule}))); - Assert.Contains("Required rule failed", ex.Message); - Assert.IsType(ex.InnerException); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public void ValidJsonFile_LoadsCorrectly() - { - using var file = TempFileHelper.Create(); - file.WriteJson(new { Name = "TestApp", Value = 42 }); - - var rule = CreateFileRule(file.FilePath, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.Equal("TestApp", config!.Name); - Assert.Equal(42, config.Value); - - Assert.Equal(Health.HealthStatus.Healthy, manager.HealthStatus); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public void MalformedJson_RequiredRule_Degrades() - { - using var file = TempFileHelper.Create(); - file.WriteContent("{ invalid json syntax }"); - - var rule = CreateFileRule(file.FilePath, required: true); - // Should throw during initialization due to JSON parse error (wrapped in InvalidOperationException) - var ex = Assert.Throws(() => ConfigManager.Create(c => c.UseConfiguration(new[]{rule}))); - Assert.Contains("Required rule failed", ex.Message); - // Inner exception should be JSON parsing related - Assert.True(ex.InnerException is JsonException || ex.InnerException?.GetType().Name.Contains("Json") == true); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public void EmptyJsonObject_LoadsAsDefault() - { - using var file = TempFileHelper.Create(); - file.WriteContent("{}"); - - var rule = CreateFileRule(file.FilePath, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.Null(config!.Name); // Default value - Assert.Equal(0, config.Value); // Default value - - Assert.Equal(Health.HealthStatus.Healthy, manager.HealthStatus); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public void NestedJsonStructure_MapsToHierarchy() - { - using var file = TempFileHelper.Create(); - file.WriteJson(new { App = new { Name = "Nested", Value = 100 } }); - - var rule = CreateFileRule(file.FilePath, required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.Equal("Nested", config!.App.Name); - Assert.Equal(100, config.App.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "FileSourceProvider")] - public void FileWithSelect_ExtractsSection() - { - using var file = TempFileHelper.Create(); - file.WriteJson(new { - Database = new { ConnectionString = "test" }, - App = new { Name = "SectionTest", Value = 200 } - }); - - var rule = CreateFileRule(file.FilePath, selectPath: "App", required: true); - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.Equal("SectionTest", config!.Name); - Assert.Equal(200, config.Value); - } -} +using System.Text.Json; +using Xunit; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Cocoar.Configuration.Rules; + +namespace Cocoar.Configuration.Providers.Tests.File; + +// File provider unit tests (deterministic file operations with TempFileHelper) +public class FileProviderUnitTests +{ + private sealed class AppConfig { public string? Name { get; set; } public int Value { get; set; } } + private sealed class NestedConfig { public AppConfig App { get; set; } = new(); } + + private static ConfigRule CreateFileRule(string filePath, string? selectPath = null, bool required = false) where T : class + { + var rulesBuilder = new RulesBuilder(); + var builder = rulesBuilder.For().FromFile(filePath); + if (selectPath != null) builder = builder.Select(selectPath); + if (required) builder = builder.Required(); + return builder; + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public void MissingFile_OptionalRule_SkipsHealthy() + { + var path = Path.Combine(Path.GetTempPath(), "cocoar_missing_" + Guid.NewGuid().ToString("N") + ".json"); + var rule = CreateFileRule(path, required: false); // explicitly optional + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + // Optional rule fails but doesn't make the system unhealthy - Degraded + Assert.Equal(Health.HealthStatus.Degraded, manager.HealthStatus); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public void MissingFile_RequiredRule_Degrades() + { + var path = Path.Combine(Path.GetTempPath(), "cocoar_missing_req_" + Guid.NewGuid().ToString("N") + ".json"); + var rule = CreateFileRule(path, required: true); + // Should throw during initialization for required missing file (wrapped in InvalidOperationException) + var ex = Assert.Throws(() => ConfigManager.Create(c => c.UseConfiguration(new[]{rule}))); + Assert.Contains("Required rule failed", ex.Message); + Assert.IsType(ex.InnerException); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public void ValidJsonFile_LoadsCorrectly() + { + using var file = TempFileHelper.Create(); + file.WriteJson(new { Name = "TestApp", Value = 42 }); + + var rule = CreateFileRule(file.FilePath, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Equal("TestApp", config!.Name); + Assert.Equal(42, config.Value); + + Assert.Equal(Health.HealthStatus.Healthy, manager.HealthStatus); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public void MalformedJson_RequiredRule_Degrades() + { + using var file = TempFileHelper.Create(); + file.WriteContent("{ invalid json syntax }"); + + var rule = CreateFileRule(file.FilePath, required: true); + // Should throw during initialization due to JSON parse error (wrapped in InvalidOperationException) + var ex = Assert.Throws(() => ConfigManager.Create(c => c.UseConfiguration(new[]{rule}))); + Assert.Contains("Required rule failed", ex.Message); + // Inner exception should be JSON parsing related + Assert.True(ex.InnerException is JsonException || ex.InnerException?.GetType().Name.Contains("Json") == true); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public void EmptyJsonObject_LoadsAsDefault() + { + using var file = TempFileHelper.Create(); + file.WriteContent("{}"); + + var rule = CreateFileRule(file.FilePath, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Null(config!.Name); // Default value + Assert.Equal(0, config.Value); // Default value + + Assert.Equal(Health.HealthStatus.Healthy, manager.HealthStatus); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public void NestedJsonStructure_MapsToHierarchy() + { + using var file = TempFileHelper.Create(); + file.WriteJson(new { App = new { Name = "Nested", Value = 100 } }); + + var rule = CreateFileRule(file.FilePath, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Equal("Nested", config!.App.Name); + Assert.Equal(100, config.App.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public void FileWithSelect_ExtractsSection() + { + using var file = TempFileHelper.Create(); + file.WriteJson(new { + Database = new { ConnectionString = "test" }, + App = new { Name = "SectionTest", Value = 200 } + }); + + var rule = CreateFileRule(file.FilePath, selectPath: "App", required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Equal("SectionTest", config!.Name); + Assert.Equal(200, config.Value); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/File/ResilientFileSourceProviderTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/File/ResilientFileSourceProviderTests.cs index 7adb971..4665423 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/File/ResilientFileSourceProviderTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/File/ResilientFileSourceProviderTests.cs @@ -1,225 +1,225 @@ -using System.Text.Json; -using Cocoar.Configuration.Providers.Tests.Helpers; -using Cocoar.Configuration.Providers.Tests.TestUtilities; -using Xunit; -using Xunit.Abstractions; - -namespace Cocoar.Configuration.Providers.Tests.File; - -public class ResilientFileSourceProviderTests -{ - private readonly ITestOutputHelper _output; - - public ResilientFileSourceProviderTests(ITestOutputHelper output) - { - _output = output; - } - - /// - /// Test the complete resilient cycle: FsWatcher → Error → Polling → Recovery - /// - [Fact] - [Trait("Type", "Integration")] - [Trait("Provider", "FileSourceProvider")] - [Trait("Category", "ResilientImplementation")] - public async Task ResilientFileSourceProvider_DirectoryDeletionAndRecreation_ShouldRecover() - { - using var tempDir = TempDirectoryHelper.Create(); - - var featureDir = Path.Combine(tempDir.Path, "features"); - var configFile = Path.Combine(featureDir, "config.json"); - - _output.WriteLine("TESTING RESILIENT FILESOURCEPROVIDER:"); - _output.WriteLine("1. Start with existing directory (FileSystemWatcher mode)"); - _output.WriteLine("2. Delete directory (triggers Error event → polling mode)"); - _output.WriteLine("3. Recreate directory (polling detects → FileSystemWatcher restarts)"); - _output.WriteLine("4. Verify configuration access works throughout"); - - // === PHASE 1: Directory exists === - Directory.CreateDirectory(featureDir); - System.IO.File.WriteAllText(configFile, "{ \"version\": 1, \"enabled\": true }"); - - _output.WriteLine($"Phase 1: Created directory {featureDir}"); - - var options = new FileSourceProviderOptions(featureDir); - var query = new FileSourceProviderQueryOptions("config.json"); - using var provider = new FileSourceProvider(options); - - var emissions = new List(); - var errors = new List(); - - var subscription = provider.ChangesAsBytes(query).Subscribe( - onNext: emission => - { - emissions.Add(emission.ToJsonElement()); - _output.WriteLine($"📩 Change emission: version={GetVersion(emission.ToJsonElement())}, enabled={GetEnabled(emission.ToJsonElement())}"); - }, - onError: ex => - { - errors.Add(ex); - _output.WriteLine($"❌ Change stream error: {ex.GetType().Name}: {ex.Message}"); - } - ); - - await Task.Delay(200); // Let FileSystemWatcher initialize - - // Test initial FetchConfigurationAsync - var initialConfig = await provider.FetchConfigurationBytesAsync(query); - _output.WriteLine($"✅ Initial config fetch: version={GetVersion(initialConfig.ToJsonElement())}"); - - // === PHASE 2: Modify file (FileSystemWatcher should detect) === - _output.WriteLine("Phase 2: Modifying file to test FileSystemWatcher"); - System.IO.File.WriteAllText(configFile, "{ \"version\": 2, \"enabled\": false }"); - - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count > 0, - timeout: TimeSpan.FromSeconds(3), - description: "detect initial modification"); - var emissionsAfterModify = emissions.Count; - - // === PHASE 3: Delete directory === - _output.WriteLine("Phase 3: Deleting directory - should trigger Error event and polling fallback"); - Directory.Delete(featureDir, recursive: true); - - await ActiveWaitHelpers.WaitUntilAsync( - () => { - try { - provider.FetchConfigurationBytesAsync(query).GetAwaiter().GetResult(); - return false; // still able to fetch - } catch (DirectoryNotFoundException) { return true; } catch { return false; } - }, - timeout: TimeSpan.FromSeconds(8), - description: "fetch failing with DirectoryNotFoundException after deletion"); - - // Test FetchConfigurationAsync during polling (should throw) - try - { - var configDuringPolling = await provider.FetchConfigurationBytesAsync(query); - _output.WriteLine("⚠️ Unexpected: FetchConfigurationAsync succeeded during polling"); - } - catch (DirectoryNotFoundException ex) - { - _output.WriteLine($"✅ Expected during polling: {ex.GetType().Name}: {ex.Message}"); - } - catch (Exception ex) - { - _output.WriteLine($"⚠️ Different exception during polling: {ex.GetType().Name}: {ex.Message}"); - } - - // === PHASE 4: Recreate directory === - _output.WriteLine("Phase 4: Recreating directory - polling should detect and restart FileSystemWatcher"); - Directory.CreateDirectory(featureDir); - System.IO.File.WriteAllText(configFile, "{ \"version\": 3, \"enabled\": true, \"recovered\": true }"); - - await ActiveWaitHelpers.WaitUntilAsync( - () => { - try { - var cfg = provider.FetchConfigurationBytesAsync(query).GetAwaiter().GetResult(); - var el = cfg.ToJsonElement(); - return GetRecovered(el); - } catch { return false; } - }, - timeout: TimeSpan.FromSeconds(15), - description: "recovery after directory recreation"); - - // Test FetchConfigurationAsync after recovery - try - { - var recoveredConfig = await provider.FetchConfigurationBytesAsync(query); - _output.WriteLine($"✅ Recovery config fetch: version={GetVersion(recoveredConfig.ToJsonElement())}, recovered={GetRecovered(recoveredConfig.ToJsonElement())}"); - } - catch (Exception ex) - { - _output.WriteLine($"❌ Failed to fetch config after recovery: {ex.GetType().Name}: {ex.Message}"); - } - - // === PHASE 5: Test recovered FileSystemWatcher === - _output.WriteLine("Phase 5: Testing recovered FileSystemWatcher with file modification"); - System.IO.File.WriteAllText(configFile, "{ \"version\": 4, \"enabled\": false, \"test\": \"final\" }"); - - await ActiveWaitHelpers.WaitUntilAsync( - () => emissions.Count > emissionsAfterModify, - timeout: TimeSpan.FromSeconds(5), - description: "post-recovery modification detected"); - - var finalEmissions = emissions.Count; - - subscription.Dispose(); - - // === RESULTS === - _output.WriteLine("=== RESILIENT PROVIDER RESULTS ==="); - _output.WriteLine($"Emissions after initial modify: {emissionsAfterModify}"); - _output.WriteLine($"Final emissions: {finalEmissions}"); - _output.WriteLine($"Total errors: {errors.Count}"); - - var hasInitialDetection = emissionsAfterModify > 0; - var hasRecoveryDetection = finalEmissions > emissionsAfterModify; - - if (hasInitialDetection && hasRecoveryDetection) - { - _output.WriteLine("✅ RESILIENT PROVIDER SUCCESS!"); - _output.WriteLine(" - FileSystemWatcher detected initial changes"); - _output.WriteLine(" - System survived directory deletion"); - _output.WriteLine(" - FileSystemWatcher recovered after directory recreation"); - } - else - { - _output.WriteLine("⚠️ Resilient provider partial success:"); - _output.WriteLine($" - Initial detection: {hasInitialDetection}"); - _output.WriteLine($" - Recovery detection: {hasRecoveryDetection}"); - } - } - - /// - /// Test Required vs Optional behavior during polling mode - /// - [Fact] - [Trait("Type", "Behavior")] - [Trait("Provider", "FileSourceProvider")] - [Trait("Category", "RequiredVsOptional")] - public async Task PollingMode_FetchConfiguration_ShouldHonorRequiredVsOptional() - { - using var tempDir = TempDirectoryHelper.Create(); - - var nonExistentDir = Path.Combine(tempDir.Path, "does-not-exist"); - var query = new FileSourceProviderQueryOptions("config.json"); - - _output.WriteLine("TESTING REQUIRED VS OPTIONAL DURING POLLING:"); - _output.WriteLine("When directory doesn't exist, FetchConfigurationAsync should throw"); - _output.WriteLine("This allows ConfigManager to distinguish Required vs Optional rules"); - - var options = new FileSourceProviderOptions(nonExistentDir); - using var provider = new FileSourceProvider(options); - - // Allow brief moment for provider initialization - await Task.Delay(10); - - // Test FetchConfigurationAsync behavior - try - { - var config = await provider.FetchConfigurationBytesAsync(query); - _output.WriteLine("❌ PROBLEM: FetchConfigurationAsync should have thrown for non-existent directory"); - } - catch (DirectoryNotFoundException ex) - { - _output.WriteLine($"✅ CORRECT: DirectoryNotFoundException thrown - {ex.Message}"); - _output.WriteLine("This allows ConfigManager to handle Required vs Optional rules properly"); - } - catch (Exception ex) - { - _output.WriteLine($"⚠️ Unexpected exception: {ex.GetType().Name}: {ex.Message}"); - } - - _output.WriteLine("\nEXPECTED BEHAVIOR:"); - _output.WriteLine("- Required rules: ConfigManager catches exception and fails application startup"); - _output.WriteLine("- Optional rules: ConfigManager catches exception and uses default/empty configuration"); - _output.WriteLine("- During polling: Provider periodically checks for directory creation"); - _output.WriteLine("- On directory creation: Provider switches back to FileSystemWatcher mode"); - } - - private static int GetVersion(JsonElement element) => element.TryGetProperty("version", out var versionProp) ? versionProp.GetInt32() : -1; - - private static bool GetEnabled(JsonElement element) => element.TryGetProperty("enabled", out var enabledProp) && enabledProp.GetBoolean(); - - private static bool GetRecovered(JsonElement element) => element.TryGetProperty("recovered", out var recoveredProp) && recoveredProp.GetBoolean(); -} +using System.Text.Json; +using Cocoar.Configuration.Providers.Tests.Helpers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Xunit; +using Xunit.Abstractions; + +namespace Cocoar.Configuration.Providers.Tests.File; + +public class ResilientFileSourceProviderTests +{ + private readonly ITestOutputHelper _output; + + public ResilientFileSourceProviderTests(ITestOutputHelper output) + { + _output = output; + } + + /// + /// Test the complete resilient cycle: FsWatcher → Error → Polling → Recovery + /// + [Fact] + [Trait("Type", "Integration")] + [Trait("Provider", "FileSourceProvider")] + [Trait("Category", "ResilientImplementation")] + public async Task ResilientFileSourceProvider_DirectoryDeletionAndRecreation_ShouldRecover() + { + using var tempDir = TempDirectoryHelper.Create(); + + var featureDir = Path.Combine(tempDir.Path, "features"); + var configFile = Path.Combine(featureDir, "config.json"); + + _output.WriteLine("TESTING RESILIENT FILESOURCEPROVIDER:"); + _output.WriteLine("1. Start with existing directory (FileSystemWatcher mode)"); + _output.WriteLine("2. Delete directory (triggers Error event → polling mode)"); + _output.WriteLine("3. Recreate directory (polling detects → FileSystemWatcher restarts)"); + _output.WriteLine("4. Verify configuration access works throughout"); + + // === PHASE 1: Directory exists === + Directory.CreateDirectory(featureDir); + System.IO.File.WriteAllText(configFile, "{ \"version\": 1, \"enabled\": true }"); + + _output.WriteLine($"Phase 1: Created directory {featureDir}"); + + var options = new FileSourceProviderOptions(featureDir); + var query = new FileSourceProviderQueryOptions("config.json"); + using var provider = new FileSourceProvider(options); + + var emissions = new List(); + var errors = new List(); + + var subscription = provider.ChangesAsBytes(query).Subscribe( + onNext: emission => + { + emissions.Add(emission.ToJsonElement()); + _output.WriteLine($"📩 Change emission: version={GetVersion(emission.ToJsonElement())}, enabled={GetEnabled(emission.ToJsonElement())}"); + }, + onError: ex => + { + errors.Add(ex); + _output.WriteLine($"❌ Change stream error: {ex.GetType().Name}: {ex.Message}"); + } + ); + + await Task.Delay(200); // Let FileSystemWatcher initialize + + // Test initial FetchConfigurationAsync + var initialConfig = await provider.FetchConfigurationBytesAsync(query); + _output.WriteLine($"✅ Initial config fetch: version={GetVersion(initialConfig.ToJsonElement())}"); + + // === PHASE 2: Modify file (FileSystemWatcher should detect) === + _output.WriteLine("Phase 2: Modifying file to test FileSystemWatcher"); + System.IO.File.WriteAllText(configFile, "{ \"version\": 2, \"enabled\": false }"); + + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count > 0, + timeout: TimeSpan.FromSeconds(3), + description: "detect initial modification"); + var emissionsAfterModify = emissions.Count; + + // === PHASE 3: Delete directory === + _output.WriteLine("Phase 3: Deleting directory - should trigger Error event and polling fallback"); + Directory.Delete(featureDir, recursive: true); + + await ActiveWaitHelpers.WaitUntilAsync( + () => { + try { + provider.FetchConfigurationBytesAsync(query).GetAwaiter().GetResult(); + return false; // still able to fetch + } catch (DirectoryNotFoundException) { return true; } catch { return false; } + }, + timeout: TimeSpan.FromSeconds(8), + description: "fetch failing with DirectoryNotFoundException after deletion"); + + // Test FetchConfigurationAsync during polling (should throw) + try + { + var configDuringPolling = await provider.FetchConfigurationBytesAsync(query); + _output.WriteLine("⚠️ Unexpected: FetchConfigurationAsync succeeded during polling"); + } + catch (DirectoryNotFoundException ex) + { + _output.WriteLine($"✅ Expected during polling: {ex.GetType().Name}: {ex.Message}"); + } + catch (Exception ex) + { + _output.WriteLine($"⚠️ Different exception during polling: {ex.GetType().Name}: {ex.Message}"); + } + + // === PHASE 4: Recreate directory === + _output.WriteLine("Phase 4: Recreating directory - polling should detect and restart FileSystemWatcher"); + Directory.CreateDirectory(featureDir); + System.IO.File.WriteAllText(configFile, "{ \"version\": 3, \"enabled\": true, \"recovered\": true }"); + + await ActiveWaitHelpers.WaitUntilAsync( + () => { + try { + var cfg = provider.FetchConfigurationBytesAsync(query).GetAwaiter().GetResult(); + var el = cfg.ToJsonElement(); + return GetRecovered(el); + } catch { return false; } + }, + timeout: TimeSpan.FromSeconds(15), + description: "recovery after directory recreation"); + + // Test FetchConfigurationAsync after recovery + try + { + var recoveredConfig = await provider.FetchConfigurationBytesAsync(query); + _output.WriteLine($"✅ Recovery config fetch: version={GetVersion(recoveredConfig.ToJsonElement())}, recovered={GetRecovered(recoveredConfig.ToJsonElement())}"); + } + catch (Exception ex) + { + _output.WriteLine($"❌ Failed to fetch config after recovery: {ex.GetType().Name}: {ex.Message}"); + } + + // === PHASE 5: Test recovered FileSystemWatcher === + _output.WriteLine("Phase 5: Testing recovered FileSystemWatcher with file modification"); + System.IO.File.WriteAllText(configFile, "{ \"version\": 4, \"enabled\": false, \"test\": \"final\" }"); + + await ActiveWaitHelpers.WaitUntilAsync( + () => emissions.Count > emissionsAfterModify, + timeout: TimeSpan.FromSeconds(5), + description: "post-recovery modification detected"); + + var finalEmissions = emissions.Count; + + subscription.Dispose(); + + // === RESULTS === + _output.WriteLine("=== RESILIENT PROVIDER RESULTS ==="); + _output.WriteLine($"Emissions after initial modify: {emissionsAfterModify}"); + _output.WriteLine($"Final emissions: {finalEmissions}"); + _output.WriteLine($"Total errors: {errors.Count}"); + + var hasInitialDetection = emissionsAfterModify > 0; + var hasRecoveryDetection = finalEmissions > emissionsAfterModify; + + if (hasInitialDetection && hasRecoveryDetection) + { + _output.WriteLine("✅ RESILIENT PROVIDER SUCCESS!"); + _output.WriteLine(" - FileSystemWatcher detected initial changes"); + _output.WriteLine(" - System survived directory deletion"); + _output.WriteLine(" - FileSystemWatcher recovered after directory recreation"); + } + else + { + _output.WriteLine("⚠️ Resilient provider partial success:"); + _output.WriteLine($" - Initial detection: {hasInitialDetection}"); + _output.WriteLine($" - Recovery detection: {hasRecoveryDetection}"); + } + } + + /// + /// Test Required vs Optional behavior during polling mode + /// + [Fact] + [Trait("Type", "Behavior")] + [Trait("Provider", "FileSourceProvider")] + [Trait("Category", "RequiredVsOptional")] + public async Task PollingMode_FetchConfiguration_ShouldHonorRequiredVsOptional() + { + using var tempDir = TempDirectoryHelper.Create(); + + var nonExistentDir = Path.Combine(tempDir.Path, "does-not-exist"); + var query = new FileSourceProviderQueryOptions("config.json"); + + _output.WriteLine("TESTING REQUIRED VS OPTIONAL DURING POLLING:"); + _output.WriteLine("When directory doesn't exist, FetchConfigurationAsync should throw"); + _output.WriteLine("This allows ConfigManager to distinguish Required vs Optional rules"); + + var options = new FileSourceProviderOptions(nonExistentDir); + using var provider = new FileSourceProvider(options); + + // Allow brief moment for provider initialization + await Task.Delay(10); + + // Test FetchConfigurationAsync behavior + try + { + var config = await provider.FetchConfigurationBytesAsync(query); + _output.WriteLine("❌ PROBLEM: FetchConfigurationAsync should have thrown for non-existent directory"); + } + catch (DirectoryNotFoundException ex) + { + _output.WriteLine($"✅ CORRECT: DirectoryNotFoundException thrown - {ex.Message}"); + _output.WriteLine("This allows ConfigManager to handle Required vs Optional rules properly"); + } + catch (Exception ex) + { + _output.WriteLine($"⚠️ Unexpected exception: {ex.GetType().Name}: {ex.Message}"); + } + + _output.WriteLine("\nEXPECTED BEHAVIOR:"); + _output.WriteLine("- Required rules: ConfigManager catches exception and fails application startup"); + _output.WriteLine("- Optional rules: ConfigManager catches exception and uses default/empty configuration"); + _output.WriteLine("- During polling: Provider periodically checks for directory creation"); + _output.WriteLine("- On directory creation: Provider switches back to FileSystemWatcher mode"); + } + + private static int GetVersion(JsonElement element) => element.TryGetProperty("version", out var versionProp) ? versionProp.GetInt32() : -1; + + private static bool GetEnabled(JsonElement element) => element.TryGetProperty("enabled", out var enabledProp) && enabledProp.GetBoolean(); + + private static bool GetRecovered(JsonElement element) => element.TryGetProperty("recovered", out var recoveredProp) && recoveredProp.GetBoolean(); +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/Helpers/ByteTestHelpers.cs b/src/tests/Cocoar.Configuration.Providers.Tests/Helpers/ByteTestHelpers.cs index 88e832b..b01305e 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/Helpers/ByteTestHelpers.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/Helpers/ByteTestHelpers.cs @@ -1,48 +1,48 @@ -using System.Text.Json; - -namespace Cocoar.Configuration.Providers.Tests.Helpers; - -/// -/// Test helper to convert byte-based provider responses back to JsonElement for test assertions. -/// This is purely for testing – in production, the ConfigManager handles the conversion internally. -/// -internal static class ByteTestHelpers -{ - /// - /// Converts ReadOnlyMemory<byte> (UTF-8 JSON) to JsonElement for test assertions. - /// If parsing fails (e.g., due to comments or malformed JSON), returns an empty JSON object {{}}. - /// - public static JsonElement ToJsonElement(this ReadOnlyMemory bytes) - { - try - { - using var doc = JsonDocument.Parse(bytes); - return doc.RootElement.Clone(); - } - catch - { - // For tests that intentionally feed invalid JSON (comments, etc.), - // return an empty object instead of throwing to keep assertions simple. - using var empty = JsonDocument.Parse("{}"); - return empty.RootElement.Clone(); - } - } - - /// - /// Converts byte[] (UTF-8 JSON) to JsonElement for test assertions. - /// If parsing fails (e.g., due to comments or malformed JSON), returns an empty JSON object {{}}. - /// - public static JsonElement ToJsonElement(this byte[] bytes) - { - return ((ReadOnlyMemory)bytes).ToJsonElement(); - } - - /// - /// Converts Task<ReadOnlyMemory<byte>> to Task<JsonElement> for test assertions. - /// - public static async Task ToJsonElementAsync(this Task bytesTask) - { - var bytes = await bytesTask; - return bytes.ToJsonElement(); - } -} +using System.Text.Json; + +namespace Cocoar.Configuration.Providers.Tests.Helpers; + +/// +/// Test helper to convert byte-based provider responses back to JsonElement for test assertions. +/// This is purely for testing – in production, the ConfigManager handles the conversion internally. +/// +internal static class ByteTestHelpers +{ + /// + /// Converts ReadOnlyMemory<byte> (UTF-8 JSON) to JsonElement for test assertions. + /// If parsing fails (e.g., due to comments or malformed JSON), returns an empty JSON object {{}}. + /// + public static JsonElement ToJsonElement(this ReadOnlyMemory bytes) + { + try + { + using var doc = JsonDocument.Parse(bytes); + return doc.RootElement.Clone(); + } + catch + { + // For tests that intentionally feed invalid JSON (comments, etc.), + // return an empty object instead of throwing to keep assertions simple. + using var empty = JsonDocument.Parse("{}"); + return empty.RootElement.Clone(); + } + } + + /// + /// Converts byte[] (UTF-8 JSON) to JsonElement for test assertions. + /// If parsing fails (e.g., due to comments or malformed JSON), returns an empty JSON object {{}}. + /// + public static JsonElement ToJsonElement(this byte[] bytes) + { + return ((ReadOnlyMemory)bytes).ToJsonElement(); + } + + /// + /// Converts Task<ReadOnlyMemory<byte>> to Task<JsonElement> for test assertions. + /// + public static async Task ToJsonElementAsync(this Task bytesTask) + { + var bytes = await bytesTask; + return bytes.ToJsonElement(); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/ActiveWaitHelpers.cs b/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/ActiveWaitHelpers.cs index 39adc88..3cdfb5a 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/ActiveWaitHelpers.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/ActiveWaitHelpers.cs @@ -1,35 +1,35 @@ -namespace Cocoar.Configuration.Providers.Tests.TestUtilities; - -public static class ActiveWaitHelpers -{ - public static async Task WaitUntilAsync( - Func condition, - TimeSpan timeout = default, - TimeSpan pollInterval = default, - string description = "condition") - { - timeout = timeout == default ? TimeSpan.FromSeconds(2) : timeout; - pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; - - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - - while (stopwatch.Elapsed < timeout) - { - try - { - if (condition()) - { - return; - } - } - catch - { - // Condition threw (e.g., accessing property on incomplete JSON) - treat as "not yet met" - } - - await Task.Delay(pollInterval); - } - - throw new TimeoutException($"Timeout waiting for {description} after {timeout}"); - } -} +namespace Cocoar.Configuration.Providers.Tests.TestUtilities; + +public static class ActiveWaitHelpers +{ + public static async Task WaitUntilAsync( + Func condition, + TimeSpan timeout = default, + TimeSpan pollInterval = default, + string description = "condition") + { + timeout = timeout == default ? TimeSpan.FromSeconds(2) : timeout; + pollInterval = pollInterval == default ? TimeSpan.FromMilliseconds(50) : pollInterval; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + try + { + if (condition()) + { + return; + } + } + catch + { + // Condition threw (e.g., accessing property on incomplete JSON) - treat as "not yet met" + } + + await Task.Delay(pollInterval); + } + + throw new TimeoutException($"Timeout waiting for {description} after {timeout}"); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/EnvScope.cs b/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/EnvScope.cs index 8dd9422..a3f2587 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/EnvScope.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/EnvScope.cs @@ -1,43 +1,43 @@ -namespace Cocoar.Configuration.Providers.Tests.TestUtilities; - -/// -/// Utility to isolate environment variable mutations within a using scope. -/// Restores previous value (or unsets) on dispose. -/// Deterministic: only touches Process-level environment. -/// -public sealed class EnvScope : IDisposable -{ - private readonly string _name; - private readonly string? _original; - private readonly bool _existed; - - private EnvScope(string name, string? newValue, bool set) - { - _name = name; - _original = System.Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.Process); - _existed = _original != null; - if (set) - { - System.Environment.SetEnvironmentVariable(name, newValue, EnvironmentVariableTarget.Process); - } - else - { - System.Environment.SetEnvironmentVariable(name, null, EnvironmentVariableTarget.Process); - } - } - - public static EnvScope Set(string name, string value) => new(name, value, set: true); - public static EnvScope Unset(string name) => new(name, null, set: false); - - public void Dispose() - { - if (_existed) - { - System.Environment.SetEnvironmentVariable(_name, _original, EnvironmentVariableTarget.Process); - } - else - { - System.Environment.SetEnvironmentVariable(_name, null, EnvironmentVariableTarget.Process); - } - } -} +namespace Cocoar.Configuration.Providers.Tests.TestUtilities; + +/// +/// Utility to isolate environment variable mutations within a using scope. +/// Restores previous value (or unsets) on dispose. +/// Deterministic: only touches Process-level environment. +/// +public sealed class EnvScope : IDisposable +{ + private readonly string _name; + private readonly string? _original; + private readonly bool _existed; + + private EnvScope(string name, string? newValue, bool set) + { + _name = name; + _original = System.Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.Process); + _existed = _original != null; + if (set) + { + System.Environment.SetEnvironmentVariable(name, newValue, EnvironmentVariableTarget.Process); + } + else + { + System.Environment.SetEnvironmentVariable(name, null, EnvironmentVariableTarget.Process); + } + } + + public static EnvScope Set(string name, string value) => new(name, value, set: true); + public static EnvScope Unset(string name) => new(name, null, set: false); + + public void Dispose() + { + if (_existed) + { + System.Environment.SetEnvironmentVariable(_name, _original, EnvironmentVariableTarget.Process); + } + else + { + System.Environment.SetEnvironmentVariable(_name, null, EnvironmentVariableTarget.Process); + } + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/TempDirectoryHelper.cs b/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/TempDirectoryHelper.cs index 1a72b7a..c66299c 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/TempDirectoryHelper.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/TempDirectoryHelper.cs @@ -1,60 +1,60 @@ -namespace Cocoar.Configuration.Providers.Tests.TestUtilities; - -/// -/// Utility for creating temporary directories with automatic cleanup. -/// Ensures deterministic directory operations for stress tests. -/// -public sealed class TempDirectoryHelper : IDisposable -{ - public string Path { get; } - - private TempDirectoryHelper(string path) - { - Path = path; - } - - /// - /// Create a temporary directory in the system temp location - /// - public static TempDirectoryHelper Create() - { - var tempPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), - "cocoar_test_dir_" + Guid.NewGuid().ToString("N")); - - Directory.CreateDirectory(tempPath); - return new(tempPath); - } - - /// - /// Create a temporary subdirectory in the specified parent - /// - public TempDirectoryHelper CreateSubdirectory(string name) - { - var subPath = System.IO.Path.Combine(Path, name); - Directory.CreateDirectory(subPath); - return new(subPath); - } - - /// - /// Delete the directory and all contents - /// - public void Delete() - { - if (Directory.Exists(Path)) - { - try - { - Directory.Delete(Path, recursive: true); - } - catch (IOException) - { - // Best effort cleanup - sometimes files are still locked - } - } - } - - public void Dispose() - { - Delete(); - } -} +namespace Cocoar.Configuration.Providers.Tests.TestUtilities; + +/// +/// Utility for creating temporary directories with automatic cleanup. +/// Ensures deterministic directory operations for stress tests. +/// +public sealed class TempDirectoryHelper : IDisposable +{ + public string Path { get; } + + private TempDirectoryHelper(string path) + { + Path = path; + } + + /// + /// Create a temporary directory in the system temp location + /// + public static TempDirectoryHelper Create() + { + var tempPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), + "cocoar_test_dir_" + Guid.NewGuid().ToString("N")); + + Directory.CreateDirectory(tempPath); + return new(tempPath); + } + + /// + /// Create a temporary subdirectory in the specified parent + /// + public TempDirectoryHelper CreateSubdirectory(string name) + { + var subPath = System.IO.Path.Combine(Path, name); + Directory.CreateDirectory(subPath); + return new(subPath); + } + + /// + /// Delete the directory and all contents + /// + public void Delete() + { + if (Directory.Exists(Path)) + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + // Best effort cleanup - sometimes files are still locked + } + } + } + + public void Dispose() + { + Delete(); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/TempFileHelper.cs b/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/TempFileHelper.cs index 1ceb1d1..5558f7c 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/TempFileHelper.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/TestUtilities/TempFileHelper.cs @@ -1,114 +1,114 @@ -using System.Text; - -namespace Cocoar.Configuration.Providers.Tests.TestUtilities; - -/// -/// Utility for creating temporary files with automatic cleanup. -/// Ensures deterministic file operations and proper disposal. -/// -public sealed class TempFileHelper : IDisposable -{ - public string FilePath { get; } - public string Directory { get; } - - private TempFileHelper(string directory, string fileName, string? initialContent) - { - Directory = directory; - FilePath = Path.Combine(directory, fileName); - - if (initialContent != null) - { - WriteContent(initialContent); - } - } - - /// - /// Create temp file in system temp directory with random name - /// - public static TempFileHelper Create(string? initialContent = null, string extension = ".json") - { - var fileName = "cocoar_test_" + Guid.NewGuid().ToString("N") + extension; - var tempDir = Path.GetTempPath(); - return new(tempDir, fileName, initialContent); - } - - /// - /// Create temp file in specific directory - /// - public static TempFileHelper CreateInDirectory(string directory, string fileName, string? initialContent = null) - { - System.IO.Directory.CreateDirectory(directory); - return new(directory, fileName, initialContent); - } - - /// - /// Write content to the file with proper sharing for concurrent access - /// - public void WriteContent(string content) - { - // Use FileShare.ReadWrite to match FileSourceProvider behavior - using var stream = new FileStream(FilePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite); - using var writer = new StreamWriter(stream, Encoding.UTF8); - writer.Write(content); - } - - /// - /// Write JSON object as string - /// - public void WriteJson(object obj) - { - var json = System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); - WriteContent(json); - } - - /// - /// Read current file content - /// - public string ReadContent() - { - using var stream = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var reader = new StreamReader(stream); - return reader.ReadToEnd(); - } - - /// - /// Delete the file if it exists - /// - public void Delete() - { - if (!System.IO.File.Exists(FilePath)) - { - return; - } - - // High-frequency file change stress tests can attempt deletion while a watcher/read handle - // is still being torn down. We retry briefly to avoid transient Windows sharing violations - // instead of marking the test flaky. This targets test reliability; production code should - // still fail fast on persistent locking. - const int maxAttempts = 25; // ~500ms worst-case - for (var attempt = 1; attempt <= maxAttempts; attempt++) - { - try - { - System.IO.File.Delete(FilePath); - return; // success - } - catch (IOException) when (attempt < maxAttempts) - { - Thread.Sleep(20); - } - catch (UnauthorizedAccessException) when (attempt < maxAttempts) - { - Thread.Sleep(20); - } - } - - // Final attempt – allow original exception to surface for diagnosis - System.IO.File.Delete(FilePath); - } - - public void Dispose() - { - Delete(); - } -} +using System.Text; + +namespace Cocoar.Configuration.Providers.Tests.TestUtilities; + +/// +/// Utility for creating temporary files with automatic cleanup. +/// Ensures deterministic file operations and proper disposal. +/// +public sealed class TempFileHelper : IDisposable +{ + public string FilePath { get; } + public string Directory { get; } + + private TempFileHelper(string directory, string fileName, string? initialContent) + { + Directory = directory; + FilePath = Path.Combine(directory, fileName); + + if (initialContent != null) + { + WriteContent(initialContent); + } + } + + /// + /// Create temp file in system temp directory with random name + /// + public static TempFileHelper Create(string? initialContent = null, string extension = ".json") + { + var fileName = "cocoar_test_" + Guid.NewGuid().ToString("N") + extension; + var tempDir = Path.GetTempPath(); + return new(tempDir, fileName, initialContent); + } + + /// + /// Create temp file in specific directory + /// + public static TempFileHelper CreateInDirectory(string directory, string fileName, string? initialContent = null) + { + System.IO.Directory.CreateDirectory(directory); + return new(directory, fileName, initialContent); + } + + /// + /// Write content to the file with proper sharing for concurrent access + /// + public void WriteContent(string content) + { + // Use FileShare.ReadWrite to match FileSourceProvider behavior + using var stream = new FileStream(FilePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite); + using var writer = new StreamWriter(stream, Encoding.UTF8); + writer.Write(content); + } + + /// + /// Write JSON object as string + /// + public void WriteJson(object obj) + { + var json = System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + WriteContent(json); + } + + /// + /// Read current file content + /// + public string ReadContent() + { + using var stream = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + /// + /// Delete the file if it exists + /// + public void Delete() + { + if (!System.IO.File.Exists(FilePath)) + { + return; + } + + // High-frequency file change stress tests can attempt deletion while a watcher/read handle + // is still being torn down. We retry briefly to avoid transient Windows sharing violations + // instead of marking the test flaky. This targets test reliability; production code should + // still fail fast on persistent locking. + const int maxAttempts = 25; // ~500ms worst-case + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + System.IO.File.Delete(FilePath); + return; // success + } + catch (IOException) when (attempt < maxAttempts) + { + Thread.Sleep(20); + } + catch (UnauthorizedAccessException) when (attempt < maxAttempts) + { + Thread.Sleep(20); + } + } + + // Final attempt – allow original exception to surface for diagnosis + System.IO.File.Delete(FilePath); + } + + public void Dispose() + { + Delete(); + } +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/AllowPlaintextTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/AllowPlaintextTests.cs index 35a623c..4b37b1f 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/AllowPlaintextTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/AllowPlaintextTests.cs @@ -1,678 +1,678 @@ -using System.Text.Json; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Secrets; -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace Cocoar.Configuration.Secrets.Tests; - -public record ConfigWithSecret -{ - public string? Name { get; init; } - public Secret? Password { get; init; } - public Secret? ApiKey { get; init; } -} - -/// -/// Configuration with nullable inner types: Secret<T?> -/// -public record ConfigWithNullableInnerSecrets -{ - public string? Name { get; init; } - public Secret? NullableStringSecret { get; init; } - public Secret? NullableIntSecret { get; init; } - public Secret? NullableBoolSecret { get; init; } -} - -/// -/// Configuration with non-nullable Secret containing nullable inner type. -/// The Secret itself is required, but the value inside can be null. -/// -public record ConfigWithRequiredNullableInnerSecrets -{ - public string? Name { get; init; } - public required Secret RequiredStringSecret { get; init; } - public required Secret RequiredIntSecret { get; init; } -} - -/// -/// Configuration with non-nullable Secret containing non-nullable inner type. -/// Neither the Secret nor the value inside can be null. -/// -public record ConfigWithNonNullableSecrets -{ - public string? Name { get; init; } - public required Secret RequiredStringSecret { get; init; } - public required Secret RequiredIntSecret { get; init; } -} - -/// -/// Tests for the AllowPlaintext() fluent API method. -/// -public class AllowPlaintextTests -{ - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void PlaintextSecret_WithoutAllowPlaintext_ThrowsOnOpen() - { - // Arrange - ConfigManager without AllowPlaintext (default behavior) - var json = """{"Name":"TestApp","Password":"secret123"}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets) // No AllowPlaintext - default security - ); - - // Act - var config = manager.GetConfig(); - - // Assert - deserialization succeeds, but Open() throws - Assert.NotNull(config); - Assert.NotNull(config!.Password); - var ex = Assert.Throws(() => { config.Password!.Open(); }); - Assert.Contains("plaintext JSON instead of an encrypted envelope", ex.Message); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void PlaintextSecret_WithAllowPlaintextTrue_DeserializesSuccessfully() - { - // Arrange - ConfigManager with AllowPlaintext(true) - var json = """{"Name":"TestApp","Password":"secret123","ApiKey":42}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - both deserialization and Open() succeed - Assert.NotNull(config); - Assert.Equal("TestApp", config!.Name); - - Assert.NotNull(config.Password); - using var passwordLease = config.Password!.Open(); - Assert.Equal("secret123", passwordLease.Value); - - Assert.NotNull(config.ApiKey); - using var apiKeyLease = config.ApiKey!.Open(); - Assert.Equal(42, apiKeyLease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void PlaintextSecret_WithAllowPlaintextFalse_StillBlocked() - { - // Arrange - Explicitly set AllowPlaintext(false) for self-documenting code - var json = """{"Name":"TestApp","Password":"secret123"}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext(false)) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - same as default: Open() throws - Assert.NotNull(config); - Assert.NotNull(config!.Password); - var ex = Assert.Throws(() => { config.Password!.Open(); }); - Assert.Contains("plaintext JSON instead of an encrypted envelope", ex.Message); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void AllowPlaintext_WorksWithNumericSecrets() - { - // Arrange - var json = """{"Name":"Test","ApiKey":12345}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - Assert.NotNull(config); - Assert.NotNull(config!.ApiKey); - using var lease = config.ApiKey!.Open(); - Assert.Equal(12345, lease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void AllowPlaintext_ChainedWithOtherMethods() - { - // Arrange - AllowPlaintext should work in a fluent chain - var json = """{"Name":"TestApp","Password":"secret123"}"""; - - // This test verifies fluent API chaining compiles and works - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext(true)) - ); - - var config = manager.GetConfig(); - - // Assert - Assert.NotNull(config); - Assert.NotNull(config!.Password); - using var lease = config.Password!.Open(); - Assert.Equal("secret123", lease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void AllowPlaintext_LastCallWins() - { - // Arrange - If called multiple times, last value wins - var json = """{"Name":"TestApp","Password":"secret123"}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets - .AllowPlaintext(false) // First: disable - .AllowPlaintext(true)) // Second: enable (wins) - ); - - var config = manager.GetConfig(); - - // Assert - The last call (true) should win - Assert.NotNull(config); - Assert.NotNull(config!.Password); - using var lease = config.Password!.Open(); - Assert.Equal("secret123", lease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void AllowPlaintext_DoesNotAffectEnvelopeSecrets() - { - // Arrange - A config type with a valid envelope structure - // The envelope format requires: type="cocoar.secret", version=1, kid= - var json = """ - { - "Name": "TestApp", - "Password": { - "type": "cocoar.secret", - "version": 1, - "kid": "test-key", - "alg": "RSA-OAEP-AES256-GCM", - "ct": "ZW5jcnlwdGVk", - "iv": "bm9uY2U=" - } - } - """; - - // Without a certificate, we can't actually decrypt, but we can verify: - // 1. The envelope is recognized as an envelope (not plaintext) - // 2. AllowPlaintext doesn't break envelope handling - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - var config = manager.GetConfig(); - - // The secret should be deserialized (it's an envelope) - Assert.NotNull(config); - Assert.NotNull(config!.Password); - - // Open() will fail because we don't have a certificate, but the error - // should be about decryption/resolver failure, not about plaintext being blocked - var ex = Record.Exception(() => { config.Password!.Open(); }); - Assert.NotNull(ex); - Assert.DoesNotContain("plaintext JSON", ex.Message); // Not a plaintext error - Assert.Contains("test-key", ex.Message); // Error is about the missing certificate - } - - #region Secret - Non-Nullable Inner Type Behavior with null JSON - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void JsonDeserialize_Int_FromNull_Throws() - { - // Verify baseline behavior: JsonSerializer.Deserialize("null") should throw - var ex = Assert.Throws(() => JsonSerializer.Deserialize("null")); - Assert.Contains("could not be converted", ex.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void NonNullableInnerSecret_ValueType_WithNullJson_DeserializationFails() - { - // Secret (non-nullable value type) with null JSON: - // The converter throws JsonException because int cannot be null. - // With Master Backplane architecture, deserialization failures at startup throw. - var json = """{"Name":"TestApp","RequiredStringSecret":"test","RequiredIntSecret":null}"""; - - // Deserialization fails at startup with Master Backplane architecture - var ex = Assert.Throws(() => ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - )); - Assert.Contains("Secret", ex.Failures[0].Message); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void NonNullableInnerSecret_ReferenceType_WithNullJson_SecretContainsNull() - { - // LIMITATION: Secret (non-nullable reference type) with null JSON: - // At runtime, we cannot distinguish between 'string' and 'string?' - they're the same type. - // C# nullable reference types are compile-time only annotations. - // Therefore, the converter creates a Secret containing null. - var json = """{"Name":"TestApp","RequiredStringSecret":null,"RequiredIntSecret":42}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.NotNull(config!.RequiredStringSecret); - - // The Secret exists but contains null - we can't enforce non-nullable reference types at runtime - using var lease = config.RequiredStringSecret.Open(); - Assert.Null(lease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void NonNullableInnerSecret_WithValidValues_Works() - { - // Arrange - Secret and Secret with valid values - var json = """{"Name":"TestApp","RequiredStringSecret":"password","RequiredIntSecret":42}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - Assert.NotNull(config); - using var stringLease = config!.RequiredStringSecret.Open(); - Assert.Equal("password", stringLease.Value); - using var intLease = config.RequiredIntSecret.Open(); - Assert.Equal(42, intLease.Value); - } - - #endregion - - #region Secret - Nullable Inner Type JSON Deserialization Tests - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void RequiredNullableInnerSecret_Int_WithNullValue_SecretContainsNull() - { - // Arrange - Secret (non-nullable container) should contain null, not BE null - // This is the key test: when someone declares `required Secret Port`, they want - // a Secret that contains null inside, NOT a null Secret. - var json = """{"Name":"TestApp","RequiredStringSecret":"test","RequiredIntSecret":null}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - The Secret should exist and contain null - Assert.NotNull(config); - Assert.NotNull(config!.RequiredIntSecret); // Secret itself is NOT null - using var lease = config.RequiredIntSecret.Open(); - Assert.Null(lease.Value); // But the value inside IS null - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void RequiredNullableInnerSecret_String_WithNullValue_SecretContainsNull() - { - // Arrange - Secret with null value - var json = """{"Name":"TestApp","RequiredStringSecret":null,"RequiredIntSecret":42}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - Assert.NotNull(config); - Assert.NotNull(config!.RequiredStringSecret); - using var lease = config.RequiredStringSecret.Open(); - Assert.Null(lease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void NullableInnerSecret_String_WithValue_DeserializesSuccessfully() - { - // Arrange - Secret with an actual value - var json = """{"Name":"TestApp","NullableStringSecret":"secret-value"}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - Assert.NotNull(config); - Assert.NotNull(config!.NullableStringSecret); - using var lease = config.NullableStringSecret!.Open(); - Assert.Equal("secret-value", lease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void NullableInnerSecret_String_WithNull_CreatesSecretContainingNull() - { - // Arrange - Secret? with explicit null JSON value - // When the inner type accepts null (string?), the converter creates a Secret containing null - // rather than returning null for the Secret itself. This ensures consistent behavior - // regardless of whether the property is declared as Secret? or Secret. - var json = """{"Name":"TestApp","NullableStringSecret":null}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - The Secret exists but contains null - Assert.NotNull(config); - Assert.NotNull(config!.NullableStringSecret); - using var lease = config.NullableStringSecret!.Open(); - Assert.Null(lease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void NullableInnerSecret_Int_WithValue_DeserializesSuccessfully() - { - // Arrange - Secret with an actual value - var json = """{"Name":"TestApp","NullableIntSecret":42}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - Assert.NotNull(config); - Assert.NotNull(config!.NullableIntSecret); - using var lease = config.NullableIntSecret!.Open(); - Assert.Equal(42, lease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void NullableInnerSecret_Int_WithNull_CreatesSecretContainingNull() - { - // Arrange - Secret? with explicit null JSON value - var json = """{"Name":"TestApp","NullableIntSecret":null}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - The Secret exists but contains null - Assert.NotNull(config); - Assert.NotNull(config!.NullableIntSecret); - using var lease = config.NullableIntSecret!.Open(); - Assert.Null(lease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void NullableInnerSecret_Bool_WithValue_DeserializesSuccessfully() - { - // Arrange - Secret with an actual value - var json = """{"Name":"TestApp","NullableBoolSecret":true}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - Assert.NotNull(config); - Assert.NotNull(config!.NullableBoolSecret); - using var lease = config.NullableBoolSecret!.Open(); - Assert.True(lease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void NullableInnerSecret_AllTypesWithValues_DeserializesSuccessfully() - { - // Arrange - All nullable inner secrets with values - var json = """{"Name":"TestApp","NullableStringSecret":"password","NullableIntSecret":123,"NullableBoolSecret":false}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - Assert.NotNull(config); - Assert.Equal("TestApp", config!.Name); - - Assert.NotNull(config.NullableStringSecret); - using var stringLease = config.NullableStringSecret!.Open(); - Assert.Equal("password", stringLease.Value); - - Assert.NotNull(config.NullableIntSecret); - using var intLease = config.NullableIntSecret!.Open(); - Assert.Equal(123, intLease.Value); - - Assert.NotNull(config.NullableBoolSecret); - using var boolLease = config.NullableBoolSecret!.Open(); - Assert.False(boolLease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void NullableInnerSecret_MissingFromJson_RemainsNull() - { - // Arrange - Secret properties not present in JSON - var json = """{"Name":"TestApp"}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - All Secret properties should be null (not set) - Assert.NotNull(config); - Assert.Equal("TestApp", config!.Name); - Assert.Null(config.NullableStringSecret); - Assert.Null(config.NullableIntSecret); - Assert.Null(config.NullableBoolSecret); - } - - #endregion - - #region Secret in Accessor (config-aware rules) - - public record SecretConfig - { - public Secret? AuthToken { get; init; } - } - - public record AppConfig - { - public string? Endpoint { get; init; } - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void Secret_InAccessor_CanBeOpenedByLaterRule() - { - // Arrange — Rule 1 loads a secret, Rule 2 reads it via accessor - var secretsJson = """{"AuthToken":"my-secret-token"}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(secretsJson).Required(), - rules.For().FromStatic(accessor => - { - var secrets = accessor.GetConfig()!; - using var lease = secrets.AuthToken!.Open(); - return new AppConfig { Endpoint = $"https://api.example.com?token={lease.Value}" }; - }) - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var appConfig = manager.GetConfig(); - - // Assert — The secret was successfully opened in the accessor factory - Assert.NotNull(appConfig); - Assert.Equal("https://api.example.com?token=my-secret-token", appConfig!.Endpoint); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void Secret_InAccessor_WithoutAllowPlaintext_ThrowsOnOpen() - { - // Arrange — Without AllowPlaintext, opening a plaintext secret in accessor should fail - var secretsJson = """{"AuthToken":"my-secret-token"}"""; - - // The InvalidOperationException from Secret.Open() propagates through the - // static rule factory, causing the recompute to fail - Assert.ThrowsAny(() => - ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(secretsJson).Required(), - rules.For().FromStatic(accessor => - { - var secrets = accessor.GetConfig()!; - using var lease = secrets.AuthToken!.Open(); // Throws — plaintext not allowed - return new AppConfig { Endpoint = lease.Value }; - }) - ]) - .UseSecretsSetup(secrets => secrets) // No AllowPlaintext - ) - ); - } - - #endregion -} +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Tests; + +public record ConfigWithSecret +{ + public string? Name { get; init; } + public Secret? Password { get; init; } + public Secret? ApiKey { get; init; } +} + +/// +/// Configuration with nullable inner types: Secret<T?> +/// +public record ConfigWithNullableInnerSecrets +{ + public string? Name { get; init; } + public Secret? NullableStringSecret { get; init; } + public Secret? NullableIntSecret { get; init; } + public Secret? NullableBoolSecret { get; init; } +} + +/// +/// Configuration with non-nullable Secret containing nullable inner type. +/// The Secret itself is required, but the value inside can be null. +/// +public record ConfigWithRequiredNullableInnerSecrets +{ + public string? Name { get; init; } + public required Secret RequiredStringSecret { get; init; } + public required Secret RequiredIntSecret { get; init; } +} + +/// +/// Configuration with non-nullable Secret containing non-nullable inner type. +/// Neither the Secret nor the value inside can be null. +/// +public record ConfigWithNonNullableSecrets +{ + public string? Name { get; init; } + public required Secret RequiredStringSecret { get; init; } + public required Secret RequiredIntSecret { get; init; } +} + +/// +/// Tests for the AllowPlaintext() fluent API method. +/// +public class AllowPlaintextTests +{ + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void PlaintextSecret_WithoutAllowPlaintext_ThrowsOnOpen() + { + // Arrange - ConfigManager without AllowPlaintext (default behavior) + var json = """{"Name":"TestApp","Password":"secret123"}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets) // No AllowPlaintext - default security + ); + + // Act + var config = manager.GetConfig(); + + // Assert - deserialization succeeds, but Open() throws + Assert.NotNull(config); + Assert.NotNull(config!.Password); + var ex = Assert.Throws(() => { config.Password!.Open(); }); + Assert.Contains("plaintext JSON instead of an encrypted envelope", ex.Message); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void PlaintextSecret_WithAllowPlaintextTrue_DeserializesSuccessfully() + { + // Arrange - ConfigManager with AllowPlaintext(true) + var json = """{"Name":"TestApp","Password":"secret123","ApiKey":42}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert - both deserialization and Open() succeed + Assert.NotNull(config); + Assert.Equal("TestApp", config!.Name); + + Assert.NotNull(config.Password); + using var passwordLease = config.Password!.Open(); + Assert.Equal("secret123", passwordLease.Value); + + Assert.NotNull(config.ApiKey); + using var apiKeyLease = config.ApiKey!.Open(); + Assert.Equal(42, apiKeyLease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void PlaintextSecret_WithAllowPlaintextFalse_StillBlocked() + { + // Arrange - Explicitly set AllowPlaintext(false) for self-documenting code + var json = """{"Name":"TestApp","Password":"secret123"}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext(false)) + ); + + // Act + var config = manager.GetConfig(); + + // Assert - same as default: Open() throws + Assert.NotNull(config); + Assert.NotNull(config!.Password); + var ex = Assert.Throws(() => { config.Password!.Open(); }); + Assert.Contains("plaintext JSON instead of an encrypted envelope", ex.Message); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void AllowPlaintext_WorksWithNumericSecrets() + { + // Arrange + var json = """{"Name":"Test","ApiKey":12345}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert + Assert.NotNull(config); + Assert.NotNull(config!.ApiKey); + using var lease = config.ApiKey!.Open(); + Assert.Equal(12345, lease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void AllowPlaintext_ChainedWithOtherMethods() + { + // Arrange - AllowPlaintext should work in a fluent chain + var json = """{"Name":"TestApp","Password":"secret123"}"""; + + // This test verifies fluent API chaining compiles and works + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext(true)) + ); + + var config = manager.GetConfig(); + + // Assert + Assert.NotNull(config); + Assert.NotNull(config!.Password); + using var lease = config.Password!.Open(); + Assert.Equal("secret123", lease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void AllowPlaintext_LastCallWins() + { + // Arrange - If called multiple times, last value wins + var json = """{"Name":"TestApp","Password":"secret123"}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets + .AllowPlaintext(false) // First: disable + .AllowPlaintext(true)) // Second: enable (wins) + ); + + var config = manager.GetConfig(); + + // Assert - The last call (true) should win + Assert.NotNull(config); + Assert.NotNull(config!.Password); + using var lease = config.Password!.Open(); + Assert.Equal("secret123", lease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void AllowPlaintext_DoesNotAffectEnvelopeSecrets() + { + // Arrange - A config type with a valid envelope structure + // The envelope format requires: type="cocoar.secret", version=1, kid= + var json = """ + { + "Name": "TestApp", + "Password": { + "type": "cocoar.secret", + "version": 1, + "kid": "test-key", + "alg": "RSA-OAEP-AES256-GCM", + "ct": "ZW5jcnlwdGVk", + "iv": "bm9uY2U=" + } + } + """; + + // Without a certificate, we can't actually decrypt, but we can verify: + // 1. The envelope is recognized as an envelope (not plaintext) + // 2. AllowPlaintext doesn't break envelope handling + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + var config = manager.GetConfig(); + + // The secret should be deserialized (it's an envelope) + Assert.NotNull(config); + Assert.NotNull(config!.Password); + + // Open() will fail because we don't have a certificate, but the error + // should be about decryption/resolver failure, not about plaintext being blocked + var ex = Record.Exception(() => { config.Password!.Open(); }); + Assert.NotNull(ex); + Assert.DoesNotContain("plaintext JSON", ex.Message); // Not a plaintext error + Assert.Contains("test-key", ex.Message); // Error is about the missing certificate + } + + #region Secret - Non-Nullable Inner Type Behavior with null JSON + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void JsonDeserialize_Int_FromNull_Throws() + { + // Verify baseline behavior: JsonSerializer.Deserialize("null") should throw + var ex = Assert.Throws(() => JsonSerializer.Deserialize("null")); + Assert.Contains("could not be converted", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void NonNullableInnerSecret_ValueType_WithNullJson_DeserializationFails() + { + // Secret (non-nullable value type) with null JSON: + // The converter throws JsonException because int cannot be null. + // With Master Backplane architecture, deserialization failures at startup throw. + var json = """{"Name":"TestApp","RequiredStringSecret":"test","RequiredIntSecret":null}"""; + + // Deserialization fails at startup with Master Backplane architecture + var ex = Assert.Throws(() => ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + )); + Assert.Contains("Secret", ex.Failures[0].Message); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void NonNullableInnerSecret_ReferenceType_WithNullJson_SecretContainsNull() + { + // LIMITATION: Secret (non-nullable reference type) with null JSON: + // At runtime, we cannot distinguish between 'string' and 'string?' - they're the same type. + // C# nullable reference types are compile-time only annotations. + // Therefore, the converter creates a Secret containing null. + var json = """{"Name":"TestApp","RequiredStringSecret":null,"RequiredIntSecret":42}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.NotNull(config!.RequiredStringSecret); + + // The Secret exists but contains null - we can't enforce non-nullable reference types at runtime + using var lease = config.RequiredStringSecret.Open(); + Assert.Null(lease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void NonNullableInnerSecret_WithValidValues_Works() + { + // Arrange - Secret and Secret with valid values + var json = """{"Name":"TestApp","RequiredStringSecret":"password","RequiredIntSecret":42}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert + Assert.NotNull(config); + using var stringLease = config!.RequiredStringSecret.Open(); + Assert.Equal("password", stringLease.Value); + using var intLease = config.RequiredIntSecret.Open(); + Assert.Equal(42, intLease.Value); + } + + #endregion + + #region Secret - Nullable Inner Type JSON Deserialization Tests + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void RequiredNullableInnerSecret_Int_WithNullValue_SecretContainsNull() + { + // Arrange - Secret (non-nullable container) should contain null, not BE null + // This is the key test: when someone declares `required Secret Port`, they want + // a Secret that contains null inside, NOT a null Secret. + var json = """{"Name":"TestApp","RequiredStringSecret":"test","RequiredIntSecret":null}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert - The Secret should exist and contain null + Assert.NotNull(config); + Assert.NotNull(config!.RequiredIntSecret); // Secret itself is NOT null + using var lease = config.RequiredIntSecret.Open(); + Assert.Null(lease.Value); // But the value inside IS null + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void RequiredNullableInnerSecret_String_WithNullValue_SecretContainsNull() + { + // Arrange - Secret with null value + var json = """{"Name":"TestApp","RequiredStringSecret":null,"RequiredIntSecret":42}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert + Assert.NotNull(config); + Assert.NotNull(config!.RequiredStringSecret); + using var lease = config.RequiredStringSecret.Open(); + Assert.Null(lease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void NullableInnerSecret_String_WithValue_DeserializesSuccessfully() + { + // Arrange - Secret with an actual value + var json = """{"Name":"TestApp","NullableStringSecret":"secret-value"}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert + Assert.NotNull(config); + Assert.NotNull(config!.NullableStringSecret); + using var lease = config.NullableStringSecret!.Open(); + Assert.Equal("secret-value", lease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void NullableInnerSecret_String_WithNull_CreatesSecretContainingNull() + { + // Arrange - Secret? with explicit null JSON value + // When the inner type accepts null (string?), the converter creates a Secret containing null + // rather than returning null for the Secret itself. This ensures consistent behavior + // regardless of whether the property is declared as Secret? or Secret. + var json = """{"Name":"TestApp","NullableStringSecret":null}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert - The Secret exists but contains null + Assert.NotNull(config); + Assert.NotNull(config!.NullableStringSecret); + using var lease = config.NullableStringSecret!.Open(); + Assert.Null(lease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void NullableInnerSecret_Int_WithValue_DeserializesSuccessfully() + { + // Arrange - Secret with an actual value + var json = """{"Name":"TestApp","NullableIntSecret":42}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert + Assert.NotNull(config); + Assert.NotNull(config!.NullableIntSecret); + using var lease = config.NullableIntSecret!.Open(); + Assert.Equal(42, lease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void NullableInnerSecret_Int_WithNull_CreatesSecretContainingNull() + { + // Arrange - Secret? with explicit null JSON value + var json = """{"Name":"TestApp","NullableIntSecret":null}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert - The Secret exists but contains null + Assert.NotNull(config); + Assert.NotNull(config!.NullableIntSecret); + using var lease = config.NullableIntSecret!.Open(); + Assert.Null(lease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void NullableInnerSecret_Bool_WithValue_DeserializesSuccessfully() + { + // Arrange - Secret with an actual value + var json = """{"Name":"TestApp","NullableBoolSecret":true}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert + Assert.NotNull(config); + Assert.NotNull(config!.NullableBoolSecret); + using var lease = config.NullableBoolSecret!.Open(); + Assert.True(lease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void NullableInnerSecret_AllTypesWithValues_DeserializesSuccessfully() + { + // Arrange - All nullable inner secrets with values + var json = """{"Name":"TestApp","NullableStringSecret":"password","NullableIntSecret":123,"NullableBoolSecret":false}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert + Assert.NotNull(config); + Assert.Equal("TestApp", config!.Name); + + Assert.NotNull(config.NullableStringSecret); + using var stringLease = config.NullableStringSecret!.Open(); + Assert.Equal("password", stringLease.Value); + + Assert.NotNull(config.NullableIntSecret); + using var intLease = config.NullableIntSecret!.Open(); + Assert.Equal(123, intLease.Value); + + Assert.NotNull(config.NullableBoolSecret); + using var boolLease = config.NullableBoolSecret!.Open(); + Assert.False(boolLease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void NullableInnerSecret_MissingFromJson_RemainsNull() + { + // Arrange - Secret properties not present in JSON + var json = """{"Name":"TestApp"}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert - All Secret properties should be null (not set) + Assert.NotNull(config); + Assert.Equal("TestApp", config!.Name); + Assert.Null(config.NullableStringSecret); + Assert.Null(config.NullableIntSecret); + Assert.Null(config.NullableBoolSecret); + } + + #endregion + + #region Secret in Accessor (config-aware rules) + + public record SecretConfig + { + public Secret? AuthToken { get; init; } + } + + public record AppConfig + { + public string? Endpoint { get; init; } + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void Secret_InAccessor_CanBeOpenedByLaterRule() + { + // Arrange — Rule 1 loads a secret, Rule 2 reads it via accessor + var secretsJson = """{"AuthToken":"my-secret-token"}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(secretsJson).Required(), + rules.For().FromStatic(accessor => + { + var secrets = accessor.GetConfig()!; + using var lease = secrets.AuthToken!.Open(); + return new AppConfig { Endpoint = $"https://api.example.com?token={lease.Value}" }; + }) + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var appConfig = manager.GetConfig(); + + // Assert — The secret was successfully opened in the accessor factory + Assert.NotNull(appConfig); + Assert.Equal("https://api.example.com?token=my-secret-token", appConfig!.Endpoint); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void Secret_InAccessor_WithoutAllowPlaintext_ThrowsOnOpen() + { + // Arrange — Without AllowPlaintext, opening a plaintext secret in accessor should fail + var secretsJson = """{"AuthToken":"my-secret-token"}"""; + + // The InvalidOperationException from Secret.Open() propagates through the + // static rule factory, causing the recompute to fail + Assert.ThrowsAny(() => + ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(secretsJson).Required(), + rules.For().FromStatic(accessor => + { + var secrets = accessor.GetConfig()!; + using var lease = secrets.AuthToken!.Open(); // Throws — plaintext not allowed + return new AppConfig { Endpoint = lease.Value }; + }) + ]) + .UseSecretsSetup(secrets => secrets) // No AllowPlaintext + ) + ); + } + + #endregion +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateExpirationTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateExpirationTests.cs index d7cd8b4..8a756ac 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateExpirationTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateExpirationTests.cs @@ -1,89 +1,89 @@ -using System.Security.Cryptography.X509Certificates; -using Cocoar.Configuration.X509Encryption; -using Cocoar.Configuration.Secrets.Helpers; - -namespace Cocoar.Configuration.Secrets.Tests; - -public class CertificateExpirationTests : IDisposable -{ - private readonly string _tempPath; - private readonly string _password = "Test123!"; - - public CertificateExpirationTests() - { - _tempPath = Path.Combine(Path.GetTempPath(), "cocoar-expiration-tests-" + Guid.NewGuid()); - Directory.CreateDirectory(_tempPath); - } - - public void Dispose() - { - if (Directory.Exists(_tempPath)) - { - Directory.Delete(_tempPath, recursive: true); - } - } - - [Fact] - public void LoadCertificate_ValidCert_LoadsSuccessfully() - { - // Generate certificate valid for 1 year (minimum) - var certPath = Path.Combine(_tempPath, "valid.pfx"); - X509CertificateGenerator.GenerateAndSave(certPath, _password, "CN=Valid", validYears: 1, keySize: 2048, overwrite: true); - - // Redirect console output to capture any messages - using var sw = new StringWriter(); - var originalOut = Console.Out; - Console.SetOut(sw); - - try - { - // Load certificate - should work without issues - using var cert = CertificateHelper.LoadFromFile(certPath, _password); - - // Verify certificate loaded successfully - Assert.NotNull(cert); - Assert.True(cert.HasPrivateKey); - Assert.Equal("CN=Valid", cert.Subject); - - // Certificate validation runs but 1-year cert won't trigger warning - var output = sw.ToString(); - // If cert expires within 30 days, there would be a warning - // For 1-year cert, no warning expected - } - finally - { - Console.SetOut(originalOut); - } - } - - [Fact] - public void LoadCertificate_NotExpiring_NoWarning() - { - // Generate certificate valid for 1 year - var certPath = Path.Combine(_tempPath, "valid.pfx"); - X509CertificateGenerator.GenerateAndSave(certPath, _password, "CN=Valid", validYears: 1, keySize: 2048, overwrite: true); - - // Redirect console output to verify no warning - using var sw = new StringWriter(); - var originalOut = Console.Out; - Console.SetOut(sw); - - try - { - // Load certificate - should NOT trigger warning - using var cert = CertificateHelper.LoadFromFile(certPath, _password); - - // Verify certificate loaded successfully - Assert.NotNull(cert); - Assert.True(cert.HasPrivateKey); - - // Verify no warning was written - var output = sw.ToString(); - Assert.DoesNotContain("WARNING", output); - } - finally - { - Console.SetOut(originalOut); - } - } -} +using System.Security.Cryptography.X509Certificates; +using Cocoar.Configuration.X509Encryption; +using Cocoar.Configuration.Secrets.Helpers; + +namespace Cocoar.Configuration.Secrets.Tests; + +public class CertificateExpirationTests : IDisposable +{ + private readonly string _tempPath; + private readonly string _password = "Test123!"; + + public CertificateExpirationTests() + { + _tempPath = Path.Combine(Path.GetTempPath(), "cocoar-expiration-tests-" + Guid.NewGuid()); + Directory.CreateDirectory(_tempPath); + } + + public void Dispose() + { + if (Directory.Exists(_tempPath)) + { + Directory.Delete(_tempPath, recursive: true); + } + } + + [Fact] + public void LoadCertificate_ValidCert_LoadsSuccessfully() + { + // Generate certificate valid for 1 year (minimum) + var certPath = Path.Combine(_tempPath, "valid.pfx"); + X509CertificateGenerator.GenerateAndSave(certPath, _password, "CN=Valid", validYears: 1, keySize: 2048, overwrite: true); + + // Redirect console output to capture any messages + using var sw = new StringWriter(); + var originalOut = Console.Out; + Console.SetOut(sw); + + try + { + // Load certificate - should work without issues + using var cert = CertificateHelper.LoadFromFile(certPath, _password); + + // Verify certificate loaded successfully + Assert.NotNull(cert); + Assert.True(cert.HasPrivateKey); + Assert.Equal("CN=Valid", cert.Subject); + + // Certificate validation runs but 1-year cert won't trigger warning + var output = sw.ToString(); + // If cert expires within 30 days, there would be a warning + // For 1-year cert, no warning expected + } + finally + { + Console.SetOut(originalOut); + } + } + + [Fact] + public void LoadCertificate_NotExpiring_NoWarning() + { + // Generate certificate valid for 1 year + var certPath = Path.Combine(_tempPath, "valid.pfx"); + X509CertificateGenerator.GenerateAndSave(certPath, _password, "CN=Valid", validYears: 1, keySize: 2048, overwrite: true); + + // Redirect console output to verify no warning + using var sw = new StringWriter(); + var originalOut = Console.Out; + Console.SetOut(sw); + + try + { + // Load certificate - should NOT trigger warning + using var cert = CertificateHelper.LoadFromFile(certPath, _password); + + // Verify certificate loaded successfully + Assert.NotNull(cert); + Assert.True(cert.HasPrivateKey); + + // Verify no warning was written + var output = sw.ToString(); + Assert.DoesNotContain("WARNING", output); + } + finally + { + Console.SetOut(originalOut); + } + } +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateFolderTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateFolderTests.cs index 26d7e3b..24ff4ee 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateFolderTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateFolderTests.cs @@ -1,297 +1,297 @@ -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Cocoar.Configuration.X509Encryption; -using Cocoar.Configuration.Secrets.Exceptions; -using Cocoar.Configuration.Secrets.Helpers; -using Cocoar.Configuration.Secrets.Protectors.Hybrid; -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace Cocoar.Configuration.Secrets.Tests; - -public class CertificateFolderTests : IDisposable -{ - private readonly string _tempBasePath; - private readonly string _password = "Test123!"; - - public CertificateFolderTests() - { - _tempBasePath = Path.Combine(Path.GetTempPath(), "cocoar-cert-tests-" + Guid.NewGuid()); - Directory.CreateDirectory(_tempBasePath); - } - - public void Dispose() - { - if (Directory.Exists(_tempBasePath)) - { - Directory.Delete(_tempBasePath, recursive: true); - } - } - - private X509Certificate2 GenerateTestCert(string path, string subject) - { - X509CertificateGenerator.GenerateAndSave(path, _password, subject, validYears: 1, keySize: 2048, overwrite: true); - return CertificateHelper.LoadFromFile(path, _password); - } - - [Fact] - public void KidSpecific_CertInKidFolder_CanDecrypt() - { - var kidFolder = Path.Combine(_tempBasePath, "pci"); - Directory.CreateDirectory(kidFolder); - var kidCertPath = Path.Combine(kidFolder, "kid-specific.pfx"); - - var kidCert = GenerateTestCert(kidCertPath, "CN=KidSpecific"); - - try - { - var globalInventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); - var protector = new X509HybridFolderSecretProtector(globalInventory); - - var envelope = CreateTestEnvelope(kidCert); - var plaintext = protector.Unprotect(envelope, "pci"); - - Assert.NotNull(plaintext); - Assert.Equal("test-secret", System.Text.Encoding.UTF8.GetString(plaintext)); - } - finally - { - kidCert.Dispose(); - } - } - - [Fact] - public void KidSpecific_MultipleKids_EachDecryptsWithOwnCert() - { - var kid1Folder = Path.Combine(_tempBasePath, "pci"); - var kid2Folder = Path.Combine(_tempBasePath, "hipaa"); - Directory.CreateDirectory(kid1Folder); - Directory.CreateDirectory(kid2Folder); - - var cert1Path = Path.Combine(kid1Folder, "cert1.pfx"); - var cert2Path = Path.Combine(kid2Folder, "cert2.pfx"); - - var cert1 = GenerateTestCert(cert1Path, "CN=PCI"); - var cert2 = GenerateTestCert(cert2Path, "CN=HIPAA"); - - try - { - var globalInventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); - var protector = new X509HybridFolderSecretProtector(globalInventory); - - var envelope1 = CreateTestEnvelope(cert1); - var plaintext1 = protector.Unprotect(envelope1, "pci"); - Assert.Equal("test-secret", System.Text.Encoding.UTF8.GetString(plaintext1)); - - var envelope2 = CreateTestEnvelope(cert2); - var plaintext2 = protector.Unprotect(envelope2, "hipaa"); - Assert.Equal("test-secret", System.Text.Encoding.UTF8.GetString(plaintext2)); - } - finally - { - cert1.Dispose(); - cert2.Dispose(); - } - } - - [Fact] - public void KidSpecific_WrongKidFolder_ThrowsException() - { - var kidFolder = Path.Combine(_tempBasePath, "pci"); - Directory.CreateDirectory(kidFolder); - var kidCertPath = Path.Combine(kidFolder, "cert.pfx"); - - var kidCert = GenerateTestCert(kidCertPath, "CN=PCI"); - - try - { - var globalInventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); - var protector = new X509HybridFolderSecretProtector(globalInventory); - - var envelope = CreateTestEnvelope(kidCert); - - // Try to decrypt with wrong kid - should fail with detailed error - var ex = Assert.Throws(() => protector.Unprotect(envelope, "hipaa")); - Assert.Contains("Failed to decrypt secret with kid 'hipaa'", ex.Message); - Assert.Contains("Possible causes", ex.Message); - } - finally - { - kidCert.Dispose(); - } - } - - [Fact] - public void KidSpecific_NoCertInKidFolder_ThrowsException() - { - var kidFolder = Path.Combine(_tempBasePath, "pci"); - Directory.CreateDirectory(kidFolder); - - var otherCertPath = Path.Combine(Path.GetTempPath(), "other-" + Guid.NewGuid() + ".pfx"); - var otherCert = GenerateTestCert(otherCertPath, "CN=Other"); - - try - { - var globalInventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); - var protector = new X509HybridFolderSecretProtector(globalInventory); - - var envelope = CreateTestEnvelope(otherCert); - - var ex = Assert.Throws(() => protector.Unprotect(envelope, "pci")); - Assert.Contains("Failed to decrypt secret with kid 'pci'", ex.Message); - Assert.Contains("Possible causes", ex.Message); - } - finally - { - otherCert.Dispose(); - if (File.Exists(otherCertPath)) - File.Delete(otherCertPath); - } - } - - [Fact] - public void MissingFolder_DoesNotThrow_OnInitialize() - { - var missingFolder = Path.Combine(Path.GetTempPath(), "cocoar-missing-" + Guid.NewGuid()); - // Intentionally NOT creating the folder - - // Should not throw when creating inventory for a missing folder - var inventory = new CertificateInventory(missingFolder, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); - var protector = new X509HybridFolderSecretProtector(inventory); - - // Protector creation should succeed - Assert.NotNull(protector); - - inventory.Dispose(); - } - - [Fact] - public void MissingFolder_ThrowsOnDecrypt() - { - var missingFolder = Path.Combine(Path.GetTempPath(), "cocoar-missing-" + Guid.NewGuid()); - // Intentionally NOT creating the folder - - // Create a valid envelope with a cert from elsewhere - var otherCertPath = Path.Combine(Path.GetTempPath(), "other-" + Guid.NewGuid() + ".pfx"); - var otherCert = GenerateTestCert(otherCertPath, "CN=Other"); - - try - { - var inventory = new CertificateInventory(missingFolder, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); - var protector = new X509HybridFolderSecretProtector(inventory); - - var envelope = CreateTestEnvelope(otherCert); - - // Should fail during decryption, not during setup - var ex = Assert.Throws(() => protector.Unprotect(envelope, "pci")); - Assert.Contains("Failed to decrypt secret with kid 'pci'", ex.Message); - - inventory.Dispose(); - } - finally - { - otherCert.Dispose(); - if (File.Exists(otherCertPath)) - File.Delete(otherCertPath); - } - } - - [Fact] - public async Task FolderRename_DetectsNewCertificates() - { - // Cocoar.FileSystem 2.2.0+ properly detects folder renames containing matching files. - // This test validates atomic folder swaps for certificate rotation (e.g., kid1 → kid2). - - var kid1Folder = Path.Combine(_tempBasePath, "kid1"); - Directory.CreateDirectory(kid1Folder); - var certPath = Path.Combine(kid1Folder, "cert.pfx"); - var cert = GenerateTestCert(certPath, "CN=Kid1"); - - try - { - var inventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); - var protector = new X509HybridFolderSecretProtector(inventory); - - var envelope = CreateTestEnvelope(cert); - - // Initial decrypt with kid1 should work - var plaintext1 = protector.Unprotect(envelope, "kid1"); - Assert.Equal("test-secret", System.Text.Encoding.UTF8.GetString(plaintext1)); - - // Atomically rename folder from kid1 to kid2 (simulating key rotation) - var kid2Folder = Path.Combine(_tempBasePath, "kid2"); - Directory.Move(kid1Folder, kid2Folder); - - // Wait for file watcher to detect the folder rename - // Use active polling to ensure the inventory has updated - var deadline = DateTime.UtcNow.AddSeconds(3); - var detected = false; - while (DateTime.UtcNow < deadline) - { - try - { - var plaintext = protector.Unprotect(envelope, "kid2"); - if (System.Text.Encoding.UTF8.GetString(plaintext) == "test-secret") - { - detected = true; - break; - } - } - catch - { - // Not yet detected, continue waiting - } - await Task.Delay(50); - } - - Assert.True(detected, "File watcher did not detect folder rename within 3 seconds"); - - // Verify kid2 works after folder rename - var plaintext2 = protector.Unprotect(envelope, "kid2"); - Assert.Equal("test-secret", System.Text.Encoding.UTF8.GetString(plaintext2)); - - // Verify kid1 no longer works (folder was renamed, not copied) - var ex = Assert.Throws(() => protector.Unprotect(envelope, "kid1")); - Assert.Contains("kid1", ex.Message); - Assert.Contains("No certificate in kid folder", ex.InnerException?.Message ?? ""); - - inventory.Dispose(); - } - finally - { - cert.Dispose(); - } - } - - private static HybridEnvelope CreateTestEnvelope(X509Certificate2 cert) - { - var plaintext = System.Text.Encoding.UTF8.GetBytes("test-secret"); - - Span dek = stackalloc byte[32]; - RandomNumberGenerator.Fill(dek); - - byte[] iv = new byte[12]; - RandomNumberGenerator.Fill(iv); - byte[] ct = new byte[plaintext.Length]; - byte[] tag = new byte[16]; - - using (var aes = new AesGcm(dek, tag.Length)) - { - aes.Encrypt(iv, plaintext, ct, tag, associatedData: null); - } - - using var rsa = cert.GetRSAPublicKey()!; - var wrappedKey = rsa.Encrypt(dek.ToArray(), RSAEncryptionPadding.OaepSHA256); - - CryptographicOperations.ZeroMemory(dek); - - return new HybridEnvelope - { - WrappedKey = wrappedKey, - WrappingAlgorithm = "RSA-OAEP-256", - Iv = iv, - Ciphertext = ct, - Tag = tag - }; - } -} - +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Cocoar.Configuration.X509Encryption; +using Cocoar.Configuration.Secrets.Exceptions; +using Cocoar.Configuration.Secrets.Helpers; +using Cocoar.Configuration.Secrets.Protectors.Hybrid; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Tests; + +public class CertificateFolderTests : IDisposable +{ + private readonly string _tempBasePath; + private readonly string _password = "Test123!"; + + public CertificateFolderTests() + { + _tempBasePath = Path.Combine(Path.GetTempPath(), "cocoar-cert-tests-" + Guid.NewGuid()); + Directory.CreateDirectory(_tempBasePath); + } + + public void Dispose() + { + if (Directory.Exists(_tempBasePath)) + { + Directory.Delete(_tempBasePath, recursive: true); + } + } + + private X509Certificate2 GenerateTestCert(string path, string subject) + { + X509CertificateGenerator.GenerateAndSave(path, _password, subject, validYears: 1, keySize: 2048, overwrite: true); + return CertificateHelper.LoadFromFile(path, _password); + } + + [Fact] + public void KidSpecific_CertInKidFolder_CanDecrypt() + { + var kidFolder = Path.Combine(_tempBasePath, "pci"); + Directory.CreateDirectory(kidFolder); + var kidCertPath = Path.Combine(kidFolder, "kid-specific.pfx"); + + var kidCert = GenerateTestCert(kidCertPath, "CN=KidSpecific"); + + try + { + var globalInventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); + var protector = new X509HybridFolderSecretProtector(globalInventory); + + var envelope = CreateTestEnvelope(kidCert); + var plaintext = protector.Unprotect(envelope, "pci"); + + Assert.NotNull(plaintext); + Assert.Equal("test-secret", System.Text.Encoding.UTF8.GetString(plaintext)); + } + finally + { + kidCert.Dispose(); + } + } + + [Fact] + public void KidSpecific_MultipleKids_EachDecryptsWithOwnCert() + { + var kid1Folder = Path.Combine(_tempBasePath, "pci"); + var kid2Folder = Path.Combine(_tempBasePath, "hipaa"); + Directory.CreateDirectory(kid1Folder); + Directory.CreateDirectory(kid2Folder); + + var cert1Path = Path.Combine(kid1Folder, "cert1.pfx"); + var cert2Path = Path.Combine(kid2Folder, "cert2.pfx"); + + var cert1 = GenerateTestCert(cert1Path, "CN=PCI"); + var cert2 = GenerateTestCert(cert2Path, "CN=HIPAA"); + + try + { + var globalInventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); + var protector = new X509HybridFolderSecretProtector(globalInventory); + + var envelope1 = CreateTestEnvelope(cert1); + var plaintext1 = protector.Unprotect(envelope1, "pci"); + Assert.Equal("test-secret", System.Text.Encoding.UTF8.GetString(plaintext1)); + + var envelope2 = CreateTestEnvelope(cert2); + var plaintext2 = protector.Unprotect(envelope2, "hipaa"); + Assert.Equal("test-secret", System.Text.Encoding.UTF8.GetString(plaintext2)); + } + finally + { + cert1.Dispose(); + cert2.Dispose(); + } + } + + [Fact] + public void KidSpecific_WrongKidFolder_ThrowsException() + { + var kidFolder = Path.Combine(_tempBasePath, "pci"); + Directory.CreateDirectory(kidFolder); + var kidCertPath = Path.Combine(kidFolder, "cert.pfx"); + + var kidCert = GenerateTestCert(kidCertPath, "CN=PCI"); + + try + { + var globalInventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); + var protector = new X509HybridFolderSecretProtector(globalInventory); + + var envelope = CreateTestEnvelope(kidCert); + + // Try to decrypt with wrong kid - should fail with detailed error + var ex = Assert.Throws(() => protector.Unprotect(envelope, "hipaa")); + Assert.Contains("Failed to decrypt secret with kid 'hipaa'", ex.Message); + Assert.Contains("Possible causes", ex.Message); + } + finally + { + kidCert.Dispose(); + } + } + + [Fact] + public void KidSpecific_NoCertInKidFolder_ThrowsException() + { + var kidFolder = Path.Combine(_tempBasePath, "pci"); + Directory.CreateDirectory(kidFolder); + + var otherCertPath = Path.Combine(Path.GetTempPath(), "other-" + Guid.NewGuid() + ".pfx"); + var otherCert = GenerateTestCert(otherCertPath, "CN=Other"); + + try + { + var globalInventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); + var protector = new X509HybridFolderSecretProtector(globalInventory); + + var envelope = CreateTestEnvelope(otherCert); + + var ex = Assert.Throws(() => protector.Unprotect(envelope, "pci")); + Assert.Contains("Failed to decrypt secret with kid 'pci'", ex.Message); + Assert.Contains("Possible causes", ex.Message); + } + finally + { + otherCert.Dispose(); + if (File.Exists(otherCertPath)) + File.Delete(otherCertPath); + } + } + + [Fact] + public void MissingFolder_DoesNotThrow_OnInitialize() + { + var missingFolder = Path.Combine(Path.GetTempPath(), "cocoar-missing-" + Guid.NewGuid()); + // Intentionally NOT creating the folder + + // Should not throw when creating inventory for a missing folder + var inventory = new CertificateInventory(missingFolder, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); + var protector = new X509HybridFolderSecretProtector(inventory); + + // Protector creation should succeed + Assert.NotNull(protector); + + inventory.Dispose(); + } + + [Fact] + public void MissingFolder_ThrowsOnDecrypt() + { + var missingFolder = Path.Combine(Path.GetTempPath(), "cocoar-missing-" + Guid.NewGuid()); + // Intentionally NOT creating the folder + + // Create a valid envelope with a cert from elsewhere + var otherCertPath = Path.Combine(Path.GetTempPath(), "other-" + Guid.NewGuid() + ".pfx"); + var otherCert = GenerateTestCert(otherCertPath, "CN=Other"); + + try + { + var inventory = new CertificateInventory(missingFolder, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); + var protector = new X509HybridFolderSecretProtector(inventory); + + var envelope = CreateTestEnvelope(otherCert); + + // Should fail during decryption, not during setup + var ex = Assert.Throws(() => protector.Unprotect(envelope, "pci")); + Assert.Contains("Failed to decrypt secret with kid 'pci'", ex.Message); + + inventory.Dispose(); + } + finally + { + otherCert.Dispose(); + if (File.Exists(otherCertPath)) + File.Delete(otherCertPath); + } + } + + [Fact] + public async Task FolderRename_DetectsNewCertificates() + { + // Cocoar.FileSystem 2.2.0+ properly detects folder renames containing matching files. + // This test validates atomic folder swaps for certificate rotation (e.g., kid1 → kid2). + + var kid1Folder = Path.Combine(_tempBasePath, "kid1"); + Directory.CreateDirectory(kid1Folder); + var certPath = Path.Combine(kid1Folder, "cert.pfx"); + var cert = GenerateTestCert(certPath, "CN=Kid1"); + + try + { + var inventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, includeSubdirectories: -1); + var protector = new X509HybridFolderSecretProtector(inventory); + + var envelope = CreateTestEnvelope(cert); + + // Initial decrypt with kid1 should work + var plaintext1 = protector.Unprotect(envelope, "kid1"); + Assert.Equal("test-secret", System.Text.Encoding.UTF8.GetString(plaintext1)); + + // Atomically rename folder from kid1 to kid2 (simulating key rotation) + var kid2Folder = Path.Combine(_tempBasePath, "kid2"); + Directory.Move(kid1Folder, kid2Folder); + + // Wait for file watcher to detect the folder rename + // Use active polling to ensure the inventory has updated + var deadline = DateTime.UtcNow.AddSeconds(3); + var detected = false; + while (DateTime.UtcNow < deadline) + { + try + { + var plaintext = protector.Unprotect(envelope, "kid2"); + if (System.Text.Encoding.UTF8.GetString(plaintext) == "test-secret") + { + detected = true; + break; + } + } + catch + { + // Not yet detected, continue waiting + } + await Task.Delay(50); + } + + Assert.True(detected, "File watcher did not detect folder rename within 3 seconds"); + + // Verify kid2 works after folder rename + var plaintext2 = protector.Unprotect(envelope, "kid2"); + Assert.Equal("test-secret", System.Text.Encoding.UTF8.GetString(plaintext2)); + + // Verify kid1 no longer works (folder was renamed, not copied) + var ex = Assert.Throws(() => protector.Unprotect(envelope, "kid1")); + Assert.Contains("kid1", ex.Message); + Assert.Contains("No certificate in kid folder", ex.InnerException?.Message ?? ""); + + inventory.Dispose(); + } + finally + { + cert.Dispose(); + } + } + + private static HybridEnvelope CreateTestEnvelope(X509Certificate2 cert) + { + var plaintext = System.Text.Encoding.UTF8.GetBytes("test-secret"); + + Span dek = stackalloc byte[32]; + RandomNumberGenerator.Fill(dek); + + byte[] iv = new byte[12]; + RandomNumberGenerator.Fill(iv); + byte[] ct = new byte[plaintext.Length]; + byte[] tag = new byte[16]; + + using (var aes = new AesGcm(dek, tag.Length)) + { + aes.Encrypt(iv, plaintext, ct, tag, associatedData: null); + } + + using var rsa = cert.GetRSAPublicKey()!; + var wrappedKey = rsa.Encrypt(dek.ToArray(), RSAEncryptionPadding.OaepSHA256); + + CryptographicOperations.ZeroMemory(dek); + + return new HybridEnvelope + { + WrappedKey = wrappedKey, + WrappingAlgorithm = "RSA-OAEP-256", + Iv = iv, + Ciphertext = ct, + Tag = tag + }; + } +} + diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateOrderingTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateOrderingTests.cs index 4c257f3..98dea5b 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateOrderingTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateOrderingTests.cs @@ -1,200 +1,200 @@ -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Cocoar.Configuration.X509Encryption; -using Cocoar.Configuration.Secrets.Helpers; -using Cocoar.Configuration.Secrets.Protectors.Hybrid; - -namespace Cocoar.Configuration.Secrets.Tests; - -public class CertificateOrderingTests : IDisposable -{ - private readonly string _tempBasePath; - private readonly string _password = "Test123!"; - - public CertificateOrderingTests() - { - _tempBasePath = Path.Combine(Path.GetTempPath(), "cocoar-cert-order-tests-" + Guid.NewGuid()); - Directory.CreateDirectory(_tempBasePath); - } - - public void Dispose() - { - if (Directory.Exists(_tempBasePath)) - { - Directory.Delete(_tempBasePath, recursive: true); - } - } - - [Fact] - public void DefaultOrdering_DescendingAlphabetical() - { - var cert01Path = Path.Combine(_tempBasePath, "01-old.pfx"); - var cert02Path = Path.Combine(_tempBasePath, "02-middle.pfx"); - var cert03Path = Path.Combine(_tempBasePath, "03-new.pfx"); - - var cert01 = X509CertificateGenerator.GenerateAndSave(cert01Path, _password, "CN=Cert01"); - var cert02 = X509CertificateGenerator.GenerateAndSave(cert02Path, _password, "CN=Cert02"); - var cert03 = X509CertificateGenerator.GenerateAndSave(cert03Path, _password, "CN=Cert03"); - - try - { - var inventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30); - - var envelope = CreateTestEnvelope(cert03, "newest-cert"); - var plaintext = inventory.TryDecrypt(envelope, out var result); - - Assert.True(plaintext); - Assert.Equal("newest-cert", System.Text.Encoding.UTF8.GetString(result)); - } - finally - { - cert01.Dispose(); - cert02.Dispose(); - cert03.Dispose(); - } - } - - [Fact] - public void CustomComparer_LastWriteTimeDescending() - { - var cert01Path = Path.Combine(_tempBasePath, "cert-a.pfx"); - var cert02Path = Path.Combine(_tempBasePath, "cert-b.pfx"); - var cert03Path = Path.Combine(_tempBasePath, "cert-c.pfx"); - - var cert01 = X509CertificateGenerator.GenerateAndSave(cert01Path, _password, "CN=Cert01"); - Thread.Sleep(100); - var cert02 = X509CertificateGenerator.GenerateAndSave(cert02Path, _password, "CN=Cert02"); - Thread.Sleep(100); - var cert03 = X509CertificateGenerator.GenerateAndSave(cert03Path, _password, "CN=Cert03"); - - try - { - var comparer = Comparer.Create((a, b) => - b.LastWriteTime.CompareTo(a.LastWriteTime)); - - var inventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, comparer); - - var envelope = CreateTestEnvelope(cert03, "newest-by-time"); - var plaintext = inventory.TryDecrypt(envelope, out var result); - - Assert.True(plaintext); - Assert.Equal("newest-by-time", System.Text.Encoding.UTF8.GetString(result)); - } - finally - { - cert01.Dispose(); - cert02.Dispose(); - cert03.Dispose(); - } - } - - [Fact] - public void CustomComparer_NumericSuffixDescending() - { - var cert01Path = Path.Combine(_tempBasePath, "cert.01.pfx"); - var cert02Path = Path.Combine(_tempBasePath, "cert.02.pfx"); - var cert03Path = Path.Combine(_tempBasePath, "cert.03.pfx"); - - var cert01 = X509CertificateGenerator.GenerateAndSave(cert01Path, _password, "CN=Cert01"); - var cert02 = X509CertificateGenerator.GenerateAndSave(cert02Path, _password, "CN=Cert02"); - var cert03 = X509CertificateGenerator.GenerateAndSave(cert03Path, _password, "CN=Cert03"); - - try - { - var comparer = Comparer.Create((a, b) => - { - var aNum = ExtractNumber(a.Name); - var bNum = ExtractNumber(b.Name); - return bNum.CompareTo(aNum); - }); - - var inventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, comparer); - - var envelope = CreateTestEnvelope(cert03, "suffix-03"); - var plaintext = inventory.TryDecrypt(envelope, out var result); - - Assert.True(plaintext); - Assert.Equal("suffix-03", System.Text.Encoding.UTF8.GetString(result)); - } - finally - { - cert01.Dispose(); - cert02.Dispose(); - cert03.Dispose(); - } - - static int ExtractNumber(string filename) - { - var nameWithoutExt = Path.GetFileNameWithoutExtension(filename); - var parts = nameWithoutExt.Split('.'); - if (parts.Length > 1 && int.TryParse(parts[^1], out var num)) - return num; - return 0; - } - } - - [Fact] - public void CustomComparer_ByFileSize_LargestFirst() - { - var smallCertPath = Path.Combine(_tempBasePath, "small.pfx"); - var mediumCertPath = Path.Combine(_tempBasePath, "medium.pfx"); - var largeCertPath = Path.Combine(_tempBasePath, "large.pfx"); - - var smallCert = X509CertificateGenerator.GenerateAndSave(smallCertPath, _password, "CN=Small", keySize: 2048); - var mediumCert = X509CertificateGenerator.GenerateAndSave(mediumCertPath, _password, "CN=Medium", keySize: 3072); - var largeCert = X509CertificateGenerator.GenerateAndSave(largeCertPath, _password, "CN=Large", keySize: 4096); - - try - { - var comparer = Comparer.Create((a, b) => - b.Length.CompareTo(a.Length)); - - var inventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, comparer); - - var envelope = CreateTestEnvelope(largeCert, "largest-cert"); - var plaintext = inventory.TryDecrypt(envelope, out var result); - - Assert.True(plaintext); - Assert.Equal("largest-cert", System.Text.Encoding.UTF8.GetString(result)); - } - finally - { - smallCert.Dispose(); - mediumCert.Dispose(); - largeCert.Dispose(); - } - } - - private static HybridEnvelope CreateTestEnvelope(X509Certificate2 cert, string? plaintext = null) - { - var certName = cert.Subject.Replace("CN=", "").ToLowerInvariant(); - var data = System.Text.Encoding.UTF8.GetBytes(plaintext ?? certName); - - Span dek = stackalloc byte[32]; - RandomNumberGenerator.Fill(dek); - - byte[] iv = new byte[12]; - RandomNumberGenerator.Fill(iv); - byte[] ct = new byte[data.Length]; - byte[] tag = new byte[16]; - - using (var aes = new AesGcm(dek, tag.Length)) - { - aes.Encrypt(iv, data, ct, tag, associatedData: null); - } - - using var rsa = cert.GetRSAPublicKey()!; - var wrappedKey = rsa.Encrypt(dek.ToArray(), RSAEncryptionPadding.OaepSHA256); - - CryptographicOperations.ZeroMemory(dek); - - return new HybridEnvelope - { - WrappedKey = wrappedKey, - WrappingAlgorithm = "RSA-OAEP-256", - Iv = iv, - Ciphertext = ct, - Tag = tag - }; - } -} +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Cocoar.Configuration.X509Encryption; +using Cocoar.Configuration.Secrets.Helpers; +using Cocoar.Configuration.Secrets.Protectors.Hybrid; + +namespace Cocoar.Configuration.Secrets.Tests; + +public class CertificateOrderingTests : IDisposable +{ + private readonly string _tempBasePath; + private readonly string _password = "Test123!"; + + public CertificateOrderingTests() + { + _tempBasePath = Path.Combine(Path.GetTempPath(), "cocoar-cert-order-tests-" + Guid.NewGuid()); + Directory.CreateDirectory(_tempBasePath); + } + + public void Dispose() + { + if (Directory.Exists(_tempBasePath)) + { + Directory.Delete(_tempBasePath, recursive: true); + } + } + + [Fact] + public void DefaultOrdering_DescendingAlphabetical() + { + var cert01Path = Path.Combine(_tempBasePath, "01-old.pfx"); + var cert02Path = Path.Combine(_tempBasePath, "02-middle.pfx"); + var cert03Path = Path.Combine(_tempBasePath, "03-new.pfx"); + + var cert01 = X509CertificateGenerator.GenerateAndSave(cert01Path, _password, "CN=Cert01"); + var cert02 = X509CertificateGenerator.GenerateAndSave(cert02Path, _password, "CN=Cert02"); + var cert03 = X509CertificateGenerator.GenerateAndSave(cert03Path, _password, "CN=Cert03"); + + try + { + var inventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30); + + var envelope = CreateTestEnvelope(cert03, "newest-cert"); + var plaintext = inventory.TryDecrypt(envelope, out var result); + + Assert.True(plaintext); + Assert.Equal("newest-cert", System.Text.Encoding.UTF8.GetString(result)); + } + finally + { + cert01.Dispose(); + cert02.Dispose(); + cert03.Dispose(); + } + } + + [Fact] + public void CustomComparer_LastWriteTimeDescending() + { + var cert01Path = Path.Combine(_tempBasePath, "cert-a.pfx"); + var cert02Path = Path.Combine(_tempBasePath, "cert-b.pfx"); + var cert03Path = Path.Combine(_tempBasePath, "cert-c.pfx"); + + var cert01 = X509CertificateGenerator.GenerateAndSave(cert01Path, _password, "CN=Cert01"); + Thread.Sleep(100); + var cert02 = X509CertificateGenerator.GenerateAndSave(cert02Path, _password, "CN=Cert02"); + Thread.Sleep(100); + var cert03 = X509CertificateGenerator.GenerateAndSave(cert03Path, _password, "CN=Cert03"); + + try + { + var comparer = Comparer.Create((a, b) => + b.LastWriteTime.CompareTo(a.LastWriteTime)); + + var inventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, comparer); + + var envelope = CreateTestEnvelope(cert03, "newest-by-time"); + var plaintext = inventory.TryDecrypt(envelope, out var result); + + Assert.True(plaintext); + Assert.Equal("newest-by-time", System.Text.Encoding.UTF8.GetString(result)); + } + finally + { + cert01.Dispose(); + cert02.Dispose(); + cert03.Dispose(); + } + } + + [Fact] + public void CustomComparer_NumericSuffixDescending() + { + var cert01Path = Path.Combine(_tempBasePath, "cert.01.pfx"); + var cert02Path = Path.Combine(_tempBasePath, "cert.02.pfx"); + var cert03Path = Path.Combine(_tempBasePath, "cert.03.pfx"); + + var cert01 = X509CertificateGenerator.GenerateAndSave(cert01Path, _password, "CN=Cert01"); + var cert02 = X509CertificateGenerator.GenerateAndSave(cert02Path, _password, "CN=Cert02"); + var cert03 = X509CertificateGenerator.GenerateAndSave(cert03Path, _password, "CN=Cert03"); + + try + { + var comparer = Comparer.Create((a, b) => + { + var aNum = ExtractNumber(a.Name); + var bNum = ExtractNumber(b.Name); + return bNum.CompareTo(aNum); + }); + + var inventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, comparer); + + var envelope = CreateTestEnvelope(cert03, "suffix-03"); + var plaintext = inventory.TryDecrypt(envelope, out var result); + + Assert.True(plaintext); + Assert.Equal("suffix-03", System.Text.Encoding.UTF8.GetString(result)); + } + finally + { + cert01.Dispose(); + cert02.Dispose(); + cert03.Dispose(); + } + + static int ExtractNumber(string filename) + { + var nameWithoutExt = Path.GetFileNameWithoutExtension(filename); + var parts = nameWithoutExt.Split('.'); + if (parts.Length > 1 && int.TryParse(parts[^1], out var num)) + return num; + return 0; + } + } + + [Fact] + public void CustomComparer_ByFileSize_LargestFirst() + { + var smallCertPath = Path.Combine(_tempBasePath, "small.pfx"); + var mediumCertPath = Path.Combine(_tempBasePath, "medium.pfx"); + var largeCertPath = Path.Combine(_tempBasePath, "large.pfx"); + + var smallCert = X509CertificateGenerator.GenerateAndSave(smallCertPath, _password, "CN=Small", keySize: 2048); + var mediumCert = X509CertificateGenerator.GenerateAndSave(mediumCertPath, _password, "CN=Medium", keySize: 3072); + var largeCert = X509CertificateGenerator.GenerateAndSave(largeCertPath, _password, "CN=Large", keySize: 4096); + + try + { + var comparer = Comparer.Create((a, b) => + b.Length.CompareTo(a.Length)); + + var inventory = new CertificateInventory(_tempBasePath, "*.pfx", null, null, _ => [_password], 30, comparer); + + var envelope = CreateTestEnvelope(largeCert, "largest-cert"); + var plaintext = inventory.TryDecrypt(envelope, out var result); + + Assert.True(plaintext); + Assert.Equal("largest-cert", System.Text.Encoding.UTF8.GetString(result)); + } + finally + { + smallCert.Dispose(); + mediumCert.Dispose(); + largeCert.Dispose(); + } + } + + private static HybridEnvelope CreateTestEnvelope(X509Certificate2 cert, string? plaintext = null) + { + var certName = cert.Subject.Replace("CN=", "").ToLowerInvariant(); + var data = System.Text.Encoding.UTF8.GetBytes(plaintext ?? certName); + + Span dek = stackalloc byte[32]; + RandomNumberGenerator.Fill(dek); + + byte[] iv = new byte[12]; + RandomNumberGenerator.Fill(iv); + byte[] ct = new byte[data.Length]; + byte[] tag = new byte[16]; + + using (var aes = new AesGcm(dek, tag.Length)) + { + aes.Encrypt(iv, data, ct, tag, associatedData: null); + } + + using var rsa = cert.GetRSAPublicKey()!; + var wrappedKey = rsa.Encrypt(dek.ToArray(), RSAEncryptionPadding.OaepSHA256); + + CryptographicOperations.ZeroMemory(dek); + + return new HybridEnvelope + { + WrappedKey = wrappedKey, + WrappingAlgorithm = "RSA-OAEP-256", + Iv = iv, + Ciphertext = ct, + Tag = tag + }; + } +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj b/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj index 53e9e38..f12c6c2 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj @@ -1,34 +1,34 @@ - - - - net9.0 - enable - enable - false - true - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - + + + + net9.0 + enable + enable + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/ComplexTypesTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/ComplexTypesTests.cs index 7015254..4ae1b27 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/ComplexTypesTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/ComplexTypesTests.cs @@ -1,118 +1,118 @@ -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace Cocoar.Configuration.Secrets.Tests; - -public record Address(string Street, string City, string Zip); -public record Person(string Name, int Age, Address Address); -public record Credentials(string Username, string Password, string? ApiKey = null); -public record Nested(Inner Inner); -public record Inner(Deep Deep); -public record Deep(string Value); - -public class ComplexTypesTests -{ - [Fact] - public void Secret_SimpleRecord_PlainValue() - { - var address = new Address("123 Main St", "Springfield", "12345"); - var secret = Secret
.FromPlain(address); - using var lease = secret.Open(); - Assert.Equal(address, lease.Value); - } - - [Fact] - public void Secret_NestedRecord_PlainValue() - { - var person = new Person( - "John Doe", - 30, - new Address("456 Oak Ave", "Portland", "97201") - ); - var secret = Secret.FromPlain(person); - using var lease = secret.Open(); - Assert.Equal(person, lease.Value); - } - - [Fact] - public void Secret_RecordWithNullable_AllFields() - { - var creds = new Credentials("admin", "secret123", "api-key-xyz"); - var secret = Secret.FromPlain(creds); - using var lease = secret.Open(); - Assert.Equal(creds, lease.Value); - } - - [Fact] - public void Secret_RecordWithNullable_NullField() - { - var creds = new Credentials("admin", "secret123", null); - var secret = Secret.FromPlain(creds); - using var lease = secret.Open(); - Assert.Equal(creds, lease.Value); - Assert.Null(lease.Value.ApiKey); - } - - [Fact] - public void Secret_DeeplyNested_PlainValue() - { - var nested = new Nested(new Inner(new Deep("deep value"))); - var secret = Secret.FromPlain(nested); - using var lease = secret.Open(); - Assert.Equal(nested, lease.Value); - Assert.Equal("deep value", lease.Value.Inner.Deep.Value); - } - - [Fact] - public void Secret_Array_PlainValue() - { - var array = new[] { "one", "two", "three" }; - var secret = Secret.FromPlain(array); - using var lease = secret.Open(); - Assert.Equal(array, lease.Value); - } - - [Fact] - public void Secret_List_PlainValue() - { - var list = new List { 1, 2, 3, 4, 5 }; - var secret = Secret>.FromPlain(list); - using var lease = secret.Open(); - Assert.Equal(list, lease.Value); - } - - [Fact] - public void Secret_Dictionary_PlainValue() - { - var dict = new Dictionary - { - ["one"] = 1, - ["two"] = 2, - ["three"] = 3 - }; - var secret = Secret>.FromPlain(dict); - using var lease = secret.Open(); - Assert.Equal(dict, lease.Value); - } - - [Fact] - public void Secret_ComplexList_PlainValue() - { - var addresses = new List
- { - new("123 Main", "City1", "11111"), - new("456 Oak", "City2", "22222") - }; - var secret = Secret>.FromPlain(addresses); - using var lease = secret.Open(); - Assert.Equal(addresses, lease.Value); - } - - [Fact] - public void Secret_EmptyCollection_PlainValue() - { - var empty = new List(); - var secret = Secret>.FromPlain(empty); - using var lease = secret.Open(); - Assert.Empty(lease.Value); - } -} +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Tests; + +public record Address(string Street, string City, string Zip); +public record Person(string Name, int Age, Address Address); +public record Credentials(string Username, string Password, string? ApiKey = null); +public record Nested(Inner Inner); +public record Inner(Deep Deep); +public record Deep(string Value); + +public class ComplexTypesTests +{ + [Fact] + public void Secret_SimpleRecord_PlainValue() + { + var address = new Address("123 Main St", "Springfield", "12345"); + var secret = Secret
.FromPlain(address); + using var lease = secret.Open(); + Assert.Equal(address, lease.Value); + } + + [Fact] + public void Secret_NestedRecord_PlainValue() + { + var person = new Person( + "John Doe", + 30, + new Address("456 Oak Ave", "Portland", "97201") + ); + var secret = Secret.FromPlain(person); + using var lease = secret.Open(); + Assert.Equal(person, lease.Value); + } + + [Fact] + public void Secret_RecordWithNullable_AllFields() + { + var creds = new Credentials("admin", "secret123", "api-key-xyz"); + var secret = Secret.FromPlain(creds); + using var lease = secret.Open(); + Assert.Equal(creds, lease.Value); + } + + [Fact] + public void Secret_RecordWithNullable_NullField() + { + var creds = new Credentials("admin", "secret123", null); + var secret = Secret.FromPlain(creds); + using var lease = secret.Open(); + Assert.Equal(creds, lease.Value); + Assert.Null(lease.Value.ApiKey); + } + + [Fact] + public void Secret_DeeplyNested_PlainValue() + { + var nested = new Nested(new Inner(new Deep("deep value"))); + var secret = Secret.FromPlain(nested); + using var lease = secret.Open(); + Assert.Equal(nested, lease.Value); + Assert.Equal("deep value", lease.Value.Inner.Deep.Value); + } + + [Fact] + public void Secret_Array_PlainValue() + { + var array = new[] { "one", "two", "three" }; + var secret = Secret.FromPlain(array); + using var lease = secret.Open(); + Assert.Equal(array, lease.Value); + } + + [Fact] + public void Secret_List_PlainValue() + { + var list = new List { 1, 2, 3, 4, 5 }; + var secret = Secret>.FromPlain(list); + using var lease = secret.Open(); + Assert.Equal(list, lease.Value); + } + + [Fact] + public void Secret_Dictionary_PlainValue() + { + var dict = new Dictionary + { + ["one"] = 1, + ["two"] = 2, + ["three"] = 3 + }; + var secret = Secret>.FromPlain(dict); + using var lease = secret.Open(); + Assert.Equal(dict, lease.Value); + } + + [Fact] + public void Secret_ComplexList_PlainValue() + { + var addresses = new List
+ { + new("123 Main", "City1", "11111"), + new("456 Oak", "City2", "22222") + }; + var secret = Secret>.FromPlain(addresses); + using var lease = secret.Open(); + Assert.Equal(addresses, lease.Value); + } + + [Fact] + public void Secret_EmptyCollection_PlainValue() + { + var empty = new List(); + var secret = Secret>.FromPlain(empty); + using var lease = secret.Open(); + Assert.Empty(lease.Value); + } +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/EdgeCasesTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/EdgeCasesTests.cs index 245e054..13831fc 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/EdgeCasesTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/EdgeCasesTests.cs @@ -1,278 +1,278 @@ -using System.Text.Json; -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace Cocoar.Configuration.Secrets.Tests; - -public record SerializableConfig -{ - public string? PublicField { get; init; } - public Secret? SecretField { get; init; } - public int Number { get; init; } -} - -public class EdgeCasesTests -{ - [Fact] - public void Secret_Serialization_NoCustomConverter() - { - // Documents current behavior: Secret serializes as empty object without custom converter - var config = new SerializableConfig - { - PublicField = "visible", - SecretField = Secret.FromPlain("hidden"), - Number = 42 - }; - - var json = JsonSerializer.Serialize(config); - - Assert.Contains("\"PublicField\":\"visible\"", json); - Assert.Contains("\"SecretField\":{}", json); // Serializes as empty object, not "***" - Assert.Contains("\"Number\":42", json); - Assert.DoesNotContain("hidden", json); // At least the secret value isn't leaked - } - - [Fact] - public void Secret_NullValue_Allowed() - { - // Documents current behavior: null values are accepted by Secret - var secret = Secret.FromPlain(null!); - using var lease = secret.Open(); - Assert.Null(lease.Value); - } - - [Fact] - public void Secret_EmptyString_Valid() - { - var secret = Secret.FromPlain(""); - using var lease = secret.Open(); - Assert.Equal("", lease.Value); - } - - [Fact] - public void Secret_VeryLongString_Valid() - { - var longString = new string('x', 100000); - var secret = Secret.FromPlain(longString); - using var lease = secret.Open(); - Assert.Equal(longString, lease.Value); - } - - [Fact] - public void Secret_Unicode_Valid() - { - var unicode = "Hello 世界 🌍 émojis"; - var secret = Secret.FromPlain(unicode); - using var lease = secret.Open(); - Assert.Equal(unicode, lease.Value); - } - - [Fact] - public void Secret_ByteArray_Empty() - { - var empty = Array.Empty(); - var secret = Secret.FromPlain(empty); - using var lease = secret.Open(); - Assert.Empty(lease.Value); - } - - [Fact] - public void Secret_ByteArray_LargeArray() - { - var large = new byte[10000]; - Random.Shared.NextBytes(large); - var secret = Secret.FromPlain(large); - using var lease = secret.Open(); - Assert.Equal(large, lease.Value); - } - - [Fact] - public void Secret_Int_MinValue() - { - var secret = Secret.FromPlain(int.MinValue); - using var lease = secret.Open(); - Assert.Equal(int.MinValue, lease.Value); - } - - [Fact] - public void Secret_Int_MaxValue() - { - var secret = Secret.FromPlain(int.MaxValue); - using var lease = secret.Open(); - Assert.Equal(int.MaxValue, lease.Value); - } - - [Fact] - public void Secret_Decimal_VeryPrecise() - { - var precise = 123456789.123456789m; - var secret = Secret.FromPlain(precise); - using var lease = secret.Open(); - Assert.Equal(precise, lease.Value); - } - - [Fact] - public void Secret_DoubleDispose_Safe() - { - var secret = Secret.FromPlain("test"); - secret.Dispose(); - secret.Dispose(); - } - - [Fact] - public void SecretLease_Dispose_Idempotent() - { - var secret = Secret.FromPlain("test"); - var lease = secret.Open(); - lease.Dispose(); - lease.Dispose(); - } - - [Fact] - public void Secret_OpenAfterLeaseDispose_StillWorks() - { - var secret = Secret.FromPlain("test"); - - using (var lease1 = secret.Open()) - { - Assert.Equal("test", lease1.Value); - } - - using (var lease2 = secret.Open()) - { - Assert.Equal("test", lease2.Value); - } - } - - [Fact] - public async Task Secret_ConcurrentOpen_Safe() - { - var secret = Secret.FromPlain(42); - var tasks = Enumerable.Range(0, 100).Select(_ => Task.Run(() => - { - using var lease = secret.Open(); - return lease.Value; - })).ToArray(); - - await Task.WhenAll(tasks); - - Assert.All(tasks, t => Assert.Equal(42, t.Result)); - } - - [Fact] - public void Secret_FromPlaintextConstructor_ThrowsOnOpen() - { - // Simulate what happens when JSON converter creates Secret from plaintext - // (not using FromPlain which is for testing) - var secret = new Secret("plaintext-password"); - - // Opening should throw because it wasn't created from an envelope - var ex = Assert.Throws(() => secret.Open()); - Assert.Contains("plaintext JSON instead of an encrypted envelope", ex.Message); - Assert.Contains("Pre-encrypted envelopes are required", ex.Message); - } - - #region Secret - Nullable Inner Type Tests - - [Fact] - public void Secret_NullableString_WithValue() - { - // Secret with an actual value - var secret = Secret.FromPlain("hello"); - using var lease = secret.Open(); - Assert.Equal("hello", lease.Value); - } - - [Fact] - public void Secret_NullableString_WithNull() - { - // Secret with null value - var secret = Secret.FromPlain(null); - using var lease = secret.Open(); - Assert.Null(lease.Value); - } - - [Fact] - public void Secret_NullableInt_WithValue() - { - // Secret with an actual value - var secret = Secret.FromPlain(42); - using var lease = secret.Open(); - Assert.Equal(42, lease.Value); - } - - [Fact] - public void Secret_NullableInt_WithNull() - { - // Secret with null value - properly supports Nullable value types - var secret = Secret.FromPlain(null); - using var lease = secret.Open(); - Assert.Null(lease.Value); - } - - [Fact] - public void Secret_NullableLong_WithValue() - { - var secret = Secret.FromPlain(9876543210L); - using var lease = secret.Open(); - Assert.Equal(9876543210L, lease.Value); - } - - [Fact] - public void Secret_NullableLong_WithNull() - { - var secret = Secret.FromPlain(null); - using var lease = secret.Open(); - Assert.Null(lease.Value); - } - - [Fact] - public void Secret_NullableDouble_WithValue() - { - var secret = Secret.FromPlain(3.14159); - using var lease = secret.Open(); - Assert.Equal(3.14159, lease.Value); - } - - [Fact] - public void Secret_NullableDouble_WithNull() - { - var secret = Secret.FromPlain(null); - using var lease = secret.Open(); - Assert.Null(lease.Value); - } - - [Fact] - public void Secret_NullableBool_WithValue() - { - var secret = Secret.FromPlain(true); - using var lease = secret.Open(); - Assert.True(lease.Value); - } - - [Fact] - public void Secret_NullableBool_WithNull() - { - var secret = Secret.FromPlain(null); - using var lease = secret.Open(); - Assert.Null(lease.Value); - } - - [Fact] - public void Secret_NullableGuid_WithValue() - { - var guid = Guid.NewGuid(); - var secret = Secret.FromPlain(guid); - using var lease = secret.Open(); - Assert.Equal(guid, lease.Value); - } - - [Fact] - public void Secret_NullableGuid_WithNull() - { - var secret = Secret.FromPlain(null); - using var lease = secret.Open(); - Assert.Null(lease.Value); - } - - #endregion -} +using System.Text.Json; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Tests; + +public record SerializableConfig +{ + public string? PublicField { get; init; } + public Secret? SecretField { get; init; } + public int Number { get; init; } +} + +public class EdgeCasesTests +{ + [Fact] + public void Secret_Serialization_NoCustomConverter() + { + // Documents current behavior: Secret serializes as empty object without custom converter + var config = new SerializableConfig + { + PublicField = "visible", + SecretField = Secret.FromPlain("hidden"), + Number = 42 + }; + + var json = JsonSerializer.Serialize(config); + + Assert.Contains("\"PublicField\":\"visible\"", json); + Assert.Contains("\"SecretField\":{}", json); // Serializes as empty object, not "***" + Assert.Contains("\"Number\":42", json); + Assert.DoesNotContain("hidden", json); // At least the secret value isn't leaked + } + + [Fact] + public void Secret_NullValue_Allowed() + { + // Documents current behavior: null values are accepted by Secret + var secret = Secret.FromPlain(null!); + using var lease = secret.Open(); + Assert.Null(lease.Value); + } + + [Fact] + public void Secret_EmptyString_Valid() + { + var secret = Secret.FromPlain(""); + using var lease = secret.Open(); + Assert.Equal("", lease.Value); + } + + [Fact] + public void Secret_VeryLongString_Valid() + { + var longString = new string('x', 100000); + var secret = Secret.FromPlain(longString); + using var lease = secret.Open(); + Assert.Equal(longString, lease.Value); + } + + [Fact] + public void Secret_Unicode_Valid() + { + var unicode = "Hello 世界 🌍 émojis"; + var secret = Secret.FromPlain(unicode); + using var lease = secret.Open(); + Assert.Equal(unicode, lease.Value); + } + + [Fact] + public void Secret_ByteArray_Empty() + { + var empty = Array.Empty(); + var secret = Secret.FromPlain(empty); + using var lease = secret.Open(); + Assert.Empty(lease.Value); + } + + [Fact] + public void Secret_ByteArray_LargeArray() + { + var large = new byte[10000]; + Random.Shared.NextBytes(large); + var secret = Secret.FromPlain(large); + using var lease = secret.Open(); + Assert.Equal(large, lease.Value); + } + + [Fact] + public void Secret_Int_MinValue() + { + var secret = Secret.FromPlain(int.MinValue); + using var lease = secret.Open(); + Assert.Equal(int.MinValue, lease.Value); + } + + [Fact] + public void Secret_Int_MaxValue() + { + var secret = Secret.FromPlain(int.MaxValue); + using var lease = secret.Open(); + Assert.Equal(int.MaxValue, lease.Value); + } + + [Fact] + public void Secret_Decimal_VeryPrecise() + { + var precise = 123456789.123456789m; + var secret = Secret.FromPlain(precise); + using var lease = secret.Open(); + Assert.Equal(precise, lease.Value); + } + + [Fact] + public void Secret_DoubleDispose_Safe() + { + var secret = Secret.FromPlain("test"); + secret.Dispose(); + secret.Dispose(); + } + + [Fact] + public void SecretLease_Dispose_Idempotent() + { + var secret = Secret.FromPlain("test"); + var lease = secret.Open(); + lease.Dispose(); + lease.Dispose(); + } + + [Fact] + public void Secret_OpenAfterLeaseDispose_StillWorks() + { + var secret = Secret.FromPlain("test"); + + using (var lease1 = secret.Open()) + { + Assert.Equal("test", lease1.Value); + } + + using (var lease2 = secret.Open()) + { + Assert.Equal("test", lease2.Value); + } + } + + [Fact] + public async Task Secret_ConcurrentOpen_Safe() + { + var secret = Secret.FromPlain(42); + var tasks = Enumerable.Range(0, 100).Select(_ => Task.Run(() => + { + using var lease = secret.Open(); + return lease.Value; + })).ToArray(); + + await Task.WhenAll(tasks); + + Assert.All(tasks, t => Assert.Equal(42, t.Result)); + } + + [Fact] + public void Secret_FromPlaintextConstructor_ThrowsOnOpen() + { + // Simulate what happens when JSON converter creates Secret from plaintext + // (not using FromPlain which is for testing) + var secret = new Secret("plaintext-password"); + + // Opening should throw because it wasn't created from an envelope + var ex = Assert.Throws(() => secret.Open()); + Assert.Contains("plaintext JSON instead of an encrypted envelope", ex.Message); + Assert.Contains("Pre-encrypted envelopes are required", ex.Message); + } + + #region Secret - Nullable Inner Type Tests + + [Fact] + public void Secret_NullableString_WithValue() + { + // Secret with an actual value + var secret = Secret.FromPlain("hello"); + using var lease = secret.Open(); + Assert.Equal("hello", lease.Value); + } + + [Fact] + public void Secret_NullableString_WithNull() + { + // Secret with null value + var secret = Secret.FromPlain(null); + using var lease = secret.Open(); + Assert.Null(lease.Value); + } + + [Fact] + public void Secret_NullableInt_WithValue() + { + // Secret with an actual value + var secret = Secret.FromPlain(42); + using var lease = secret.Open(); + Assert.Equal(42, lease.Value); + } + + [Fact] + public void Secret_NullableInt_WithNull() + { + // Secret with null value - properly supports Nullable value types + var secret = Secret.FromPlain(null); + using var lease = secret.Open(); + Assert.Null(lease.Value); + } + + [Fact] + public void Secret_NullableLong_WithValue() + { + var secret = Secret.FromPlain(9876543210L); + using var lease = secret.Open(); + Assert.Equal(9876543210L, lease.Value); + } + + [Fact] + public void Secret_NullableLong_WithNull() + { + var secret = Secret.FromPlain(null); + using var lease = secret.Open(); + Assert.Null(lease.Value); + } + + [Fact] + public void Secret_NullableDouble_WithValue() + { + var secret = Secret.FromPlain(3.14159); + using var lease = secret.Open(); + Assert.Equal(3.14159, lease.Value); + } + + [Fact] + public void Secret_NullableDouble_WithNull() + { + var secret = Secret.FromPlain(null); + using var lease = secret.Open(); + Assert.Null(lease.Value); + } + + [Fact] + public void Secret_NullableBool_WithValue() + { + var secret = Secret.FromPlain(true); + using var lease = secret.Open(); + Assert.True(lease.Value); + } + + [Fact] + public void Secret_NullableBool_WithNull() + { + var secret = Secret.FromPlain(null); + using var lease = secret.Open(); + Assert.Null(lease.Value); + } + + [Fact] + public void Secret_NullableGuid_WithValue() + { + var guid = Guid.NewGuid(); + var secret = Secret.FromPlain(guid); + using var lease = secret.Open(); + Assert.Equal(guid, lease.Value); + } + + [Fact] + public void Secret_NullableGuid_WithNull() + { + var secret = Secret.FromPlain(null); + using var lease = secret.Open(); + Assert.Null(lease.Value); + } + + #endregion +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/ErrorMessageTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/ErrorMessageTests.cs index 5b6ba7e..3017005 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/ErrorMessageTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/ErrorMessageTests.cs @@ -1,193 +1,193 @@ -using System.Text.Json; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Secrets; -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace Cocoar.Configuration.Secrets.Tests; - -public record ConfigWithSecretString -{ - public string? Name { get; init; } - public Secret? ApiKey { get; init; } -} - -/// -/// Tests that error messages are helpful and actionable when secrets are misconfigured. -/// -public class ErrorMessageTests -{ - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void Secret_WithSecretsButNoCertificates_ThrowsHelpfulError() - { - // Arrange - ConfigManager WITH setup.Secrets() but NO certificates configured - var json = """ - { - "Name": "TestApp", - "ApiKey": { - "type": "cocoar.secret", - "version": 1, - "kid": "my-key-id", - "alg": "RSA-OAEP-AES256-GCM", - "ct": "ZW5jcnlwdGVk", - "iv": "bm9uY2U=" - } - } - """; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ]) - .UseSecretsSetup(secrets => secrets) // Secrets enabled, but no certificates configured - ); - - // Act - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.NotNull(config!.ApiKey); - - // Assert - the error message should tell them to configure certificates - var ex = Assert.Throws(() => config.ApiKey!.Open()); - - Assert.Contains("no certificates configured", ex.Message); - Assert.Contains("UseCertificateFromFile", ex.Message); - Assert.Contains("UseCertificatesFromFolder", ex.Message); - Assert.Contains("my-key-id", ex.Message); // Should mention the kid they're trying to use - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void Secret_NoCertificates_ErrorContainsCodeExamples() - { - // Arrange - var json = """ - { - "Name": "TestApp", - "ApiKey": { - "type": "cocoar.secret", - "version": 1, - "kid": "test-key", - "alg": "RSA-OAEP-AES256-GCM", - "ct": "ZW5jcnlwdGVk", - "iv": "bm9uY2U=" - } - } - """; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [rules.For().FromStaticJson(json).Required()]) - .UseSecretsSetup(secrets => secrets) - ); - - var config = manager.GetConfig(); - - // Act - var ex = Assert.Throws(() => config!.ApiKey!.Open()); - - // Assert - error should have code examples showing certificate setup - Assert.Contains(".UseCertificateFromFile(", ex.Message); - Assert.Contains(".WithKeyId(", ex.Message); - Assert.Contains(".UseSecretsSetup(", ex.Message); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void Secret_FromEnvelopeWithoutResolver_ThrowsHelpfulError() - { - // This tests the direct Secret construction path where resolver is null - // This can happen if Secret is constructed outside of ConfigManager deserialization - - // Arrange - create a valid envelope JSON element - var envelopeJson = """ - { - "type": "cocoar.secret", - "version": 1, - "kid": "orphan-key", - "alg": "RSA-OAEP-AES256-GCM", - "ct": "ZW5jcnlwdGVk", - "iv": "bm9uY2U=" - } - """; - - using var doc = JsonDocument.Parse(envelopeJson); - var secret = Secret.FromEnvelope(doc.RootElement); - - // Act & Assert - the error should be helpful - var ex = Assert.Throws(() => secret.Open()); - - Assert.Contains("secrets infrastructure not configured", ex.Message); - Assert.Contains("UseSecretsSetup", ex.Message); - Assert.Contains("ConfigManager", ex.Message); - Assert.Contains("AddCocoarConfiguration", ex.Message); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void Secret_FromEnvelopeWithoutResolver_ErrorIncludesTypeName() - { - // Arrange - var envelopeJson = """ - { - "type": "cocoar.secret", - "version": 1, - "kid": "test-key", - "alg": "RSA-OAEP-AES256-GCM", - "ct": "ZW5jcnlwdGVk", - "iv": "bm9uY2U=" - } - """; - - using var doc = JsonDocument.Parse(envelopeJson); - var secret = Secret.FromEnvelope(doc.RootElement); - - // Act - var ex = Assert.Throws(() => secret.Open()); - - // Assert - error message includes the type name for easier debugging - Assert.Contains("Secret", ex.Message); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void Secret_WithoutSecretsSetup_FailsAtDeserialization() - { - // This test documents the current behavior: without setup.Secrets(), - // deserialization fails because no JSON converter is registered for Secret. - // The improved error message in Secret.cs won't be reached in this case. - - var json = """ - { - "Name": "TestApp", - "ApiKey": { - "type": "cocoar.secret", - "version": 1, - "kid": "my-key-id", - "alg": "RSA-OAEP-AES256-GCM", - "ct": "ZW5jcnlwdGVk", - "iv": "bm9uY2U=" - } - } - """; - - // Act & Assert - With Master Backplane, deserialization fails at startup - var ex = Assert.Throws(() => ConfigManager.Create(c => c.UseConfiguration( - rules => [ - rules.For().FromStaticJson(json).Required() - ] - // NOTE: No setup.Secrets() - deserialization will fail - ))); - - // The error is about JSON deserialization, not about secrets infrastructure - Assert.Contains("Deserialization", ex.Message); - } -} +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Tests; + +public record ConfigWithSecretString +{ + public string? Name { get; init; } + public Secret? ApiKey { get; init; } +} + +/// +/// Tests that error messages are helpful and actionable when secrets are misconfigured. +/// +public class ErrorMessageTests +{ + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void Secret_WithSecretsButNoCertificates_ThrowsHelpfulError() + { + // Arrange - ConfigManager WITH setup.Secrets() but NO certificates configured + var json = """ + { + "Name": "TestApp", + "ApiKey": { + "type": "cocoar.secret", + "version": 1, + "kid": "my-key-id", + "alg": "RSA-OAEP-AES256-GCM", + "ct": "ZW5jcnlwdGVk", + "iv": "bm9uY2U=" + } + } + """; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ]) + .UseSecretsSetup(secrets => secrets) // Secrets enabled, but no certificates configured + ); + + // Act + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.NotNull(config!.ApiKey); + + // Assert - the error message should tell them to configure certificates + var ex = Assert.Throws(() => config.ApiKey!.Open()); + + Assert.Contains("no certificates configured", ex.Message); + Assert.Contains("UseCertificateFromFile", ex.Message); + Assert.Contains("UseCertificatesFromFolder", ex.Message); + Assert.Contains("my-key-id", ex.Message); // Should mention the kid they're trying to use + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void Secret_NoCertificates_ErrorContainsCodeExamples() + { + // Arrange + var json = """ + { + "Name": "TestApp", + "ApiKey": { + "type": "cocoar.secret", + "version": 1, + "kid": "test-key", + "alg": "RSA-OAEP-AES256-GCM", + "ct": "ZW5jcnlwdGVk", + "iv": "bm9uY2U=" + } + } + """; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [rules.For().FromStaticJson(json).Required()]) + .UseSecretsSetup(secrets => secrets) + ); + + var config = manager.GetConfig(); + + // Act + var ex = Assert.Throws(() => config!.ApiKey!.Open()); + + // Assert - error should have code examples showing certificate setup + Assert.Contains(".UseCertificateFromFile(", ex.Message); + Assert.Contains(".WithKeyId(", ex.Message); + Assert.Contains(".UseSecretsSetup(", ex.Message); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void Secret_FromEnvelopeWithoutResolver_ThrowsHelpfulError() + { + // This tests the direct Secret construction path where resolver is null + // This can happen if Secret is constructed outside of ConfigManager deserialization + + // Arrange - create a valid envelope JSON element + var envelopeJson = """ + { + "type": "cocoar.secret", + "version": 1, + "kid": "orphan-key", + "alg": "RSA-OAEP-AES256-GCM", + "ct": "ZW5jcnlwdGVk", + "iv": "bm9uY2U=" + } + """; + + using var doc = JsonDocument.Parse(envelopeJson); + var secret = Secret.FromEnvelope(doc.RootElement); + + // Act & Assert - the error should be helpful + var ex = Assert.Throws(() => secret.Open()); + + Assert.Contains("secrets infrastructure not configured", ex.Message); + Assert.Contains("UseSecretsSetup", ex.Message); + Assert.Contains("ConfigManager", ex.Message); + Assert.Contains("AddCocoarConfiguration", ex.Message); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void Secret_FromEnvelopeWithoutResolver_ErrorIncludesTypeName() + { + // Arrange + var envelopeJson = """ + { + "type": "cocoar.secret", + "version": 1, + "kid": "test-key", + "alg": "RSA-OAEP-AES256-GCM", + "ct": "ZW5jcnlwdGVk", + "iv": "bm9uY2U=" + } + """; + + using var doc = JsonDocument.Parse(envelopeJson); + var secret = Secret.FromEnvelope(doc.RootElement); + + // Act + var ex = Assert.Throws(() => secret.Open()); + + // Assert - error message includes the type name for easier debugging + Assert.Contains("Secret", ex.Message); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void Secret_WithoutSecretsSetup_FailsAtDeserialization() + { + // This test documents the current behavior: without setup.Secrets(), + // deserialization fails because no JSON converter is registered for Secret. + // The improved error message in Secret.cs won't be reached in this case. + + var json = """ + { + "Name": "TestApp", + "ApiKey": { + "type": "cocoar.secret", + "version": 1, + "kid": "my-key-id", + "alg": "RSA-OAEP-AES256-GCM", + "ct": "ZW5jcnlwdGVk", + "iv": "bm9uY2U=" + } + } + """; + + // Act & Assert - With Master Backplane, deserialization fails at startup + var ex = Assert.Throws(() => ConfigManager.Create(c => c.UseConfiguration( + rules => [ + rules.For().FromStaticJson(json).Required() + ] + // NOTE: No setup.Secrets() - deserialization will fail + ))); + + // The error is about JSON deserialization, not about secrets infrastructure + Assert.Contains("Deserialization", ex.Message); + } +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/ISecretInterfaceTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/ISecretInterfaceTests.cs index e30f4ed..7ac1173 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/ISecretInterfaceTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/ISecretInterfaceTests.cs @@ -1,242 +1,242 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace Cocoar.Configuration.Secrets.Tests; - -/// -/// Tests verifying that Secret<T> correctly implements ISecret<T> -/// and can be used polymorphically through the interface. -/// -public class ISecretInterfaceTests -{ - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void Secret_Implements_ISecret_Interface() - { - // Verify Secret can be assigned to ISecret - ISecret secret = Secret.FromPlain("test"); - Assert.NotNull(secret); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void ISecret_Open_Returns_SecretLease() - { - ISecret secret = Secret.FromPlain("test"); - using var lease = secret.Open(); - Assert.Equal("test", lease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void ISecret_Can_Be_Used_In_Method_Parameter() - { - // Simulates library author accepting ISecret - static string ProcessSecret(ISecret secret) - { - using var lease = secret.Open(); - return lease.Value.ToUpperInvariant(); - } - - Secret concreteSecret = Secret.FromPlain("hello"); - var result = ProcessSecret(concreteSecret); - Assert.Equal("HELLO", result); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void ISecret_Dispose_Works_Through_Interface() - { - ISecret secret = Secret.FromPlain("test"); - secret.Dispose(); - // Should throw ObjectDisposedException - Assert.Throws(() => secret.Open()); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void SecretLease_From_Abstractions_Package_Works() - { - // Verify SecretLease type from abstractions works correctly - var lease = new SecretLease("test-value", null); - Assert.Equal("test-value", lease.Value); - lease.Dispose(); // Should not throw - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void SecretLease_Dispose_Invokes_Cleanup_Action() - { - // Verify the cleanup action is invoked on dispose - var cleanupCalled = false; - var lease = new SecretLease("test-value", () => cleanupCalled = true); - - Assert.False(cleanupCalled); - lease.Dispose(); - Assert.True(cleanupCalled); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void ISecret_Works_With_Complex_Types() - { - // Verify ISecret works with complex types, not just primitives - var testData = new TestData { Name = "Test", Value = 42 }; - ISecret secret = Secret.FromPlain(testData); - - using var lease = secret.Open(); - Assert.NotNull(lease.Value); - Assert.Equal("Test", lease.Value.Name); - Assert.Equal(42, lease.Value.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void ISecret_Works_With_Numeric_Types() - { - ISecret secret = Secret.FromPlain(12345); - using var lease = secret.Open(); - Assert.Equal(12345, lease.Value); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Component", "Secrets")] - public void ISecret_Interface_Allows_Generic_Processing() - { - // Demonstrates generic code that works with any ISecret - static T ExtractValue(ISecret secret) - { - using var lease = secret.Open(); - return lease.Value; - } - - var stringSecret = Secret.FromPlain("hello"); - var intSecret = Secret.FromPlain(42); - - Assert.Equal("hello", ExtractValue(stringSecret)); - Assert.Equal(42, ExtractValue(intSecret)); - } - - private record TestData - { - public string? Name { get; init; } - public int Value { get; init; } - } - - #region ISecret Deserialization Tests - - private record ConfigWithISecret - { - public string? Name { get; init; } - public ISecret? Password { get; init; } - public ISecret? ApiKey { get; init; } - } - - [Fact] - [Trait("Type", "Integration")] - [Trait("Component", "Secrets")] - public void ISecret_Property_DeserializesFromJson_WithAllowPlaintext() - { - // Arrange - Config class with ISecret property (interface, not concrete) - var json = """{"Name":"TestApp","Password":"secret123"}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [rules.For().FromStaticJson(json).Required()]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - ISecret property deserializes to Secret and works correctly - Assert.NotNull(config); - Assert.Equal("TestApp", config!.Name); - Assert.NotNull(config.Password); - - using var lease = config.Password!.Open(); - Assert.Equal("secret123", lease.Value); - } - - [Fact] - [Trait("Type", "Integration")] - [Trait("Component", "Secrets")] - public void ISecret_Property_DeserializesNumericType() - { - // Arrange - var json = """{"Name":"Test","ApiKey":42}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [rules.For().FromStaticJson(json).Required()]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - Assert.NotNull(config); - Assert.NotNull(config!.ApiKey); - - using var lease = config.ApiKey!.Open(); - Assert.Equal(42, lease.Value); - } - - [Fact] - [Trait("Type", "Integration")] - [Trait("Component", "Secrets")] - public void ISecret_Property_WithoutAllowPlaintext_ThrowsOnOpen() - { - // Arrange - Default security behavior (no AllowPlaintext) - var json = """{"Name":"TestApp","Password":"secret123"}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [rules.For().FromStaticJson(json).Required()]) - .UseSecretsSetup(secrets => secrets) - ); - - // Act - var config = manager.GetConfig(); - - // Assert - Deserializes but Open() throws - Assert.NotNull(config); - Assert.NotNull(config!.Password); - - var ex = Assert.Throws(() => config.Password!.Open()); - Assert.Contains("plaintext JSON instead of an encrypted envelope", ex.Message); - } - - [Fact] - [Trait("Type", "Integration")] - [Trait("Component", "Secrets")] - public void ISecret_Property_ReturnsSecretInstance() - { - // Verify the deserialized value is actually a Secret instance - var json = """{"Name":"Test","Password":"value"}"""; - - var manager = ConfigManager.Create(c => c - .UseConfiguration( - rules => [rules.For().FromStaticJson(json).Required()]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext()) - ); - - var config = manager.GetConfig(); - - Assert.NotNull(config?.Password); - Assert.IsType>(config!.Password); - } - - #endregion -} +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Tests; + +/// +/// Tests verifying that Secret<T> correctly implements ISecret<T> +/// and can be used polymorphically through the interface. +/// +public class ISecretInterfaceTests +{ + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void Secret_Implements_ISecret_Interface() + { + // Verify Secret can be assigned to ISecret + ISecret secret = Secret.FromPlain("test"); + Assert.NotNull(secret); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void ISecret_Open_Returns_SecretLease() + { + ISecret secret = Secret.FromPlain("test"); + using var lease = secret.Open(); + Assert.Equal("test", lease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void ISecret_Can_Be_Used_In_Method_Parameter() + { + // Simulates library author accepting ISecret + static string ProcessSecret(ISecret secret) + { + using var lease = secret.Open(); + return lease.Value.ToUpperInvariant(); + } + + Secret concreteSecret = Secret.FromPlain("hello"); + var result = ProcessSecret(concreteSecret); + Assert.Equal("HELLO", result); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void ISecret_Dispose_Works_Through_Interface() + { + ISecret secret = Secret.FromPlain("test"); + secret.Dispose(); + // Should throw ObjectDisposedException + Assert.Throws(() => secret.Open()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void SecretLease_From_Abstractions_Package_Works() + { + // Verify SecretLease type from abstractions works correctly + var lease = new SecretLease("test-value", null); + Assert.Equal("test-value", lease.Value); + lease.Dispose(); // Should not throw + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void SecretLease_Dispose_Invokes_Cleanup_Action() + { + // Verify the cleanup action is invoked on dispose + var cleanupCalled = false; + var lease = new SecretLease("test-value", () => cleanupCalled = true); + + Assert.False(cleanupCalled); + lease.Dispose(); + Assert.True(cleanupCalled); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void ISecret_Works_With_Complex_Types() + { + // Verify ISecret works with complex types, not just primitives + var testData = new TestData { Name = "Test", Value = 42 }; + ISecret secret = Secret.FromPlain(testData); + + using var lease = secret.Open(); + Assert.NotNull(lease.Value); + Assert.Equal("Test", lease.Value.Name); + Assert.Equal(42, lease.Value.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void ISecret_Works_With_Numeric_Types() + { + ISecret secret = Secret.FromPlain(12345); + using var lease = secret.Open(); + Assert.Equal(12345, lease.Value); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Component", "Secrets")] + public void ISecret_Interface_Allows_Generic_Processing() + { + // Demonstrates generic code that works with any ISecret + static T ExtractValue(ISecret secret) + { + using var lease = secret.Open(); + return lease.Value; + } + + var stringSecret = Secret.FromPlain("hello"); + var intSecret = Secret.FromPlain(42); + + Assert.Equal("hello", ExtractValue(stringSecret)); + Assert.Equal(42, ExtractValue(intSecret)); + } + + private record TestData + { + public string? Name { get; init; } + public int Value { get; init; } + } + + #region ISecret Deserialization Tests + + private record ConfigWithISecret + { + public string? Name { get; init; } + public ISecret? Password { get; init; } + public ISecret? ApiKey { get; init; } + } + + [Fact] + [Trait("Type", "Integration")] + [Trait("Component", "Secrets")] + public void ISecret_Property_DeserializesFromJson_WithAllowPlaintext() + { + // Arrange - Config class with ISecret property (interface, not concrete) + var json = """{"Name":"TestApp","Password":"secret123"}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [rules.For().FromStaticJson(json).Required()]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert - ISecret property deserializes to Secret and works correctly + Assert.NotNull(config); + Assert.Equal("TestApp", config!.Name); + Assert.NotNull(config.Password); + + using var lease = config.Password!.Open(); + Assert.Equal("secret123", lease.Value); + } + + [Fact] + [Trait("Type", "Integration")] + [Trait("Component", "Secrets")] + public void ISecret_Property_DeserializesNumericType() + { + // Arrange + var json = """{"Name":"Test","ApiKey":42}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [rules.For().FromStaticJson(json).Required()]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + // Act + var config = manager.GetConfig(); + + // Assert + Assert.NotNull(config); + Assert.NotNull(config!.ApiKey); + + using var lease = config.ApiKey!.Open(); + Assert.Equal(42, lease.Value); + } + + [Fact] + [Trait("Type", "Integration")] + [Trait("Component", "Secrets")] + public void ISecret_Property_WithoutAllowPlaintext_ThrowsOnOpen() + { + // Arrange - Default security behavior (no AllowPlaintext) + var json = """{"Name":"TestApp","Password":"secret123"}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [rules.For().FromStaticJson(json).Required()]) + .UseSecretsSetup(secrets => secrets) + ); + + // Act + var config = manager.GetConfig(); + + // Assert - Deserializes but Open() throws + Assert.NotNull(config); + Assert.NotNull(config!.Password); + + var ex = Assert.Throws(() => config.Password!.Open()); + Assert.Contains("plaintext JSON instead of an encrypted envelope", ex.Message); + } + + [Fact] + [Trait("Type", "Integration")] + [Trait("Component", "Secrets")] + public void ISecret_Property_ReturnsSecretInstance() + { + // Verify the deserialized value is actually a Secret instance + var json = """{"Name":"Test","Password":"value"}"""; + + var manager = ConfigManager.Create(c => c + .UseConfiguration( + rules => [rules.For().FromStaticJson(json).Required()]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext()) + ); + + var config = manager.GetConfig(); + + Assert.NotNull(config?.Password); + Assert.IsType>(config!.Password); + } + + #endregion +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/MixedTypesTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/MixedTypesTests.cs index c8850ee..a63e3cb 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/MixedTypesTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/MixedTypesTests.cs @@ -1,179 +1,179 @@ -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace Cocoar.Configuration.Secrets.Tests; - -public record MixedConfig -{ - public Secret? Username { get; init; } - public Secret? Password { get; init; } - public string? PublicEndpoint { get; init; } - public Secret? Port { get; init; } - public bool Enabled { get; init; } -} - -public record AllPrimitivesSecret -{ - public Secret? IntValue { get; init; } - public Secret? LongValue { get; init; } - public Secret? DoubleValue { get; init; } - public Secret? DecimalValue { get; init; } - public Secret? BoolValue { get; init; } - public Secret? StringValue { get; init; } - public Secret? GuidValue { get; init; } -} - -public record ComplexWithSecrets -{ - public string? PublicName { get; init; } - public Secret? Credentials { get; init; } - public Secret
? BillingAddress { get; init; } - public int Version { get; init; } -} - -public record NestedSecrets -{ - public Secret? SecretInner { get; init; } - public Inner? PlainInner { get; init; } -} - -public class MixedTypesTests -{ - [Fact] - public void MixedConfig_SecretAndPlainProperties() - { - var config = new MixedConfig - { - Username = Secret.FromPlain("admin"), - Password = Secret.FromPlain("secret123"), - PublicEndpoint = "https://api.example.com", - Port = Secret.FromPlain(8080), - Enabled = true - }; - - using var userLease = config.Username!.Open(); - using var passLease = config.Password!.Open(); - using var portLease = config.Port!.Open(); - - Assert.Equal("admin", userLease.Value); - Assert.Equal("secret123", passLease.Value); - Assert.Equal("https://api.example.com", config.PublicEndpoint); - Assert.Equal(8080, portLease.Value); - Assert.True(config.Enabled); - } - - [Fact] - public void AllPrimitives_AsSecrets() - { - var guid = Guid.NewGuid(); - var config = new AllPrimitivesSecret - { - IntValue = Secret.FromPlain(42), - LongValue = Secret.FromPlain(123456789L), - DoubleValue = Secret.FromPlain(3.14), - DecimalValue = Secret.FromPlain(99.99m), - BoolValue = Secret.FromPlain(true), - StringValue = Secret.FromPlain("test"), - GuidValue = Secret.FromPlain(guid) - }; - - using var intLease = config.IntValue!.Open(); - using var longLease = config.LongValue!.Open(); - using var doubleLease = config.DoubleValue!.Open(); - using var decimalLease = config.DecimalValue!.Open(); - using var boolLease = config.BoolValue!.Open(); - using var stringLease = config.StringValue!.Open(); - using var guidLease = config.GuidValue!.Open(); - - Assert.Equal(42, intLease.Value); - Assert.Equal(123456789L, longLease.Value); - Assert.Equal(3.14, doubleLease.Value, precision: 2); - Assert.Equal(99.99m, decimalLease.Value); - Assert.True(boolLease.Value); - Assert.Equal("test", stringLease.Value); - Assert.Equal(guid, guidLease.Value); - } - - [Fact] - public void ComplexTypes_AsSecrets() - { - var config = new ComplexWithSecrets - { - PublicName = "My Service", - Credentials = Secret.FromPlain(new Credentials("user", "pass", "key")), - BillingAddress = Secret
.FromPlain(new Address("123 Main", "City", "12345")), - Version = 1 - }; - - Assert.Equal("My Service", config.PublicName); - Assert.Equal(1, config.Version); - - using var credsLease = config.Credentials!.Open(); - Assert.Equal("user", credsLease.Value.Username); - Assert.Equal("pass", credsLease.Value.Password); - Assert.Equal("key", credsLease.Value.ApiKey); - - using var addrLease = config.BillingAddress!.Open(); - Assert.Equal("123 Main", addrLease.Value.Street); - Assert.Equal("City", addrLease.Value.City); - Assert.Equal("12345", addrLease.Value.Zip); - } - - [Fact] - public void NestedTypes_SecretAndPlain() - { - var config = new NestedSecrets - { - SecretInner = Secret.FromPlain(new Inner(new Deep("secret value"))), - PlainInner = new Inner(new Deep("plain value")) - }; - - using var secretLease = config.SecretInner!.Open(); - Assert.Equal("secret value", secretLease.Value.Deep.Value); - Assert.Equal("plain value", config.PlainInner!.Deep.Value); - } - - [Fact] - public void MixedConfig_NullSecrets() - { - var config = new MixedConfig - { - Username = null, - Password = Secret.FromPlain("secret"), - PublicEndpoint = "https://api.example.com", - Port = null, - Enabled = false - }; - - Assert.Null(config.Username); - Assert.NotNull(config.Password); - Assert.Null(config.Port); - - using var passLease = config.Password.Open(); - Assert.Equal("secret", passLease.Value); - } - - [Fact] - public void MultipleSecrets_IndependentLifecycles() - { - var secret1 = Secret.FromPlain("value1"); - var secret2 = Secret.FromPlain("value2"); - - using (var lease1 = secret1.Open()) - { - Assert.Equal("value1", lease1.Value); - } - - using (var lease2 = secret2.Open()) - { - Assert.Equal("value2", lease2.Value); - } - - secret1.Dispose(); - Assert.Throws(() => secret1.Open()); - - using (var lease2Again = secret2.Open()) - { - Assert.Equal("value2", lease2Again.Value); - } - } -} +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Tests; + +public record MixedConfig +{ + public Secret? Username { get; init; } + public Secret? Password { get; init; } + public string? PublicEndpoint { get; init; } + public Secret? Port { get; init; } + public bool Enabled { get; init; } +} + +public record AllPrimitivesSecret +{ + public Secret? IntValue { get; init; } + public Secret? LongValue { get; init; } + public Secret? DoubleValue { get; init; } + public Secret? DecimalValue { get; init; } + public Secret? BoolValue { get; init; } + public Secret? StringValue { get; init; } + public Secret? GuidValue { get; init; } +} + +public record ComplexWithSecrets +{ + public string? PublicName { get; init; } + public Secret? Credentials { get; init; } + public Secret
? BillingAddress { get; init; } + public int Version { get; init; } +} + +public record NestedSecrets +{ + public Secret? SecretInner { get; init; } + public Inner? PlainInner { get; init; } +} + +public class MixedTypesTests +{ + [Fact] + public void MixedConfig_SecretAndPlainProperties() + { + var config = new MixedConfig + { + Username = Secret.FromPlain("admin"), + Password = Secret.FromPlain("secret123"), + PublicEndpoint = "https://api.example.com", + Port = Secret.FromPlain(8080), + Enabled = true + }; + + using var userLease = config.Username!.Open(); + using var passLease = config.Password!.Open(); + using var portLease = config.Port!.Open(); + + Assert.Equal("admin", userLease.Value); + Assert.Equal("secret123", passLease.Value); + Assert.Equal("https://api.example.com", config.PublicEndpoint); + Assert.Equal(8080, portLease.Value); + Assert.True(config.Enabled); + } + + [Fact] + public void AllPrimitives_AsSecrets() + { + var guid = Guid.NewGuid(); + var config = new AllPrimitivesSecret + { + IntValue = Secret.FromPlain(42), + LongValue = Secret.FromPlain(123456789L), + DoubleValue = Secret.FromPlain(3.14), + DecimalValue = Secret.FromPlain(99.99m), + BoolValue = Secret.FromPlain(true), + StringValue = Secret.FromPlain("test"), + GuidValue = Secret.FromPlain(guid) + }; + + using var intLease = config.IntValue!.Open(); + using var longLease = config.LongValue!.Open(); + using var doubleLease = config.DoubleValue!.Open(); + using var decimalLease = config.DecimalValue!.Open(); + using var boolLease = config.BoolValue!.Open(); + using var stringLease = config.StringValue!.Open(); + using var guidLease = config.GuidValue!.Open(); + + Assert.Equal(42, intLease.Value); + Assert.Equal(123456789L, longLease.Value); + Assert.Equal(3.14, doubleLease.Value, precision: 2); + Assert.Equal(99.99m, decimalLease.Value); + Assert.True(boolLease.Value); + Assert.Equal("test", stringLease.Value); + Assert.Equal(guid, guidLease.Value); + } + + [Fact] + public void ComplexTypes_AsSecrets() + { + var config = new ComplexWithSecrets + { + PublicName = "My Service", + Credentials = Secret.FromPlain(new Credentials("user", "pass", "key")), + BillingAddress = Secret
.FromPlain(new Address("123 Main", "City", "12345")), + Version = 1 + }; + + Assert.Equal("My Service", config.PublicName); + Assert.Equal(1, config.Version); + + using var credsLease = config.Credentials!.Open(); + Assert.Equal("user", credsLease.Value.Username); + Assert.Equal("pass", credsLease.Value.Password); + Assert.Equal("key", credsLease.Value.ApiKey); + + using var addrLease = config.BillingAddress!.Open(); + Assert.Equal("123 Main", addrLease.Value.Street); + Assert.Equal("City", addrLease.Value.City); + Assert.Equal("12345", addrLease.Value.Zip); + } + + [Fact] + public void NestedTypes_SecretAndPlain() + { + var config = new NestedSecrets + { + SecretInner = Secret.FromPlain(new Inner(new Deep("secret value"))), + PlainInner = new Inner(new Deep("plain value")) + }; + + using var secretLease = config.SecretInner!.Open(); + Assert.Equal("secret value", secretLease.Value.Deep.Value); + Assert.Equal("plain value", config.PlainInner!.Deep.Value); + } + + [Fact] + public void MixedConfig_NullSecrets() + { + var config = new MixedConfig + { + Username = null, + Password = Secret.FromPlain("secret"), + PublicEndpoint = "https://api.example.com", + Port = null, + Enabled = false + }; + + Assert.Null(config.Username); + Assert.NotNull(config.Password); + Assert.Null(config.Port); + + using var passLease = config.Password.Open(); + Assert.Equal("secret", passLease.Value); + } + + [Fact] + public void MultipleSecrets_IndependentLifecycles() + { + var secret1 = Secret.FromPlain("value1"); + var secret2 = Secret.FromPlain("value2"); + + using (var lease1 = secret1.Open()) + { + Assert.Equal("value1", lease1.Value); + } + + using (var lease2 = secret2.Open()) + { + Assert.Equal("value2", lease2.Value); + } + + secret1.Dispose(); + Assert.Throws(() => secret1.Open()); + + using (var lease2Again = secret2.Open()) + { + Assert.Equal("value2", lease2Again.Value); + } + } +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/MultiProviderTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/MultiProviderTests.cs index e634b9e..34c6d42 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/MultiProviderTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/MultiProviderTests.cs @@ -1,152 +1,152 @@ -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace Cocoar.Configuration.Secrets.Tests; - -public record SourceConfig -{ - public string? Username { get; init; } - public string? Password { get; init; } - public int Port { get; init; } -} - -public record TargetConfigA -{ - public Secret? Username { get; init; } - public Secret? Password { get; init; } -} - -public record TargetConfigB -{ - public string? Username { get; init; } - public string? Password { get; init; } -} - -public record TargetConfigC -{ - public Secret? CompleteConfig { get; init; } -} - -public class MultiProviderTests -{ - [Fact] - public void MultipleSecretTypes_FromSameSource() - { - var sourceConfig = new SourceConfig - { - Username = "admin", - Password = "secret123", - Port = 8080 - }; - - var configA = new TargetConfigA - { - Username = Secret.FromPlain(sourceConfig.Username!), - Password = Secret.FromPlain(sourceConfig.Password!) - }; - - var configB = new TargetConfigB - { - Username = sourceConfig.Username, - Password = sourceConfig.Password - }; - - var configC = new TargetConfigC - { - CompleteConfig = Secret.FromPlain(sourceConfig) - }; - - using var userLeaseA = configA.Username!.Open(); - using var passLeaseA = configA.Password!.Open(); - using var completeLeaseC = configC.CompleteConfig!.Open(); - - Assert.Equal("admin", userLeaseA.Value); - Assert.Equal("secret123", passLeaseA.Value); - Assert.Equal("admin", configB.Username); - Assert.Equal("secret123", configB.Password); - Assert.Equal("admin", completeLeaseC.Value.Username); - Assert.Equal("secret123", completeLeaseC.Value.Password); - Assert.Equal(8080, completeLeaseC.Value.Port); - } - - [Fact] - public void SameValue_AsSecretString_AndSecretComplexType() - { - var creds = new Credentials("user", "pass", "key"); - - var secretString = Secret.FromPlain("user"); - var secretCreds = Secret.FromPlain(creds); - - using var stringLease = secretString.Open(); - using var credsLease = secretCreds.Open(); - - Assert.Equal("user", stringLease.Value); - Assert.Equal("user", credsLease.Value.Username); - } - - [Fact] - public void MultipleSecrets_SameType_IndependentValues() - { - var secret1 = Secret.FromPlain("value1"); - var secret2 = Secret.FromPlain("value2"); - var secret3 = Secret.FromPlain("value3"); - - using var lease1 = secret1.Open(); - using var lease2 = secret2.Open(); - using var lease3 = secret3.Open(); - - Assert.Equal("value1", lease1.Value); - Assert.Equal("value2", lease2.Value); - Assert.Equal("value3", lease3.Value); - } - - [Fact] - public void MultipleSecrets_DifferentTypes_IndependentValues() - { - var secretString = Secret.FromPlain("text"); - var secretInt = Secret.FromPlain(42); - var secretBool = Secret.FromPlain(true); - var secretCreds = Secret.FromPlain(new Credentials("u", "p", "k")); - - using var stringLease = secretString.Open(); - using var intLease = secretInt.Open(); - using var boolLease = secretBool.Open(); - using var credsLease = secretCreds.Open(); - - Assert.Equal("text", stringLease.Value); - Assert.Equal(42, intLease.Value); - Assert.True(boolLease.Value); - Assert.Equal("u", credsLease.Value.Username); - } - - [Fact] - public void SecretCollection_MultipleItems() - { - var secrets = new List> - { - Secret.FromPlain("secret1"), - Secret.FromPlain("secret2"), - Secret.FromPlain("secret3") - }; - - var values = new List(); - foreach (var secret in secrets) - { - using var lease = secret.Open(); - values.Add(lease.Value); - } - - Assert.Equal(new[] { "secret1", "secret2", "secret3" }, values); - } - - [Fact] - public void NestedSecrets_BothPlainAndSecret() - { - var plainInner = new Inner(new Deep("plain")); - var secretInner = Secret.FromPlain(new Inner(new Deep("secret"))); - - using var lease = secretInner.Open(); - - Assert.Equal("plain", plainInner.Deep.Value); - Assert.Equal("secret", lease.Value.Deep.Value); - } -} +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Tests; + +public record SourceConfig +{ + public string? Username { get; init; } + public string? Password { get; init; } + public int Port { get; init; } +} + +public record TargetConfigA +{ + public Secret? Username { get; init; } + public Secret? Password { get; init; } +} + +public record TargetConfigB +{ + public string? Username { get; init; } + public string? Password { get; init; } +} + +public record TargetConfigC +{ + public Secret? CompleteConfig { get; init; } +} + +public class MultiProviderTests +{ + [Fact] + public void MultipleSecretTypes_FromSameSource() + { + var sourceConfig = new SourceConfig + { + Username = "admin", + Password = "secret123", + Port = 8080 + }; + + var configA = new TargetConfigA + { + Username = Secret.FromPlain(sourceConfig.Username!), + Password = Secret.FromPlain(sourceConfig.Password!) + }; + + var configB = new TargetConfigB + { + Username = sourceConfig.Username, + Password = sourceConfig.Password + }; + + var configC = new TargetConfigC + { + CompleteConfig = Secret.FromPlain(sourceConfig) + }; + + using var userLeaseA = configA.Username!.Open(); + using var passLeaseA = configA.Password!.Open(); + using var completeLeaseC = configC.CompleteConfig!.Open(); + + Assert.Equal("admin", userLeaseA.Value); + Assert.Equal("secret123", passLeaseA.Value); + Assert.Equal("admin", configB.Username); + Assert.Equal("secret123", configB.Password); + Assert.Equal("admin", completeLeaseC.Value.Username); + Assert.Equal("secret123", completeLeaseC.Value.Password); + Assert.Equal(8080, completeLeaseC.Value.Port); + } + + [Fact] + public void SameValue_AsSecretString_AndSecretComplexType() + { + var creds = new Credentials("user", "pass", "key"); + + var secretString = Secret.FromPlain("user"); + var secretCreds = Secret.FromPlain(creds); + + using var stringLease = secretString.Open(); + using var credsLease = secretCreds.Open(); + + Assert.Equal("user", stringLease.Value); + Assert.Equal("user", credsLease.Value.Username); + } + + [Fact] + public void MultipleSecrets_SameType_IndependentValues() + { + var secret1 = Secret.FromPlain("value1"); + var secret2 = Secret.FromPlain("value2"); + var secret3 = Secret.FromPlain("value3"); + + using var lease1 = secret1.Open(); + using var lease2 = secret2.Open(); + using var lease3 = secret3.Open(); + + Assert.Equal("value1", lease1.Value); + Assert.Equal("value2", lease2.Value); + Assert.Equal("value3", lease3.Value); + } + + [Fact] + public void MultipleSecrets_DifferentTypes_IndependentValues() + { + var secretString = Secret.FromPlain("text"); + var secretInt = Secret.FromPlain(42); + var secretBool = Secret.FromPlain(true); + var secretCreds = Secret.FromPlain(new Credentials("u", "p", "k")); + + using var stringLease = secretString.Open(); + using var intLease = secretInt.Open(); + using var boolLease = secretBool.Open(); + using var credsLease = secretCreds.Open(); + + Assert.Equal("text", stringLease.Value); + Assert.Equal(42, intLease.Value); + Assert.True(boolLease.Value); + Assert.Equal("u", credsLease.Value.Username); + } + + [Fact] + public void SecretCollection_MultipleItems() + { + var secrets = new List> + { + Secret.FromPlain("secret1"), + Secret.FromPlain("secret2"), + Secret.FromPlain("secret3") + }; + + var values = new List(); + foreach (var secret in secrets) + { + using var lease = secret.Open(); + values.Add(lease.Value); + } + + Assert.Equal(new[] { "secret1", "secret2", "secret3" }, values); + } + + [Fact] + public void NestedSecrets_BothPlainAndSecret() + { + var plainInner = new Inner(new Deep("plain")); + var secretInner = Secret.FromPlain(new Inner(new Deep("secret"))); + + using var lease = secretInner.Open(); + + Assert.Equal("plain", plainInner.Deep.Value); + Assert.Equal("secret", lease.Value.Deep.Value); + } +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/PrimitivesTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/PrimitivesTests.cs index 0f4dcdc..0f3b14d 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/PrimitivesTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/PrimitivesTests.cs @@ -1,159 +1,159 @@ -using Cocoar.Configuration.Secrets.SecretTypes; - -namespace Cocoar.Configuration.Secrets.Tests; - -public class PrimitivesTests -{ - [Fact] - public void Secret_Int_PlainValue() - { - var secret = Secret.FromPlain(42); - using var lease = secret.Open(); - Assert.Equal(42, lease.Value); - } - - [Fact] - public void Secret_Long_PlainValue() - { - var secret = Secret.FromPlain(123456789L); - using var lease = secret.Open(); - Assert.Equal(123456789L, lease.Value); - } - - [Fact] - public void Secret_Double_PlainValue() - { - var secret = Secret.FromPlain(3.14159); - using var lease = secret.Open(); - Assert.Equal(3.14159, lease.Value, precision: 5); - } - - [Fact] - public void Secret_Decimal_PlainValue() - { - var secret = Secret.FromPlain(99.99m); - using var lease = secret.Open(); - Assert.Equal(99.99m, lease.Value); - } - - [Fact] - public void Secret_Bool_True() - { - var secret = Secret.FromPlain(true); - using var lease = secret.Open(); - Assert.True(lease.Value); - } - - [Fact] - public void Secret_Bool_False() - { - var secret = Secret.FromPlain(false); - using var lease = secret.Open(); - Assert.False(lease.Value); - } - - [Fact] - public void Secret_String_PlainValue() - { - var secret = Secret.FromPlain("hello world"); - using var lease = secret.Open(); - Assert.Equal("hello world", lease.Value); - } - - [Fact] - public void Secret_String_Empty() - { - var secret = Secret.FromPlain(""); - using var lease = secret.Open(); - Assert.Equal("", lease.Value); - } - - [Fact] - public void Secret_String_Multiline() - { - var text = "line1\nline2\r\nline3"; - var secret = Secret.FromPlain(text); - using var lease = secret.Open(); - Assert.Equal(text, lease.Value); - } - - [Fact] - public void Secret_String_SpecialChars() - { - var text = "\"quotes\" and 'apostrophes' and {braces} and [brackets]"; - var secret = Secret.FromPlain(text); - using var lease = secret.Open(); - Assert.Equal(text, lease.Value); - } - - [Fact] - public void Secret_Guid_PlainValue() - { - var guid = Guid.NewGuid(); - var secret = Secret.FromPlain(guid); - using var lease = secret.Open(); - Assert.Equal(guid, lease.Value); - } - - [Fact] - public void Secret_DateTime_PlainValue() - { - var now = DateTime.UtcNow; - var secret = Secret.FromPlain(now); - using var lease = secret.Open(); - Assert.Equal(now, lease.Value); - } - - [Fact] - public void Secret_DateTimeOffset_PlainValue() - { - var now = DateTimeOffset.UtcNow; - var secret = Secret.FromPlain(now); - using var lease = secret.Open(); - Assert.Equal(now, lease.Value); - } - - [Fact] - public void Secret_ByteArray_Base64String() - { - var bytes = new byte[] { 0x01, 0x02, 0x03, 0x04 }; - var secret = Secret.FromPlain(bytes); - using var lease = secret.Open(); - Assert.Equal(bytes, lease.Value); - } - - [Fact] - public void Secret_ByteArray_PlainString() - { - var text = "not base64!@#"; - var bytes = System.Text.Encoding.UTF8.GetBytes(text); - var secret = Secret.FromPlain(bytes); - using var lease = secret.Open(); - Assert.Equal(bytes, lease.Value); - } - - [Fact] - public void Secret_ToString_ReturnsStars() - { - var secret = Secret.FromPlain(42); - Assert.Equal("***", secret.ToString()); - } - - [Fact] - public void Secret_Dispose_ZeroizesBytes() - { - var secret = Secret.FromPlain("sensitive"); - secret.Dispose(); - Assert.Throws(() => secret.Open()); - } - - [Fact] - public void Secret_MultipleOpen_SameValue() - { - var secret = Secret.FromPlain(42); - using var lease1 = secret.Open(); - using var lease2 = secret.Open(); - Assert.Equal(42, lease1.Value); - Assert.Equal(42, lease2.Value); - } -} +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Tests; + +public class PrimitivesTests +{ + [Fact] + public void Secret_Int_PlainValue() + { + var secret = Secret.FromPlain(42); + using var lease = secret.Open(); + Assert.Equal(42, lease.Value); + } + + [Fact] + public void Secret_Long_PlainValue() + { + var secret = Secret.FromPlain(123456789L); + using var lease = secret.Open(); + Assert.Equal(123456789L, lease.Value); + } + + [Fact] + public void Secret_Double_PlainValue() + { + var secret = Secret.FromPlain(3.14159); + using var lease = secret.Open(); + Assert.Equal(3.14159, lease.Value, precision: 5); + } + + [Fact] + public void Secret_Decimal_PlainValue() + { + var secret = Secret.FromPlain(99.99m); + using var lease = secret.Open(); + Assert.Equal(99.99m, lease.Value); + } + + [Fact] + public void Secret_Bool_True() + { + var secret = Secret.FromPlain(true); + using var lease = secret.Open(); + Assert.True(lease.Value); + } + + [Fact] + public void Secret_Bool_False() + { + var secret = Secret.FromPlain(false); + using var lease = secret.Open(); + Assert.False(lease.Value); + } + + [Fact] + public void Secret_String_PlainValue() + { + var secret = Secret.FromPlain("hello world"); + using var lease = secret.Open(); + Assert.Equal("hello world", lease.Value); + } + + [Fact] + public void Secret_String_Empty() + { + var secret = Secret.FromPlain(""); + using var lease = secret.Open(); + Assert.Equal("", lease.Value); + } + + [Fact] + public void Secret_String_Multiline() + { + var text = "line1\nline2\r\nline3"; + var secret = Secret.FromPlain(text); + using var lease = secret.Open(); + Assert.Equal(text, lease.Value); + } + + [Fact] + public void Secret_String_SpecialChars() + { + var text = "\"quotes\" and 'apostrophes' and {braces} and [brackets]"; + var secret = Secret.FromPlain(text); + using var lease = secret.Open(); + Assert.Equal(text, lease.Value); + } + + [Fact] + public void Secret_Guid_PlainValue() + { + var guid = Guid.NewGuid(); + var secret = Secret.FromPlain(guid); + using var lease = secret.Open(); + Assert.Equal(guid, lease.Value); + } + + [Fact] + public void Secret_DateTime_PlainValue() + { + var now = DateTime.UtcNow; + var secret = Secret.FromPlain(now); + using var lease = secret.Open(); + Assert.Equal(now, lease.Value); + } + + [Fact] + public void Secret_DateTimeOffset_PlainValue() + { + var now = DateTimeOffset.UtcNow; + var secret = Secret.FromPlain(now); + using var lease = secret.Open(); + Assert.Equal(now, lease.Value); + } + + [Fact] + public void Secret_ByteArray_Base64String() + { + var bytes = new byte[] { 0x01, 0x02, 0x03, 0x04 }; + var secret = Secret.FromPlain(bytes); + using var lease = secret.Open(); + Assert.Equal(bytes, lease.Value); + } + + [Fact] + public void Secret_ByteArray_PlainString() + { + var text = "not base64!@#"; + var bytes = System.Text.Encoding.UTF8.GetBytes(text); + var secret = Secret.FromPlain(bytes); + using var lease = secret.Open(); + Assert.Equal(bytes, lease.Value); + } + + [Fact] + public void Secret_ToString_ReturnsStars() + { + var secret = Secret.FromPlain(42); + Assert.Equal("***", secret.ToString()); + } + + [Fact] + public void Secret_Dispose_ZeroizesBytes() + { + var secret = Secret.FromPlain("sensitive"); + secret.Dispose(); + Assert.Throws(() => secret.Open()); + } + + [Fact] + public void Secret_MultipleOpen_SameValue() + { + var secret = Secret.FromPlain(42); + using var lease1 = secret.Open(); + using var lease2 = secret.Open(); + Assert.Equal(42, lease1.Value); + Assert.Equal(42, lease2.Value); + } +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/ReplaceSecretsSetupTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/ReplaceSecretsSetupTests.cs index e435bde..e180798 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/ReplaceSecretsSetupTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/ReplaceSecretsSetupTests.cs @@ -1,202 +1,202 @@ -using Cocoar.Configuration.Configure; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Fluent; -using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Secrets; -using Cocoar.Configuration.Secrets.SecretTypes; -using Cocoar.Configuration.Testing; - -namespace Cocoar.Configuration.Secrets.Tests; - -/// -/// Tests for ReplaceSecretsSetup — the per-concern secrets override that is independent of -/// ReplaceConfiguration / AppendConfiguration. -/// -public class ReplaceSecretsSetupTests -{ - public ReplaceSecretsSetupTests() - { - CocoarTestConfiguration.Clear(); - } - - // Helper: activate a secrets-only override via Apply (ReplaceSecretsSetup is a Secrets extension - // on TestOverrideBuilder; CocoarTestConfiguration.ReplaceSecretsSetup takes Delegate and can't - // infer lambda types, so we use the builder pattern for standalone secrets overrides). - private static TestConfigurationScope ApplySecretsOverride(Func configure) - => CocoarTestConfiguration.Apply( - new TestOverrideBuilder().ReplaceSecretsSetup(configure).Build()); - - private record AppConfig - { - public Secret? ApiKey { get; init; } - public string Name { get; init; } = ""; - } - - // ------------------------------------------------------------------ - // Standalone secrets override (no rule replacement) - // ------------------------------------------------------------------ - - [Fact] - public void ReplaceSecretsSetup_AllowsPlaintext_WhenAppCallsUseSecretsSetup() - { - using var _ = ApplySecretsOverride(secrets => secrets.AllowPlaintext()); - - var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromStaticJson("""{"Name":"test","ApiKey":"plain-value"}""") - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext())); // intercepted by test override - - var config = manager.GetConfig(); - Assert.NotNull(config); - using var lease = config.ApiKey!.Open(); - Assert.Equal("plain-value", lease.Value); - } - - [Fact] - public void ReplaceSecretsSetup_Alone_DoesNotAffectRules() - { - // Only secrets setup is overridden; original rules still run - using var _ = ApplySecretsOverride(secrets => secrets.AllowPlaintext()); - - var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromStaticJson("""{"Name":"from-original","ApiKey":"original-key"}""") - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext())); - - var config = manager.GetConfig(); - Assert.NotNull(config); - // Original rule still runs — name comes from the original static JSON - Assert.Equal("from-original", config.Name); - using var lease = config.ApiKey!.Open(); - Assert.Equal("original-key", lease.Value); - } - - [Fact] - public void ReplaceSecretsSetup_DoesNotActivateRulesOverride() - { - using var _ = ApplySecretsOverride(secrets => secrets.AllowPlaintext()); - - // Context is active but ConfigurationMode is null — rules override is NOT set - Assert.True(CocoarTestConfiguration.IsActive); - Assert.Null(CocoarTestConfiguration.Current?.ConfigurationMode); - } - - // ------------------------------------------------------------------ - // Chaining: ReplaceConfiguration + ReplaceSecretsSetup - // ------------------------------------------------------------------ - - [Fact] - public void ReplaceConfiguration_ChainedWithReplaceSecretsSetup_BothApply() - { - using var _ = CocoarTestConfiguration - .ReplaceConfiguration(rule => [ - rule.For().FromStaticJson("""{"Name":"overridden","ApiKey":"chained-key"}""") - ]) - .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); - - var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromStaticJson("""{"Name":"original","ApiKey":"original-key"}""") - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext())); - - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.Equal("overridden", config.Name); - using var lease = config.ApiKey!.Open(); - Assert.Equal("chained-key", lease.Value); - } - - [Fact] - public void AppendConfiguration_ChainedWithReplaceSecretsSetup_BothApply() - { - using var _ = CocoarTestConfiguration - .AppendConfiguration(rule => [ - rule.For().FromStaticJson("""{"Name":"appended"}""") - ]) - .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); - - var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromStaticJson("""{"Name":"original","ApiKey":"base-key"}""") - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext())); - - var config = manager.GetConfig(); - Assert.NotNull(config); - // Last rule wins for Name - Assert.Equal("appended", config.Name); - // ApiKey from base rule - using var lease = config.ApiKey!.Open(); - Assert.Equal("base-key", lease.Value); - } - - // ------------------------------------------------------------------ - // Fixture pattern: TestOverrideBuilder (no auto-activate) - // ------------------------------------------------------------------ - - [Fact] - public void TestOverrideBuilder_WithReplaceSecretsSetup_BuildsThenApply() - { - var context = new TestOverrideBuilder() - .ReplaceConfiguration(rule => [ - rule.For().FromStaticJson("""{"Name":"fixture","ApiKey":"fixture-key"}""") - ]) - .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()) - .Build(); - - // Not yet active - Assert.False(CocoarTestConfiguration.IsActive); - - using var scope = CocoarTestConfiguration.Apply(context); - Assert.True(CocoarTestConfiguration.IsActive); - - var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromStaticJson("""{"Name":"original","ApiKey":"original-key"}""") - ]) - .UseSecretsSetup(secrets => secrets.AllowPlaintext())); - - var config = manager.GetConfig(); - Assert.NotNull(config); - Assert.Equal("fixture", config.Name); - using var lease = config.ApiKey!.Open(); - Assert.Equal("fixture-key", lease.Value); - } - - // ------------------------------------------------------------------ - // Scope / dispose behavior - // ------------------------------------------------------------------ - - [Fact] - public void ReplaceSecretsSetup_ClearsOnDispose() - { - Assert.False(CocoarTestConfiguration.IsActive); - - using (var scope = ApplySecretsOverride(secrets => secrets.AllowPlaintext())) - { - Assert.True(CocoarTestConfiguration.IsActive); - } - - Assert.False(CocoarTestConfiguration.IsActive); - } - - [Fact] - public void ChainedReplaceSecretsSetup_ClearsOnDispose() - { - Assert.False(CocoarTestConfiguration.IsActive); - - using (CocoarTestConfiguration - .ReplaceConfiguration(rule => [ - rule.For().FromStaticJson("""{"Name":"test"}""") - ]) - .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext())) - { - Assert.True(CocoarTestConfiguration.IsActive); - } - - Assert.False(CocoarTestConfiguration.IsActive); - } -} +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.Secrets.SecretTypes; +using Cocoar.Configuration.Testing; + +namespace Cocoar.Configuration.Secrets.Tests; + +/// +/// Tests for ReplaceSecretsSetup — the per-concern secrets override that is independent of +/// ReplaceConfiguration / AppendConfiguration. +/// +public class ReplaceSecretsSetupTests +{ + public ReplaceSecretsSetupTests() + { + CocoarTestConfiguration.Clear(); + } + + // Helper: activate a secrets-only override via Apply (ReplaceSecretsSetup is a Secrets extension + // on TestOverrideBuilder; CocoarTestConfiguration.ReplaceSecretsSetup takes Delegate and can't + // infer lambda types, so we use the builder pattern for standalone secrets overrides). + private static TestConfigurationScope ApplySecretsOverride(Func configure) + => CocoarTestConfiguration.Apply( + new TestOverrideBuilder().ReplaceSecretsSetup(configure).Build()); + + private record AppConfig + { + public Secret? ApiKey { get; init; } + public string Name { get; init; } = ""; + } + + // ------------------------------------------------------------------ + // Standalone secrets override (no rule replacement) + // ------------------------------------------------------------------ + + [Fact] + public void ReplaceSecretsSetup_AllowsPlaintext_WhenAppCallsUseSecretsSetup() + { + using var _ = ApplySecretsOverride(secrets => secrets.AllowPlaintext()); + + var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromStaticJson("""{"Name":"test","ApiKey":"plain-value"}""") + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext())); // intercepted by test override + + var config = manager.GetConfig(); + Assert.NotNull(config); + using var lease = config.ApiKey!.Open(); + Assert.Equal("plain-value", lease.Value); + } + + [Fact] + public void ReplaceSecretsSetup_Alone_DoesNotAffectRules() + { + // Only secrets setup is overridden; original rules still run + using var _ = ApplySecretsOverride(secrets => secrets.AllowPlaintext()); + + var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromStaticJson("""{"Name":"from-original","ApiKey":"original-key"}""") + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext())); + + var config = manager.GetConfig(); + Assert.NotNull(config); + // Original rule still runs — name comes from the original static JSON + Assert.Equal("from-original", config.Name); + using var lease = config.ApiKey!.Open(); + Assert.Equal("original-key", lease.Value); + } + + [Fact] + public void ReplaceSecretsSetup_DoesNotActivateRulesOverride() + { + using var _ = ApplySecretsOverride(secrets => secrets.AllowPlaintext()); + + // Context is active but ConfigurationMode is null — rules override is NOT set + Assert.True(CocoarTestConfiguration.IsActive); + Assert.Null(CocoarTestConfiguration.Current?.ConfigurationMode); + } + + // ------------------------------------------------------------------ + // Chaining: ReplaceConfiguration + ReplaceSecretsSetup + // ------------------------------------------------------------------ + + [Fact] + public void ReplaceConfiguration_ChainedWithReplaceSecretsSetup_BothApply() + { + using var _ = CocoarTestConfiguration + .ReplaceConfiguration(rule => [ + rule.For().FromStaticJson("""{"Name":"overridden","ApiKey":"chained-key"}""") + ]) + .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); + + var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromStaticJson("""{"Name":"original","ApiKey":"original-key"}""") + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext())); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Equal("overridden", config.Name); + using var lease = config.ApiKey!.Open(); + Assert.Equal("chained-key", lease.Value); + } + + [Fact] + public void AppendConfiguration_ChainedWithReplaceSecretsSetup_BothApply() + { + using var _ = CocoarTestConfiguration + .AppendConfiguration(rule => [ + rule.For().FromStaticJson("""{"Name":"appended"}""") + ]) + .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()); + + var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromStaticJson("""{"Name":"original","ApiKey":"base-key"}""") + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext())); + + var config = manager.GetConfig(); + Assert.NotNull(config); + // Last rule wins for Name + Assert.Equal("appended", config.Name); + // ApiKey from base rule + using var lease = config.ApiKey!.Open(); + Assert.Equal("base-key", lease.Value); + } + + // ------------------------------------------------------------------ + // Fixture pattern: TestOverrideBuilder (no auto-activate) + // ------------------------------------------------------------------ + + [Fact] + public void TestOverrideBuilder_WithReplaceSecretsSetup_BuildsThenApply() + { + var context = new TestOverrideBuilder() + .ReplaceConfiguration(rule => [ + rule.For().FromStaticJson("""{"Name":"fixture","ApiKey":"fixture-key"}""") + ]) + .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext()) + .Build(); + + // Not yet active + Assert.False(CocoarTestConfiguration.IsActive); + + using var scope = CocoarTestConfiguration.Apply(context); + Assert.True(CocoarTestConfiguration.IsActive); + + var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromStaticJson("""{"Name":"original","ApiKey":"original-key"}""") + ]) + .UseSecretsSetup(secrets => secrets.AllowPlaintext())); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Equal("fixture", config.Name); + using var lease = config.ApiKey!.Open(); + Assert.Equal("fixture-key", lease.Value); + } + + // ------------------------------------------------------------------ + // Scope / dispose behavior + // ------------------------------------------------------------------ + + [Fact] + public void ReplaceSecretsSetup_ClearsOnDispose() + { + Assert.False(CocoarTestConfiguration.IsActive); + + using (var scope = ApplySecretsOverride(secrets => secrets.AllowPlaintext())) + { + Assert.True(CocoarTestConfiguration.IsActive); + } + + Assert.False(CocoarTestConfiguration.IsActive); + } + + [Fact] + public void ChainedReplaceSecretsSetup_ClearsOnDispose() + { + Assert.False(CocoarTestConfiguration.IsActive); + + using (CocoarTestConfiguration + .ReplaceConfiguration(rule => [ + rule.For().FromStaticJson("""{"Name":"test"}""") + ]) + .ReplaceSecretsSetup(secrets => secrets.AllowPlaintext())) + { + Assert.True(CocoarTestConfiguration.IsActive); + } + + Assert.False(CocoarTestConfiguration.IsActive); + } +} diff --git a/src/tests/Directory.Build.props b/src/tests/Directory.Build.props index 6372b8e..47ba467 100644 --- a/src/tests/Directory.Build.props +++ b/src/tests/Directory.Build.props @@ -1,23 +1,23 @@ - - - - - - - false - - - - - - - - - - - - - - $(NoWarn);CA1707;CA1816;CA1822;CA1852;CA1310;CA1305;CA1859;CA1861;CA1836;CA1805;CA1001;CA1869;CS0618;CS8625 - - + + + + + + + false + + + + + + + + + + + + + + $(NoWarn);CA1707;CA1816;CA1822;CA1852;CA1310;CA1305;CA1859;CA1861;CA1836;CA1805;CA1001;CA1869;CS0618;CS8625 + + diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index b6ba5aa..a41d564 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -1,215 +1,215 @@ -import { defineConfig } from 'vitepress' -import llmstxt from 'vitepress-plugin-llms' - -export default defineConfig({ - title: 'Cocoar.Configuration', - description: 'Reactive, strongly-typed configuration for .NET', - - head: [ - ['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo_light.svg' }], - ['link', { rel: 'alternate', type: 'text/plain', href: '/llms.txt', title: 'LLM documentation (summary)' }], - ['link', { rel: 'alternate', type: 'text/plain', href: '/llms-full.txt', title: 'LLM documentation (full)' }], - ], - - vite: { - plugins: [llmstxt({ - excludeUnnecessaryFiles: false, - ignoreFiles: ['changelog.md'], - })], - }, - - themeConfig: { - logo: { - light: '/logo_light.svg', - dark: '/logo_dark.svg', - }, - - siteTitle: 'Cocoar.Configuration v5', - - nav: [ - { text: 'Guide', link: '/guide/getting-started' }, - { text: 'Reference', link: '/reference/packages' }, - { text: 'ADR', link: '/adr/' }, - { text: 'Roadmap', link: '/roadmap/overview' }, - { text: 'Changelog', link: '/changelog' }, - { text: 'LLM Docs', link: '/llms-full.txt', target: '_blank' }, - { text: 'NuGet', link: 'https://www.nuget.org/packages/Cocoar.Configuration' }, - ], - - sidebar: { - '/guide/': [ - { - text: 'Introduction', - items: [ - { text: 'Getting Started', link: '/guide/getting-started' }, - { text: 'Why Cocoar?', link: '/guide/why-cocoar' }, - { text: 'Working with Certificates', link: '/guide/certificates' }, - ], - }, - { - text: 'Configuration', - items: [ - { text: 'Rules & Layering', link: '/guide/configuration/rules' }, - { text: 'Required vs Optional', link: '/guide/configuration/required-optional' }, - { text: 'Setup & Type Exposure', link: '/guide/configuration/setup' }, - { text: 'Config-Aware Rules ', link: '/guide/configuration/config-aware' }, - { text: 'Conditional Rules ', link: '/guide/configuration/conditional-rules' }, - { text: 'Aggregate Rules ', link: '/guide/configuration/aggregate-rules' }, - ], - }, - { - text: 'Providers', - items: [ - { text: 'Overview', link: '/guide/providers/overview' }, - { text: 'File', link: '/guide/providers/file' }, - { text: 'Environment Variables', link: '/guide/providers/environment' }, - { text: 'Command Line', link: '/guide/providers/command-line' }, - { text: 'HTTP Polling', link: '/guide/providers/http-polling' }, - { text: 'Microsoft IConfiguration', link: '/guide/providers/microsoft-adapter' }, - { text: 'Static & Observable', link: '/guide/providers/static-observable' }, - { text: 'Writable Store', link: '/guide/providers/writable-store' }, - { text: 'Custom Providers ', link: '/guide/providers/custom' }, - ], - }, - { - text: 'Dependency Injection', - items: [ - { text: 'DI Setup', link: '/guide/di/setup' }, - { text: 'ASP.NET Core', link: '/guide/di/aspnetcore' }, - { text: 'Lifetimes & Registration ', link: '/guide/di/lifetimes' }, - { text: 'Service-Backed Config ', link: '/guide/di/service-backed' }, - ], - }, - { - text: 'Reactive Updates', - items: [ - { text: 'IReactiveConfig', link: '/guide/reactive/basics' }, - { text: 'Reactive Tuples ', link: '/guide/reactive/tuples' }, - { text: 'Debouncing', link: '/guide/reactive/debouncing' }, - ], - }, - { - text: 'Feature Flags & Entitlements', - items: [ - { text: 'Concepts', link: '/guide/flags/concepts' }, - { text: 'Defining Flags', link: '/guide/flags/defining-flags' }, - { text: 'Defining Entitlements', link: '/guide/flags/defining-entitlements' }, - { text: 'Registration', link: '/guide/flags/registration' }, - { text: 'Context Resolvers ', link: '/guide/flags/context-resolvers' }, - { text: 'REST Endpoints ', link: '/guide/flags/rest-endpoints' }, - { text: 'Expiry & Health', link: '/guide/flags/expiry-health' }, - ], - }, - { - text: 'Multi-Tenancy', - items: [ - { text: 'Overview ', link: '/guide/multi-tenancy/overview' }, - ], - }, - { - text: 'Secrets', - items: [ - { text: 'Overview', link: '/guide/secrets/overview' }, - { text: 'Secret & Leases', link: '/guide/secrets/secret-type' }, - { text: 'Encryption Setup', link: '/guide/secrets/encryption-setup' }, - { text: 'Publishing Encryption Keys ', link: '/guide/secrets/key-publishing' }, - { text: 'Browser & Client Encryption ', link: '/guide/secrets/client-encryption' }, - { text: 'CLI Tools', link: '/guide/secrets/cli' }, - { text: 'Certificate Caching ', link: '/guide/secrets/certificate-caching' }, - { text: 'Security Model ', link: '/guide/secrets/security-model' }, - ], - }, - { - text: 'Health Monitoring', - items: [ - { text: 'Overview', link: '/guide/health/overview' }, - { text: 'ASP.NET Core Health Checks', link: '/guide/health/aspnetcore' }, - { text: 'Logging & Diagnostics ', link: '/guide/health/logging' }, - { text: 'Performance ', link: '/guide/health/performance' }, - ], - }, - { - text: 'Testing', - items: [ - { text: 'Test Overrides', link: '/guide/testing/overrides' }, - { text: 'Integration Testing', link: '/guide/testing/integration' }, - { text: 'Testing Strategy ', link: '/guide/testing/strategy' }, - ], - }, - { - text: 'Analyzers', - items: [ - { text: 'Overview', link: '/guide/analyzers/overview' }, - { text: 'Configuration Diagnostics', link: '/guide/analyzers/configuration' }, - { text: 'Flags Diagnostics', link: '/guide/analyzers/flags' }, - ], - }, - { - text: 'How-To', - items: [ - { text: 'From IOptions', link: '/guide/how-to/from-ioptions' }, - ], - }, - { - text: 'Migration', - collapsed: true, - items: [ - { text: 'v4 → v5', link: '/guide/migration/v4-to-v5' }, - { text: 'v3 → v4', link: '/guide/migration/v3-to-v4' }, - { text: 'v2 → v3', link: '/guide/migration/v2-to-v3' }, - ], - }, - ], - '/reference/': [ - { - text: 'Reference', - items: [ - { text: 'Package Overview', link: '/reference/packages' }, - { text: 'Health API', link: '/reference/health-api' }, - { text: 'CLI Commands', link: '/reference/cli-commands' }, - { text: 'Analyzer Diagnostics', link: '/reference/analyzer-diagnostics' }, - { text: 'Examples', link: '/reference/examples' }, - ], - }, - ], - '/roadmap/': [ - { - text: 'Roadmap', - items: [ - { text: 'Overview', link: '/roadmap/overview' }, - { text: 'ConfigHub', link: '/roadmap/confighub' }, - { text: 'Cloud Providers', link: '/roadmap/cloud-providers' }, - { text: 'Database Provider', link: '/roadmap/database-provider' }, - ], - }, - ], - '/adr/': [ - { - text: 'Architecture Decision Records', - items: [ - { text: 'Overview', link: '/adr/' }, - { text: 'ADR-001 · Capabilities System', link: '/adr/ADR-001-capabilities-system' }, - { text: 'ADR-002 · Atomic Reactive Updates', link: '/adr/ADR-002-atomic-reactive-updates' }, - { text: 'ADR-003 · Provider Consistency', link: '/adr/ADR-003-provider-consistency-empty-objects' }, - { text: 'ADR-004 · Aggregate Rules', link: '/adr/ADR-004-aggregate-rules' }, - { text: 'ADR-005 · Multi-Tenant Configuration', link: '/adr/ADR-005-multi-tenant-configuration' }, - { text: 'ADR-006 · DI-aware Configuration', link: '/adr/ADR-006-di-aware-configuration' }, - ], - }, - ], - }, - - socialLinks: [ - { icon: 'github', link: 'https://github.com/cocoar-dev/Cocoar.Configuration' }, - ], - - search: { - provider: 'local', - }, - - footer: { - message: 'Released under the Apache-2.0 License.', - copyright: 'Copyright 2025-present Cocoar', - }, - }, -}) +import { defineConfig } from 'vitepress' +import llmstxt from 'vitepress-plugin-llms' + +export default defineConfig({ + title: 'Cocoar.Configuration', + description: 'Reactive, strongly-typed configuration for .NET', + + head: [ + ['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo_light.svg' }], + ['link', { rel: 'alternate', type: 'text/plain', href: '/llms.txt', title: 'LLM documentation (summary)' }], + ['link', { rel: 'alternate', type: 'text/plain', href: '/llms-full.txt', title: 'LLM documentation (full)' }], + ], + + vite: { + plugins: [llmstxt({ + excludeUnnecessaryFiles: false, + ignoreFiles: ['changelog.md'], + })], + }, + + themeConfig: { + logo: { + light: '/logo_light.svg', + dark: '/logo_dark.svg', + }, + + siteTitle: 'Cocoar.Configuration v5', + + nav: [ + { text: 'Guide', link: '/guide/getting-started' }, + { text: 'Reference', link: '/reference/packages' }, + { text: 'ADR', link: '/adr/' }, + { text: 'Roadmap', link: '/roadmap/overview' }, + { text: 'Changelog', link: '/changelog' }, + { text: 'LLM Docs', link: '/llms-full.txt', target: '_blank' }, + { text: 'NuGet', link: 'https://www.nuget.org/packages/Cocoar.Configuration' }, + ], + + sidebar: { + '/guide/': [ + { + text: 'Introduction', + items: [ + { text: 'Getting Started', link: '/guide/getting-started' }, + { text: 'Why Cocoar?', link: '/guide/why-cocoar' }, + { text: 'Working with Certificates', link: '/guide/certificates' }, + ], + }, + { + text: 'Configuration', + items: [ + { text: 'Rules & Layering', link: '/guide/configuration/rules' }, + { text: 'Required vs Optional', link: '/guide/configuration/required-optional' }, + { text: 'Setup & Type Exposure', link: '/guide/configuration/setup' }, + { text: 'Config-Aware Rules ', link: '/guide/configuration/config-aware' }, + { text: 'Conditional Rules ', link: '/guide/configuration/conditional-rules' }, + { text: 'Aggregate Rules ', link: '/guide/configuration/aggregate-rules' }, + ], + }, + { + text: 'Providers', + items: [ + { text: 'Overview', link: '/guide/providers/overview' }, + { text: 'File', link: '/guide/providers/file' }, + { text: 'Environment Variables', link: '/guide/providers/environment' }, + { text: 'Command Line', link: '/guide/providers/command-line' }, + { text: 'HTTP Polling', link: '/guide/providers/http-polling' }, + { text: 'Microsoft IConfiguration', link: '/guide/providers/microsoft-adapter' }, + { text: 'Static & Observable', link: '/guide/providers/static-observable' }, + { text: 'Writable Store', link: '/guide/providers/writable-store' }, + { text: 'Custom Providers ', link: '/guide/providers/custom' }, + ], + }, + { + text: 'Dependency Injection', + items: [ + { text: 'DI Setup', link: '/guide/di/setup' }, + { text: 'ASP.NET Core', link: '/guide/di/aspnetcore' }, + { text: 'Lifetimes & Registration ', link: '/guide/di/lifetimes' }, + { text: 'Service-Backed Config ', link: '/guide/di/service-backed' }, + ], + }, + { + text: 'Reactive Updates', + items: [ + { text: 'IReactiveConfig', link: '/guide/reactive/basics' }, + { text: 'Reactive Tuples ', link: '/guide/reactive/tuples' }, + { text: 'Debouncing', link: '/guide/reactive/debouncing' }, + ], + }, + { + text: 'Feature Flags & Entitlements', + items: [ + { text: 'Concepts', link: '/guide/flags/concepts' }, + { text: 'Defining Flags', link: '/guide/flags/defining-flags' }, + { text: 'Defining Entitlements', link: '/guide/flags/defining-entitlements' }, + { text: 'Registration', link: '/guide/flags/registration' }, + { text: 'Context Resolvers ', link: '/guide/flags/context-resolvers' }, + { text: 'REST Endpoints ', link: '/guide/flags/rest-endpoints' }, + { text: 'Expiry & Health', link: '/guide/flags/expiry-health' }, + ], + }, + { + text: 'Multi-Tenancy', + items: [ + { text: 'Overview ', link: '/guide/multi-tenancy/overview' }, + ], + }, + { + text: 'Secrets', + items: [ + { text: 'Overview', link: '/guide/secrets/overview' }, + { text: 'Secret & Leases', link: '/guide/secrets/secret-type' }, + { text: 'Encryption Setup', link: '/guide/secrets/encryption-setup' }, + { text: 'Publishing Encryption Keys ', link: '/guide/secrets/key-publishing' }, + { text: 'Browser & Client Encryption ', link: '/guide/secrets/client-encryption' }, + { text: 'CLI Tools', link: '/guide/secrets/cli' }, + { text: 'Certificate Caching ', link: '/guide/secrets/certificate-caching' }, + { text: 'Security Model ', link: '/guide/secrets/security-model' }, + ], + }, + { + text: 'Health Monitoring', + items: [ + { text: 'Overview', link: '/guide/health/overview' }, + { text: 'ASP.NET Core Health Checks', link: '/guide/health/aspnetcore' }, + { text: 'Logging & Diagnostics ', link: '/guide/health/logging' }, + { text: 'Performance ', link: '/guide/health/performance' }, + ], + }, + { + text: 'Testing', + items: [ + { text: 'Test Overrides', link: '/guide/testing/overrides' }, + { text: 'Integration Testing', link: '/guide/testing/integration' }, + { text: 'Testing Strategy ', link: '/guide/testing/strategy' }, + ], + }, + { + text: 'Analyzers', + items: [ + { text: 'Overview', link: '/guide/analyzers/overview' }, + { text: 'Configuration Diagnostics', link: '/guide/analyzers/configuration' }, + { text: 'Flags Diagnostics', link: '/guide/analyzers/flags' }, + ], + }, + { + text: 'How-To', + items: [ + { text: 'From IOptions', link: '/guide/how-to/from-ioptions' }, + ], + }, + { + text: 'Migration', + collapsed: true, + items: [ + { text: 'v4 → v5', link: '/guide/migration/v4-to-v5' }, + { text: 'v3 → v4', link: '/guide/migration/v3-to-v4' }, + { text: 'v2 → v3', link: '/guide/migration/v2-to-v3' }, + ], + }, + ], + '/reference/': [ + { + text: 'Reference', + items: [ + { text: 'Package Overview', link: '/reference/packages' }, + { text: 'Health API', link: '/reference/health-api' }, + { text: 'CLI Commands', link: '/reference/cli-commands' }, + { text: 'Analyzer Diagnostics', link: '/reference/analyzer-diagnostics' }, + { text: 'Examples', link: '/reference/examples' }, + ], + }, + ], + '/roadmap/': [ + { + text: 'Roadmap', + items: [ + { text: 'Overview', link: '/roadmap/overview' }, + { text: 'ConfigHub', link: '/roadmap/confighub' }, + { text: 'Cloud Providers', link: '/roadmap/cloud-providers' }, + { text: 'Database Provider', link: '/roadmap/database-provider' }, + ], + }, + ], + '/adr/': [ + { + text: 'Architecture Decision Records', + items: [ + { text: 'Overview', link: '/adr/' }, + { text: 'ADR-001 · Capabilities System', link: '/adr/ADR-001-capabilities-system' }, + { text: 'ADR-002 · Atomic Reactive Updates', link: '/adr/ADR-002-atomic-reactive-updates' }, + { text: 'ADR-003 · Provider Consistency', link: '/adr/ADR-003-provider-consistency-empty-objects' }, + { text: 'ADR-004 · Aggregate Rules', link: '/adr/ADR-004-aggregate-rules' }, + { text: 'ADR-005 · Multi-Tenant Configuration', link: '/adr/ADR-005-multi-tenant-configuration' }, + { text: 'ADR-006 · DI-aware Configuration', link: '/adr/ADR-006-di-aware-configuration' }, + ], + }, + ], + }, + + socialLinks: [ + { icon: 'github', link: 'https://github.com/cocoar-dev/Cocoar.Configuration' }, + ], + + search: { + provider: 'local', + }, + + footer: { + message: 'Released under the Apache-2.0 License.', + copyright: 'Copyright 2025-present Cocoar', + }, + }, +}) diff --git a/website/adr/ADR-001-capabilities-system.md b/website/adr/ADR-001-capabilities-system.md index df395a8..36a972e 100644 --- a/website/adr/ADR-001-capabilities-system.md +++ b/website/adr/ADR-001-capabilities-system.md @@ -1,215 +1,215 @@ -# ADR-001: Using Cocoar.Capabilities for Cross-Assembly Extensibility - -**Status:** Accepted -**Date:** 2024-09-14 -**Decision Makers:** Core Team - ---- - -## Context - -Cocoar.Configuration has a **fundamental architectural requirement**: extension methods from separate assemblies (like `Cocoar.Configuration.DI`) need to attach metadata to builder objects from the core assembly without creating circular dependencies. - -### The Problem - -Consider this user-facing API: - -```csharp -builder.AddCocoarConfiguration(rule => [ - rule.For().FromFile("config.json") -], setup => [ - setup.ConcreteType().ExposeAs(), // Core assembly - setup.ConcreteType().AsSingleton() // DI assembly -]); -``` - -**Requirements:** -1. Core assembly defines `ConcreteTypeSetup` with `.ExposeAs()` method -2. DI assembly adds `.AsSingleton()` extension method to the same builder type -3. Both methods must attach metadata to the **same builder instance** -4. Core assembly **cannot reference** DI assembly (would create circular dependency) -5. DI assembly needs to retrieve **all metadata** from all builders at registration time -6. The fluent API must remain clean and chainable -7. Third parties should be able to add their own extensions - -### Why Standard .NET Patterns Don't Work - -| Pattern | Why It Fails | -|---------|--------------| -| **Dictionary<object, object>** | No type safety, can't compose multiple metadata types, external global state | -| **Reflection Attributes** | Compile-time only, can't configure same type differently in different contexts | -| **Method Parameters** | Destroys fluent API, parameter explosion, not extensible from other assemblies | -| **Builder Internal State** | Core must know about ALL extension metadata types → circular dependencies | - -After several days exploring alternatives, we concluded: **There is no standard .NET pattern that solves this problem without compromising on quality.** - ---- - -## Decision - -We will use **[Cocoar.Capabilities](https://github.com/cocoar-dev/Cocoar.Capabilities)**, a separate open-source library that enables type-safe metadata attachment across assembly boundaries. - -### Why This Library? - -1. **Solves the exact problem** - Designed specifically for cross-assembly metadata composition -2. **Zero coupling** - Core and DI assemblies remain completely independent -3. **Type-safe** - All metadata is strongly typed at compile time -4. **Proven pattern** - Used successfully in production -5. **Reusable** - Separate library means it can be used in other projects -6. **Third-party extensible** - Anyone can add capabilities without modifying our code - -### How We Use It - -**Core Assembly** attaches primary and core metadata: -```csharp -public sealed class ConcreteTypeSetup : SetupDefinition -{ - internal ConcreteTypeSetup(ConfigManagerCapabilityScope capabilityScope) - : base(capabilityScope) - { - capabilityScope.Compose(this).WithPrimary( - new ConcreteTypePrimary(typeof(T))); - } - - public ConcreteTypeSetup ExposeAs() - { - GetComposer(this).Add( - new ExposeAsCapability(typeof(TInterface))); - return this; - } -} -``` - -**DI Assembly** extends without coupling: -```csharp -public static class ConcreteTypeSetupExtensions -{ - public static ConcreteTypeSetup AsSingleton(this ConcreteTypeSetup builder) - { - SetupDefinition.GetComposer(builder).Add( - new ServiceLifetimeCapability(ServiceLifetime.Singleton, null)); - return builder; - } -} -``` - -**Registration Time** retrieves all metadata: -```csharp -foreach (var builder in configManager.SetupDefinitions) -{ - if (!scope.Compositions.TryGet(builder, out var composition)) - continue; - - // Get metadata from ANY assembly that added capabilities - var typeCapability = composition.TryGetPrimaryAs>(); - var lifetimes = composition.GetAll>(); - var exposures = composition.GetAll>(); - - // Use all metadata for registration -} -``` - ---- - -## Consequences - -### Positive -✅ **Zero Coupling** - Core and DI assemblies are completely independent -✅ **Type Safety** - All capabilities are strongly typed at compile time -✅ **Fluent API Preserved** - Method chaining works seamlessly -✅ **Extensible** - Third parties can add their own capabilities -✅ **Testable** - Capabilities can be mocked and verified independently - -### Trade-offs -⚠️ **Additional Dependency** - Requires Cocoar.Capabilities package -⚠️ **Learning Curve** - Contributors must understand the Capabilities pattern -⚠️ **Debugging Indirection** - Capability composition harder to trace than direct fields - -**Mitigation:** This ADR and inline documentation explain the pattern. The complexity is hidden from end users - they just use the fluent API. - ---- - -## Alternatives Considered - -### Alternative 1: Accept Circular Dependencies -Make Core reference DI, DI reference Core. - -**Rejected:** Violates clean architecture, makes testing difficult, prevents third-party extensions. - -### Alternative 2: ConditionalWeakTable -Use .NET's ConditionalWeakTable for metadata storage. - -**Rejected:** Still lacks type safety and composition. Cannot distinguish primary vs secondary metadata. - -### Alternative 3: Event-Based Registration -Use events to notify about builder creation and metadata. - -**Rejected:** Ordering issues, no clear ownership, difficult to query later, thread safety nightmares. - -### Alternative 4: Custom Metadata Interface -Create `IMetadataCarrier` interface for builders. - -**Rejected:** Requires all builders to implement interface (intrusive), string-keyed dictionaries lose type safety, doesn't solve cross-assembly attachment. - ---- - -## Implementation Details - -### Key Classes -- `ConfigManagerCapabilityScope` - Manages capability lifetime for this ConfigManager instance -- `SetupDefinition` - Abstract base that provides access to `CapabilityScope` and `Composer` -- `ConcreteTypeSetup` / `InterfaceTypeSetup` - Builders that compose capabilities -- `ExposureRegistry` - Reads capabilities at registration time - -### Capability Records -- `ConcreteTypePrimary` - Primary: The type being configured -- `ExposeAsCapability` - Secondary: Interface exposure for DI -- `DeserializeToCapability` - Secondary: Interface→Concrete mapping for deserialization -- `ServiceLifetimeCapability` - Secondary (DI): Service lifetime metadata - -### Thread Safety -Cocoar.Capabilities handles thread safety internally using concurrent collections. Composers are immutable after Build(). - ---- - -## References - -### External -- **Cocoar.Capabilities Repository:** https://github.com/cocoar-dev/Cocoar.Capabilities -- **Blog Post (Context):** [The Cross-Assembly Metadata Problem in .NET](https://dev.to/bwi/the-cross-assembly-metadata-problem-in-net-and-how-i-solved-it-14eo) - -### Internal -- `Core/ConfigManagerCapabilityScope.cs` - Scope implementation -- `Configure/SetupBuilder.cs` - Builder API using Capabilities -- `Infrastructure/ExposureRegistry.cs` - Capability retrieval example -- `DI/ConcreteTypeSetupExtensions.cs` - Cross-assembly extension example - ---- - -## FAQ - -**Q: Why not just use a dictionary and accept the type-safety loss?** -A: Type safety isn't just about compile-time errors - it's about maintainability. When adding a new capability type, the compiler helps find all places that need updates. With untyped dictionaries, we lose that safety net. - -**Q: Doesn't this feel like over-engineering?** -A: The problem only appears simple because the requirements are subtle. We explored simpler solutions for several days before choosing Capabilities - none worked without compromising on architecture or extensibility. - -**Q: What if Cocoar.Capabilities changes or breaks compatibility?** -A: Both libraries are maintained by the same author/team. Breaking changes would be coordinated across both projects. The separation provides architectural benefits (reusability, clear boundaries) without introducing external dependency risk. - -**Q: Why create a separate library instead of keeping it internal?** -A: (1) The pattern is reusable across other projects. (2) Clear separation of concerns - Capabilities is a general-purpose library. (3) Forces a clean API boundary. (4) Can be used by third-party extensions to this project. - ---- - -**Status:** ✅ Accepted and Implemented -**Next Review:** When significant new extension patterns emerge or if third-party extension requirements change - ---- - -**Revision History:** - -| Date | Version | Changes | Author | -|------|---------|---------|--------| -| 2025-11-12 | 1.0 | Initial ADR | Core Team | - +# ADR-001: Using Cocoar.Capabilities for Cross-Assembly Extensibility + +**Status:** Accepted +**Date:** 2024-09-14 +**Decision Makers:** Core Team + +--- + +## Context + +Cocoar.Configuration has a **fundamental architectural requirement**: extension methods from separate assemblies (like `Cocoar.Configuration.DI`) need to attach metadata to builder objects from the core assembly without creating circular dependencies. + +### The Problem + +Consider this user-facing API: + +```csharp +builder.AddCocoarConfiguration(rule => [ + rule.For().FromFile("config.json") +], setup => [ + setup.ConcreteType().ExposeAs(), // Core assembly + setup.ConcreteType().AsSingleton() // DI assembly +]); +``` + +**Requirements:** +1. Core assembly defines `ConcreteTypeSetup` with `.ExposeAs()` method +2. DI assembly adds `.AsSingleton()` extension method to the same builder type +3. Both methods must attach metadata to the **same builder instance** +4. Core assembly **cannot reference** DI assembly (would create circular dependency) +5. DI assembly needs to retrieve **all metadata** from all builders at registration time +6. The fluent API must remain clean and chainable +7. Third parties should be able to add their own extensions + +### Why Standard .NET Patterns Don't Work + +| Pattern | Why It Fails | +|---------|--------------| +| **Dictionary<object, object>** | No type safety, can't compose multiple metadata types, external global state | +| **Reflection Attributes** | Compile-time only, can't configure same type differently in different contexts | +| **Method Parameters** | Destroys fluent API, parameter explosion, not extensible from other assemblies | +| **Builder Internal State** | Core must know about ALL extension metadata types → circular dependencies | + +After several days exploring alternatives, we concluded: **There is no standard .NET pattern that solves this problem without compromising on quality.** + +--- + +## Decision + +We will use **[Cocoar.Capabilities](https://github.com/cocoar-dev/Cocoar.Capabilities)**, a separate open-source library that enables type-safe metadata attachment across assembly boundaries. + +### Why This Library? + +1. **Solves the exact problem** - Designed specifically for cross-assembly metadata composition +2. **Zero coupling** - Core and DI assemblies remain completely independent +3. **Type-safe** - All metadata is strongly typed at compile time +4. **Proven pattern** - Used successfully in production +5. **Reusable** - Separate library means it can be used in other projects +6. **Third-party extensible** - Anyone can add capabilities without modifying our code + +### How We Use It + +**Core Assembly** attaches primary and core metadata: +```csharp +public sealed class ConcreteTypeSetup : SetupDefinition +{ + internal ConcreteTypeSetup(ConfigManagerCapabilityScope capabilityScope) + : base(capabilityScope) + { + capabilityScope.Compose(this).WithPrimary( + new ConcreteTypePrimary(typeof(T))); + } + + public ConcreteTypeSetup ExposeAs() + { + GetComposer(this).Add( + new ExposeAsCapability(typeof(TInterface))); + return this; + } +} +``` + +**DI Assembly** extends without coupling: +```csharp +public static class ConcreteTypeSetupExtensions +{ + public static ConcreteTypeSetup AsSingleton(this ConcreteTypeSetup builder) + { + SetupDefinition.GetComposer(builder).Add( + new ServiceLifetimeCapability(ServiceLifetime.Singleton, null)); + return builder; + } +} +``` + +**Registration Time** retrieves all metadata: +```csharp +foreach (var builder in configManager.SetupDefinitions) +{ + if (!scope.Compositions.TryGet(builder, out var composition)) + continue; + + // Get metadata from ANY assembly that added capabilities + var typeCapability = composition.TryGetPrimaryAs>(); + var lifetimes = composition.GetAll>(); + var exposures = composition.GetAll>(); + + // Use all metadata for registration +} +``` + +--- + +## Consequences + +### Positive +✅ **Zero Coupling** - Core and DI assemblies are completely independent +✅ **Type Safety** - All capabilities are strongly typed at compile time +✅ **Fluent API Preserved** - Method chaining works seamlessly +✅ **Extensible** - Third parties can add their own capabilities +✅ **Testable** - Capabilities can be mocked and verified independently + +### Trade-offs +⚠️ **Additional Dependency** - Requires Cocoar.Capabilities package +⚠️ **Learning Curve** - Contributors must understand the Capabilities pattern +⚠️ **Debugging Indirection** - Capability composition harder to trace than direct fields + +**Mitigation:** This ADR and inline documentation explain the pattern. The complexity is hidden from end users - they just use the fluent API. + +--- + +## Alternatives Considered + +### Alternative 1: Accept Circular Dependencies +Make Core reference DI, DI reference Core. + +**Rejected:** Violates clean architecture, makes testing difficult, prevents third-party extensions. + +### Alternative 2: ConditionalWeakTable +Use .NET's ConditionalWeakTable for metadata storage. + +**Rejected:** Still lacks type safety and composition. Cannot distinguish primary vs secondary metadata. + +### Alternative 3: Event-Based Registration +Use events to notify about builder creation and metadata. + +**Rejected:** Ordering issues, no clear ownership, difficult to query later, thread safety nightmares. + +### Alternative 4: Custom Metadata Interface +Create `IMetadataCarrier` interface for builders. + +**Rejected:** Requires all builders to implement interface (intrusive), string-keyed dictionaries lose type safety, doesn't solve cross-assembly attachment. + +--- + +## Implementation Details + +### Key Classes +- `ConfigManagerCapabilityScope` - Manages capability lifetime for this ConfigManager instance +- `SetupDefinition` - Abstract base that provides access to `CapabilityScope` and `Composer` +- `ConcreteTypeSetup` / `InterfaceTypeSetup` - Builders that compose capabilities +- `ExposureRegistry` - Reads capabilities at registration time + +### Capability Records +- `ConcreteTypePrimary` - Primary: The type being configured +- `ExposeAsCapability` - Secondary: Interface exposure for DI +- `DeserializeToCapability` - Secondary: Interface→Concrete mapping for deserialization +- `ServiceLifetimeCapability` - Secondary (DI): Service lifetime metadata + +### Thread Safety +Cocoar.Capabilities handles thread safety internally using concurrent collections. Composers are immutable after Build(). + +--- + +## References + +### External +- **Cocoar.Capabilities Repository:** https://github.com/cocoar-dev/Cocoar.Capabilities +- **Blog Post (Context):** [The Cross-Assembly Metadata Problem in .NET](https://dev.to/bwi/the-cross-assembly-metadata-problem-in-net-and-how-i-solved-it-14eo) + +### Internal +- `Core/ConfigManagerCapabilityScope.cs` - Scope implementation +- `Configure/SetupBuilder.cs` - Builder API using Capabilities +- `Infrastructure/ExposureRegistry.cs` - Capability retrieval example +- `DI/ConcreteTypeSetupExtensions.cs` - Cross-assembly extension example + +--- + +## FAQ + +**Q: Why not just use a dictionary and accept the type-safety loss?** +A: Type safety isn't just about compile-time errors - it's about maintainability. When adding a new capability type, the compiler helps find all places that need updates. With untyped dictionaries, we lose that safety net. + +**Q: Doesn't this feel like over-engineering?** +A: The problem only appears simple because the requirements are subtle. We explored simpler solutions for several days before choosing Capabilities - none worked without compromising on architecture or extensibility. + +**Q: What if Cocoar.Capabilities changes or breaks compatibility?** +A: Both libraries are maintained by the same author/team. Breaking changes would be coordinated across both projects. The separation provides architectural benefits (reusability, clear boundaries) without introducing external dependency risk. + +**Q: Why create a separate library instead of keeping it internal?** +A: (1) The pattern is reusable across other projects. (2) Clear separation of concerns - Capabilities is a general-purpose library. (3) Forces a clean API boundary. (4) Can be used by third-party extensions to this project. + +--- + +**Status:** ✅ Accepted and Implemented +**Next Review:** When significant new extension patterns emerge or if third-party extension requirements change + +--- + +**Revision History:** + +| Date | Version | Changes | Author | +|------|---------|---------|--------| +| 2025-11-12 | 1.0 | Initial ADR | Core Team | + diff --git a/website/adr/ADR-002-atomic-reactive-updates.md b/website/adr/ADR-002-atomic-reactive-updates.md index 336c675..998050d 100644 --- a/website/adr/ADR-002-atomic-reactive-updates.md +++ b/website/adr/ADR-002-atomic-reactive-updates.md @@ -1,718 +1,718 @@ -# ADR-002: Atomic Reactive Configuration Updates - -**Status:** Accepted -**Date:** 2024-09-14 -**Decision Makers:** Core Team -**Related:** ADR-001 (Capabilities System) - ---- - -## Context - -Configuration in distributed systems has a fundamental challenge: **how do you notify subscribers of changes across multiple related configuration types without exposing them to inconsistent state?** - -### The Problem: Partial Updates - -Consider a typical application with related configurations: - -```csharp -public class AppSettings -{ - public string ApiUrl { get; set; } - public int Timeout { get; set; } -} - -public class DatabaseSettings -{ - public string ConnectionString { get; set; } - public int PoolSize { get; set; } -} -``` - -When a configuration file changes that affects **both** types, subscribers need to see them update **atomically**. Seeing new `AppSettings` with old `DatabaseSettings` could cause: - -- API calls to new endpoints with old timeouts -- Database connections with mismatched pool sizes -- Race conditions during the update window -- Unpredictable behavior in dependent services - -### Why Standard Patterns Fail - -**Microsoft's IOptionsMonitor<T>:** - -```csharp -services.Configure(config.GetSection("App")); -services.Configure(config.GetSection("Database")); - -// In your service: -_appMonitor.OnChange(newApp => { /* update */ }); -_dbMonitor.OnChange(newDb => { /* update */ }); -``` - -**Problems:** -- ❌ Two separate notification streams - no atomicity guarantee -- ❌ Observer for `AppSettings` fires before `DatabaseSettings` is ready -- ❌ Time window where state is inconsistent -- ❌ Manual coordination required across monitors -- ❌ Race conditions in multi-threaded scenarios - -**System.Reactive (Rx) CombineLatest:** - -```csharp -Observable.CombineLatest( - appObservable, - dbObservable, - (app, db) => (app, db)) -``` - -**Problems:** -- ❌ Emits on **any** source change, even if only one changed -- ❌ No transaction semantics - can see partial source updates -- ❌ No rollback on failure -- ❌ Complex subscription management - -### Real-World Impact - -**Without Atomic Updates (IOptionsMonitor):** - -``` -Time: 0ms → File changes (both App + DB sections modified) -Time: 5ms → AppSettings reloads → Observer 1 fires -Time: 10ms → Service uses new App + OLD Db ❌ INCONSISTENT -Time: 15ms → DatabaseSettings reloads → Observer 2 fires -Time: 20ms → Service uses new App + new Db ✓ Consistent again -``` - -**Window of Inconsistency:** 10ms where state is partially updated. - -**With Atomic Updates (Cocoar.Configuration):** - -``` -Time: 0ms → File changes -Time: 5ms → Recompute transaction begins - → AppSettings computes - → DatabaseSettings computes - → Both commit atomically -Time: 15ms → Single emission with (newApp, newDb) ✓ ATOMIC -``` - -**No inconsistency window.** Subscribers **never** see partial state. - ---- - -## Decision - -We implement **tuple-reactive atomic updates** using a transaction-based recomputation pipeline with the following guarantees: - -### 1. Transactional Recompute - -All configuration changes process as an **all-or-nothing transaction**: - -```csharp -// Recompute Pipeline -BeginTransaction() - ├─ Rule 1: Compute AppSettings - ├─ Rule 2: Compute DatabaseSettings - ├─ Rule 3: Compute CacheSettings - └─ CommitOrRollback() -``` - -**If any required rule fails:** -- ❌ Entire transaction rolls back -- ✅ Consumers keep previous good configuration -- ✅ **Zero emissions** - no observer is notified -- ✅ Health status → Unhealthy - -**If all rules succeed:** -- ✅ All changes commit atomically -- ✅ **Single emission** with new snapshot -- ✅ All subscribers see consistent state -- ✅ Health status → Healthy - -### 2. Tuple-Reactive API - -Consumers can subscribe to **multiple configurations atomically**: - -```csharp -public class MyService -{ - public MyService(IReactiveConfig<(AppSettings, DatabaseSettings, CacheSettings)> config) - { - config.Subscribe(tuple => - { - var (app, db, cache) = tuple; - - // GUARANTEED: All three are from the same recompute pass - // GUARANTEED: No partial updates - // GUARANTEED: If one changed, this fires; if all unchanged, no emission - - RebuildClient(app, db, cache); - }); - } -} -``` - -### 3. Reference-Equality Change Detection - -Only emit when configuration **actually changes**. Each recompute produces fresh -config instances; the engine compares the new per-type **instance reference** -against the last published reference and suppresses emission when they are -reference-equal: - -```csharp -// Recompute produces new snapshot -oldSnapshot = { AppSettings: v1, DatabaseSettings: v1 } -newSnapshot = { AppSettings: v2, DatabaseSettings: v1 } // Only App got a new instance - -// Per-Type Change Detection (DistinctUntilChanged with ReferenceEquals): -- ReferenceEquals(AppSettings v2, AppSettings v1) == false → Changed -- ReferenceEquals(DatabaseSettings v1, DatabaseSettings v1) == true → Unchanged - -// Emission: -- IReactiveConfig → Emits (reference changed) -- IReactiveConfig → No emission (reference unchanged) -- IReactiveConfig<(AppSettings, DatabaseSettings)> → Emits (tuple member changed) -``` - -**Benefits:** -- Avoids spurious emissions on non-changes -- Subscribers only react when a type gets a new instance -- O(1) reference comparison — no hashing or serialization on the emit path - (`MasterBackplane.CreateTypeProjection` ends in `.DistinctUntilChanged(ReferenceEqualityComparer.Instance)`) - ---- - -## Implementation - -### Core Components - -**1. MasterBackplane** (single source of truth) - -`MasterBackplane` holds the current `ConfigSnapshot` in a `SimpleBehaviorSubject` -and atomically publishes new snapshots. Per-type reactive consumers subscribe to -a **type projection** built lazily over that snapshot stream. The projection -selects the type out of each snapshot and gates emissions by reference equality: - -```csharp -internal sealed class MasterBackplane : IDisposable -{ - private readonly SimpleBehaviorSubject _snapshotSubject; - private readonly ConcurrentDictionary _typeProjectionCache = new(); - - // Atomic publish: all type projections update from a single snapshot - public void Publish(ConfigSnapshot snapshot) => _snapshotSubject.OnNext(snapshot); - - private IObservable CreateTypeProjection() where T : class => - _snapshotSubject - .Select(snapshot => snapshot.GetConfig() /* + interface mapping */) - .Where(config => config != null) - // Uses ReferenceEquals — no hashing on the emit path - .DistinctUntilChanged(ReferenceEqualityComparer.Instance); -} -``` - -**2. ReactiveConfigManager** (wrapper cache over the backplane) - -Holds the `MasterBackplane` plus a single `_reactiveConfigs` dictionary of -per-type wrappers. `GetReactiveConfig` returns a cached, backplane-backed -`BackplaneReactiveConfig` whose `CurrentValue` reads from the backplane and -whose `Subscribe` forwards to the type projection: - -```csharp -internal sealed class ReactiveConfigManager : IDisposable -{ - private readonly ConcurrentDictionary _reactiveConfigs = new(); - private MasterBackplane? _backplane; - - public IReactiveConfig GetReactiveConfig(Func fallbackAccessor) where T : class => - (IReactiveConfig)_reactiveConfigs.GetOrAdd( - typeof(T), _ => new BackplaneReactiveConfig(_backplane!)); - - private sealed class BackplaneReactiveConfig : IReactiveConfig, IDisposable where T : class - { - private readonly MasterBackplane _backplane; - private readonly IObservable _observable; - - public BackplaneReactiveConfig(MasterBackplane backplane) - { - _backplane = backplane; - _observable = backplane.GetTypeProjection(); - } - - public T CurrentValue => _backplane.GetConfig() ?? throw new InvalidOperationException(...); - public IDisposable Subscribe(IObserver observer) => _observable.Subscribe(observer); - } -} -``` - -There are no per-type subjects, hash dictionaries, or per-pass subjects — a -single snapshot subject plus reference-equality projections provide change -detection. - -**3. Tuple Reactive Factory** - -Handles flattening of nested tuples for atomic subscriptions. The factory -reflects over the `ValueTuple` fields to discover the element types (recursing -into `Rest` for tuples larger than 7), validates each element is a configured / -exposed type, primes each element's reactive config, then instantiates a -`ReactiveTupleConfig<>` over the same `MasterBackplane`. There is no -`Observable.CombineLatest` — the tuple reads all members from one atomic -snapshot: - -```csharp -internal class ReactiveConfigurationFactory(/* ... */) -{ - private object CreateTupleReactiveConfig(Type tupleType) - { - var elementTypes = FlattenTuple(tupleType).ToArray(); // reflection-flatten - - // Validate + prime each distinct element's reactive config - foreach (var et in elementTypes.Distinct()) { /* prime element type */ } - - // One ReactiveTupleConfig over the backplane — all members from one snapshot - var generic = typeof(ReactiveTupleConfig<>).MakeGenericType(tupleType); - return Activator.CreateInstance( - generic, accessor, backplaneAccessor(), reactiveConfigManager, logger, bindingRegistry)!; - } -} -``` - -### Atomic Recompute Flow - -``` -1. Change Detection - └─ Provider signals change (file modified, HTTP poll, etc.) - -2. Recompute Transaction - ├─ ConfigurationEngine.BeginUpdate() - ├─ Execute all rules sequentially - ├─ Build candidate snapshot - └─ Decision: - ├─ Success → CommitUpdate(snapshot) - └─ Failure → RollbackUpdate() - -3. Change Detection (on success only) - ├─ Publish the new snapshot to the MasterBackplane - ├─ For each registered type projection: - │ ├─ Select the type's new instance from the snapshot - │ ├─ Compare with last published reference (ReferenceEquals) - │ └─ If reference changed → emit - └─ Emit changed types atomically - -4. Subscriber Notification - ├─ Single-type: Emits if that type changed - ├─ Tuple-type: Emits if ANY member changed - └─ All emissions use same snapshot (atomic guarantee) -``` - ---- - -## Consequences - -### Positive - -✅ **Atomic Consistency**: Subscribers **never** see partial updates -✅ **Transactional Safety**: Failed recomputes don't corrupt state -✅ **Type-Safe**: Compile-time checked tuple subscriptions -✅ **Reference-Equality Efficiency**: O(1) change detection, no spurious emissions on non-changes -✅ **Flexible Granularity**: Subscribe to single types or tuples -✅ **Automatic Rollback**: Errors preserve last known good state -✅ **Zero External Dependencies**: `IReactiveConfig : IObservable` uses only BCL types — no System.Reactive in shipped packages -✅ **Observable by Design**: Configuration as first-class reactive stream - -### Trade-offs - -⚠️ **Complexity**: A backplane plus per-type projections and tuple flattening (justified by correctness) -⚠️ **Memory**: One snapshot subject plus one cached wrapper/projection per type — a single dictionary, not dual -⚠️ **Tuple Limitation**: C# supports tuples up to 8 members (combine with nesting if needed) -⚠️ **Reflection**: Tuple flattening uses reflection (results are cached per type) - -**Why Complexity Is Acceptable:** - -- Atomic guarantees are **non-negotiable** for correctness -- Memory overhead is **negligible** (one wrapper/projection per type) -- Reference-equality change detection is **O(1)** — no hashing or serialization on the emit path -- Alternative (IOptionsMonitor) has **unfixable race conditions** - -### Negative - -❌ **Learning Curve**: Developers must understand tuple-reactive pattern -❌ **Debugging**: Rx stack traces can be difficult to follow -❌ **Expression Trees**: Tuple factory uses reflection (cached, but still indirection) - -**Mitigation:** -- Comprehensive documentation in this ADR -- Examples demonstrating both single-type and tuple usage -- Health monitoring integration for observability - ---- - -## Alternatives Considered - -### Alternative 1: Manual Coordination with IOptionsMonitor - -```csharp -private AppSettings? _app; -private DatabaseSettings? _db; -private bool _appReady, _dbReady; - -_appMonitor.OnChange(newApp => { - _app = newApp; - _appReady = true; - TryRebuild(); -}); - -_dbMonitor.OnChange(newDb => { - _db = newDb; - _dbReady = true; - TryRebuild(); -}); - -void TryRebuild() { - if (_appReady && _dbReady) { - RebuildClient(_app, _db); - _appReady = _dbReady = false; - } -} -``` - -**Rejected because:** -- Boilerplate for every subscriber -- Race conditions (what if only one changes?) -- No transactional rollback -- No hash-based change detection -- Doesn't scale to 3+ types - -### Alternative 2: Polling with Locks - -```csharp -private readonly object _lock = new(); -private (AppSettings, DatabaseSettings) _snapshot; - -// Poll every 100ms -while (true) { - var newApp = LoadApp(); - var newDb = LoadDb(); - - lock (_lock) { - _snapshot = (newApp, newDb); - } - - await Task.Delay(100); -} -``` - -**Rejected because:** -- Wastes CPU cycles polling -- 100ms latency for changes -- No reactive push notifications -- Still no atomicity guarantee (lock only helps readers) - -### Alternative 3: Rx CombineLatest (Naive) - -```csharp -Observable.CombineLatest( - appObservable, - dbObservable, - (app, db) => (app, db)) -``` - -**Rejected because:** -- Emits on **every** source change (spurious emissions) -- No hash-based change detection -- No transactional rollback on failure -- Doesn't integrate with our recompute pipeline - -### Alternative 4: Event Sourcing - -```csharp -public record ConfigChanged(Type ConfigType, object NewValue, long Version); - -// Emit events, rebuild snapshots -``` - -**Rejected because:** -- Massive architectural change -- Requires event store -- Overkill for configuration (not business events) -- No built-in Rx integration - ---- - -## Usage Examples - -### Example 1: Single Configuration (Change-Based) - -```csharp -public class ApiClient -{ - public ApiClient(IReactiveConfig config, ILogger logger) - { - config.Subscribe(newSettings => - { - logger.LogInformation("API config changed: {Url}", newSettings.ApiUrl); - RebuildClient(newSettings); - }); - } -} -``` - -**Behavior:** -- Emits **only when AppSettings value changes** (hash-based) -- No emission if recompute produces same AppSettings -- Automatic on initialization (BehaviorSubject) - -### Example 2: Atomic Multi-Config (Tuple) - -```csharp -public class DatabasePool -{ - public DatabasePool( - IReactiveConfig<(AppSettings App, DatabaseSettings Db, CacheSettings Cache)> config, - ILogger logger) - { - config.Subscribe(tuple => - { - var (app, db, cache) = tuple; - - logger.LogInformation( - "Config changed atomically: {AppUrl}, {DbConn}, {CacheTtl}", - app.ApiUrl, db.ConnectionString, cache.TtlSeconds); - - // GUARANTEED: All three are consistent (same recompute pass) - RebuildPool(app, db, cache); - }); - } -} -``` - -**Behavior:** -- Emits **when any member changes** -- **All members are from the same snapshot** (atomic) -- If only `AppSettings` changed, still get all three (but only `AppSettings` has a new reference) - -### Example 3: Health Monitoring Integration - -```csharp -public class ConfigHealthService -{ - public ConfigHealthService( - IReactiveConfig<(AppSettings, DatabaseSettings)> config, - ConfigManager configManager) - { - // Monitor config changes - config.Subscribe(tuple => - { - var (app, db) = tuple; - ValidateConsistency(app, db); - }); - - // Check recompute health after a change - config.Subscribe(_ => - { - if (configManager.HealthStatus == HealthStatus.Unhealthy) - { - AlertOps("Configuration recompute failed!"); - } - }); - } -} -``` - -`ConfigManager` exposes the current health as `HealthStatus` -(`Unknown`/`Healthy`/`Degraded`/`Unhealthy`) plus the `IsHealthy` convenience -flag. A failed required rule leaves the last good configuration in place and sets -`HealthStatus` to `Unhealthy`. - ---- - -## Performance Characteristics - -### Benchmarks (Typical Scenario) - -**Recompute Transaction:** -- 3 rules (File + Env + HTTP) -- 3 config types -- Total time: ~50-200ms (dominated by HTTP polling) - -**Change Detection:** -- Reference comparison per type (`DistinctUntilChanged(ReferenceEquals)`): O(1), effectively free -- No hashing or serialization on the emit path -- **Negligible** compared to provider I/O - -**Emission Overhead:** -- Subject.OnNext: ~10-50 microseconds per subscriber -- 10 subscribers: ~100-500 microseconds total -- **Trivial** overhead - -**Memory per Type:** -- One cached `BackplaneReactiveConfig` wrapper + its type projection -- No per-type hash storage, no per-pass subject -- A single shared snapshot subject backs all types - -**Typical app (10 config types, 20 subscribers):** -- Memory: one snapshot subject + ~10 cached wrappers/projections = negligible -- Recompute time: ~50-200ms (provider I/O) -- Change detection: O(1) reference compares (effectively free) -- Emission time: ~1-10ms (notification) - -**Conclusion:** Performance overhead is **negligible** compared to correctness benefits. - ---- - -## Testing Strategy - -**Unit Tests:** -- Atomic emission on multi-config change -- No emission when a type's instance reference is unchanged -- Rollback on required rule failure - -**Integration Tests:** -- File change triggers atomic tuple update -- Failed HTTP poll rolls back entire transaction -- Concurrent subscribers see same snapshot - -**Property Tests:** -- No subscriber ever sees partial state -- A type emits if and only if it gets a new instance reference -- Transaction never commits partial updates - ---- - -## Migration Notes - -### From IOptionsMonitor (Microsoft) - -**Before:** -```csharp -public class MyService( - IOptionsMonitor appMonitor, - IOptionsMonitor dbMonitor) -{ - private AppSettings? _app; - private DatabaseSettings? _db; - - public MyService(...) - { - appMonitor.OnChange(a => _app = a); // Separate notifications - dbMonitor.OnChange(d => _db = d); // Race condition risk - } -} -``` - -**After:** -```csharp -public class MyService( - IReactiveConfig<(AppSettings, DatabaseSettings)> config) // Atomic tuple -{ - public MyService(...) - { - config.Subscribe(tuple => - { - var (app, db) = tuple; // Always consistent - RebuildState(app, db); - }); - } -} -``` - -### From System.Reactive (CombineLatest) - -**Before:** -```csharp -Observable.CombineLatest( - appObservable.DistinctUntilChanged(), // Manual change detection - dbObservable.DistinctUntilChanged(), - (app, db) => (app, db)) - .Subscribe(tuple => RebuildState(tuple.app, tuple.db)); -``` - -**After:** -```csharp -config.Subscribe(tuple => -{ - var (app, db) = tuple; // Built-in reference-equality change detection - RebuildState(app, db); -}); -``` - ---- - -## Future Enhancements - -The following are aspirational sketches — **none are implemented yet**. - -**1. Snapshot Diffing API** - -```csharp -config.ObserveDiffs().Subscribe(diff => -{ - Console.WriteLine($"Changed properties: {diff.ChangedPaths}"); -}); -``` - -**2. Conditional Subscriptions** - -```csharp -config.SubscribeWhen(tuple => tuple.App.IsFeatureEnabled, tuple => -{ - // Only fires when condition is true AND value changed -}); -``` - -**3. Backpressure Control** - -```csharp -config.Sample(TimeSpan.FromSeconds(1)) // At most once per second - .Subscribe(tuple => /* ... */); -``` - ---- - -## References - -### Internal -- `Core/MasterBackplane.cs` - Snapshot subject + per-type reference-equality projections -- `Reactive/ReactiveConfigManager.cs` - Backplane-backed wrapper cache (`BackplaneReactiveConfig`) -- `Reactive/ReactiveConfigurationFactory.cs` - Tuple flattening (reflection) over the backplane -- `Reactive/ReactiveTupleConfig.cs` - Tuple wrapper -- `Core/ConfigurationEngine.cs` - Recompute transaction (BeginUpdate/CommitUpdate/RollbackUpdate) - -### External -- [System.Reactive Documentation](https://github.com/dotnet/reactive) -- [Rx Design Guidelines](https://github.com/dotnet/reactive/blob/main/Rx.NET/Documentation/DesignGuidelines.md) -- [BehaviorSubject Semantics](https://reactivex.io/documentation/subject.html) - -### Articles -- [Reactive Configuration Part 1](https://dev.to/bwi/reactive-strongly-typed-configuration-in-net-introducing-cocoarconfiguration-v30-3gbn) -- [Config-Aware Rules Part 2](https://dev.to/bwi/config-aware-rules-in-net-the-power-feature-of-cocoarconfiguration-part-2-2ibk) - ---- - -## Conclusion - -The Reactive System's complexity is **intentional and necessary** to provide atomic consistency guarantees that are impossible with standard patterns like `IOptionsMonitor`. - -**Key Insight:** -Configuration changes are **transactions**, not isolated events. Subscribers must see consistent snapshots or risk undefined behavior. - -**The Alternative:** -Manual coordination with `IOptionsMonitor` is error-prone, doesn't scale, and fundamentally cannot provide atomicity guarantees. - -**The Decision:** -Accept ~250 lines of well-tested reactive infrastructure to provide **bulletproof atomic updates** that work correctly in production under concurrent load. - ---- - -**Status:** ✅ Accepted and Implemented -**Complexity Justified:** Yes - Atomic consistency is non-negotiable -**Next Review:** If alternative patterns emerge that provide atomicity without complexity - - -## Revision History - -| Date | Version | Changes | Author | -|------|---------|---------|--------| -| 2024-09-14 | 1.0 | Initial ADR documenting atomic reactive design | Core Team | - ---- +# ADR-002: Atomic Reactive Configuration Updates + +**Status:** Accepted +**Date:** 2024-09-14 +**Decision Makers:** Core Team +**Related:** ADR-001 (Capabilities System) + +--- + +## Context + +Configuration in distributed systems has a fundamental challenge: **how do you notify subscribers of changes across multiple related configuration types without exposing them to inconsistent state?** + +### The Problem: Partial Updates + +Consider a typical application with related configurations: + +```csharp +public class AppSettings +{ + public string ApiUrl { get; set; } + public int Timeout { get; set; } +} + +public class DatabaseSettings +{ + public string ConnectionString { get; set; } + public int PoolSize { get; set; } +} +``` + +When a configuration file changes that affects **both** types, subscribers need to see them update **atomically**. Seeing new `AppSettings` with old `DatabaseSettings` could cause: + +- API calls to new endpoints with old timeouts +- Database connections with mismatched pool sizes +- Race conditions during the update window +- Unpredictable behavior in dependent services + +### Why Standard Patterns Fail + +**Microsoft's IOptionsMonitor<T>:** + +```csharp +services.Configure(config.GetSection("App")); +services.Configure(config.GetSection("Database")); + +// In your service: +_appMonitor.OnChange(newApp => { /* update */ }); +_dbMonitor.OnChange(newDb => { /* update */ }); +``` + +**Problems:** +- ❌ Two separate notification streams - no atomicity guarantee +- ❌ Observer for `AppSettings` fires before `DatabaseSettings` is ready +- ❌ Time window where state is inconsistent +- ❌ Manual coordination required across monitors +- ❌ Race conditions in multi-threaded scenarios + +**System.Reactive (Rx) CombineLatest:** + +```csharp +Observable.CombineLatest( + appObservable, + dbObservable, + (app, db) => (app, db)) +``` + +**Problems:** +- ❌ Emits on **any** source change, even if only one changed +- ❌ No transaction semantics - can see partial source updates +- ❌ No rollback on failure +- ❌ Complex subscription management + +### Real-World Impact + +**Without Atomic Updates (IOptionsMonitor):** + +``` +Time: 0ms → File changes (both App + DB sections modified) +Time: 5ms → AppSettings reloads → Observer 1 fires +Time: 10ms → Service uses new App + OLD Db ❌ INCONSISTENT +Time: 15ms → DatabaseSettings reloads → Observer 2 fires +Time: 20ms → Service uses new App + new Db ✓ Consistent again +``` + +**Window of Inconsistency:** 10ms where state is partially updated. + +**With Atomic Updates (Cocoar.Configuration):** + +``` +Time: 0ms → File changes +Time: 5ms → Recompute transaction begins + → AppSettings computes + → DatabaseSettings computes + → Both commit atomically +Time: 15ms → Single emission with (newApp, newDb) ✓ ATOMIC +``` + +**No inconsistency window.** Subscribers **never** see partial state. + +--- + +## Decision + +We implement **tuple-reactive atomic updates** using a transaction-based recomputation pipeline with the following guarantees: + +### 1. Transactional Recompute + +All configuration changes process as an **all-or-nothing transaction**: + +```csharp +// Recompute Pipeline +BeginTransaction() + ├─ Rule 1: Compute AppSettings + ├─ Rule 2: Compute DatabaseSettings + ├─ Rule 3: Compute CacheSettings + └─ CommitOrRollback() +``` + +**If any required rule fails:** +- ❌ Entire transaction rolls back +- ✅ Consumers keep previous good configuration +- ✅ **Zero emissions** - no observer is notified +- ✅ Health status → Unhealthy + +**If all rules succeed:** +- ✅ All changes commit atomically +- ✅ **Single emission** with new snapshot +- ✅ All subscribers see consistent state +- ✅ Health status → Healthy + +### 2. Tuple-Reactive API + +Consumers can subscribe to **multiple configurations atomically**: + +```csharp +public class MyService +{ + public MyService(IReactiveConfig<(AppSettings, DatabaseSettings, CacheSettings)> config) + { + config.Subscribe(tuple => + { + var (app, db, cache) = tuple; + + // GUARANTEED: All three are from the same recompute pass + // GUARANTEED: No partial updates + // GUARANTEED: If one changed, this fires; if all unchanged, no emission + + RebuildClient(app, db, cache); + }); + } +} +``` + +### 3. Reference-Equality Change Detection + +Only emit when configuration **actually changes**. Each recompute produces fresh +config instances; the engine compares the new per-type **instance reference** +against the last published reference and suppresses emission when they are +reference-equal: + +```csharp +// Recompute produces new snapshot +oldSnapshot = { AppSettings: v1, DatabaseSettings: v1 } +newSnapshot = { AppSettings: v2, DatabaseSettings: v1 } // Only App got a new instance + +// Per-Type Change Detection (DistinctUntilChanged with ReferenceEquals): +- ReferenceEquals(AppSettings v2, AppSettings v1) == false → Changed +- ReferenceEquals(DatabaseSettings v1, DatabaseSettings v1) == true → Unchanged + +// Emission: +- IReactiveConfig → Emits (reference changed) +- IReactiveConfig → No emission (reference unchanged) +- IReactiveConfig<(AppSettings, DatabaseSettings)> → Emits (tuple member changed) +``` + +**Benefits:** +- Avoids spurious emissions on non-changes +- Subscribers only react when a type gets a new instance +- O(1) reference comparison — no hashing or serialization on the emit path + (`MasterBackplane.CreateTypeProjection` ends in `.DistinctUntilChanged(ReferenceEqualityComparer.Instance)`) + +--- + +## Implementation + +### Core Components + +**1. MasterBackplane** (single source of truth) + +`MasterBackplane` holds the current `ConfigSnapshot` in a `SimpleBehaviorSubject` +and atomically publishes new snapshots. Per-type reactive consumers subscribe to +a **type projection** built lazily over that snapshot stream. The projection +selects the type out of each snapshot and gates emissions by reference equality: + +```csharp +internal sealed class MasterBackplane : IDisposable +{ + private readonly SimpleBehaviorSubject _snapshotSubject; + private readonly ConcurrentDictionary _typeProjectionCache = new(); + + // Atomic publish: all type projections update from a single snapshot + public void Publish(ConfigSnapshot snapshot) => _snapshotSubject.OnNext(snapshot); + + private IObservable CreateTypeProjection() where T : class => + _snapshotSubject + .Select(snapshot => snapshot.GetConfig() /* + interface mapping */) + .Where(config => config != null) + // Uses ReferenceEquals — no hashing on the emit path + .DistinctUntilChanged(ReferenceEqualityComparer.Instance); +} +``` + +**2. ReactiveConfigManager** (wrapper cache over the backplane) + +Holds the `MasterBackplane` plus a single `_reactiveConfigs` dictionary of +per-type wrappers. `GetReactiveConfig` returns a cached, backplane-backed +`BackplaneReactiveConfig` whose `CurrentValue` reads from the backplane and +whose `Subscribe` forwards to the type projection: + +```csharp +internal sealed class ReactiveConfigManager : IDisposable +{ + private readonly ConcurrentDictionary _reactiveConfigs = new(); + private MasterBackplane? _backplane; + + public IReactiveConfig GetReactiveConfig(Func fallbackAccessor) where T : class => + (IReactiveConfig)_reactiveConfigs.GetOrAdd( + typeof(T), _ => new BackplaneReactiveConfig(_backplane!)); + + private sealed class BackplaneReactiveConfig : IReactiveConfig, IDisposable where T : class + { + private readonly MasterBackplane _backplane; + private readonly IObservable _observable; + + public BackplaneReactiveConfig(MasterBackplane backplane) + { + _backplane = backplane; + _observable = backplane.GetTypeProjection(); + } + + public T CurrentValue => _backplane.GetConfig() ?? throw new InvalidOperationException(...); + public IDisposable Subscribe(IObserver observer) => _observable.Subscribe(observer); + } +} +``` + +There are no per-type subjects, hash dictionaries, or per-pass subjects — a +single snapshot subject plus reference-equality projections provide change +detection. + +**3. Tuple Reactive Factory** + +Handles flattening of nested tuples for atomic subscriptions. The factory +reflects over the `ValueTuple` fields to discover the element types (recursing +into `Rest` for tuples larger than 7), validates each element is a configured / +exposed type, primes each element's reactive config, then instantiates a +`ReactiveTupleConfig<>` over the same `MasterBackplane`. There is no +`Observable.CombineLatest` — the tuple reads all members from one atomic +snapshot: + +```csharp +internal class ReactiveConfigurationFactory(/* ... */) +{ + private object CreateTupleReactiveConfig(Type tupleType) + { + var elementTypes = FlattenTuple(tupleType).ToArray(); // reflection-flatten + + // Validate + prime each distinct element's reactive config + foreach (var et in elementTypes.Distinct()) { /* prime element type */ } + + // One ReactiveTupleConfig over the backplane — all members from one snapshot + var generic = typeof(ReactiveTupleConfig<>).MakeGenericType(tupleType); + return Activator.CreateInstance( + generic, accessor, backplaneAccessor(), reactiveConfigManager, logger, bindingRegistry)!; + } +} +``` + +### Atomic Recompute Flow + +``` +1. Change Detection + └─ Provider signals change (file modified, HTTP poll, etc.) + +2. Recompute Transaction + ├─ ConfigurationEngine.BeginUpdate() + ├─ Execute all rules sequentially + ├─ Build candidate snapshot + └─ Decision: + ├─ Success → CommitUpdate(snapshot) + └─ Failure → RollbackUpdate() + +3. Change Detection (on success only) + ├─ Publish the new snapshot to the MasterBackplane + ├─ For each registered type projection: + │ ├─ Select the type's new instance from the snapshot + │ ├─ Compare with last published reference (ReferenceEquals) + │ └─ If reference changed → emit + └─ Emit changed types atomically + +4. Subscriber Notification + ├─ Single-type: Emits if that type changed + ├─ Tuple-type: Emits if ANY member changed + └─ All emissions use same snapshot (atomic guarantee) +``` + +--- + +## Consequences + +### Positive + +✅ **Atomic Consistency**: Subscribers **never** see partial updates +✅ **Transactional Safety**: Failed recomputes don't corrupt state +✅ **Type-Safe**: Compile-time checked tuple subscriptions +✅ **Reference-Equality Efficiency**: O(1) change detection, no spurious emissions on non-changes +✅ **Flexible Granularity**: Subscribe to single types or tuples +✅ **Automatic Rollback**: Errors preserve last known good state +✅ **Zero External Dependencies**: `IReactiveConfig : IObservable` uses only BCL types — no System.Reactive in shipped packages +✅ **Observable by Design**: Configuration as first-class reactive stream + +### Trade-offs + +⚠️ **Complexity**: A backplane plus per-type projections and tuple flattening (justified by correctness) +⚠️ **Memory**: One snapshot subject plus one cached wrapper/projection per type — a single dictionary, not dual +⚠️ **Tuple Limitation**: C# supports tuples up to 8 members (combine with nesting if needed) +⚠️ **Reflection**: Tuple flattening uses reflection (results are cached per type) + +**Why Complexity Is Acceptable:** + +- Atomic guarantees are **non-negotiable** for correctness +- Memory overhead is **negligible** (one wrapper/projection per type) +- Reference-equality change detection is **O(1)** — no hashing or serialization on the emit path +- Alternative (IOptionsMonitor) has **unfixable race conditions** + +### Negative + +❌ **Learning Curve**: Developers must understand tuple-reactive pattern +❌ **Debugging**: Rx stack traces can be difficult to follow +❌ **Expression Trees**: Tuple factory uses reflection (cached, but still indirection) + +**Mitigation:** +- Comprehensive documentation in this ADR +- Examples demonstrating both single-type and tuple usage +- Health monitoring integration for observability + +--- + +## Alternatives Considered + +### Alternative 1: Manual Coordination with IOptionsMonitor + +```csharp +private AppSettings? _app; +private DatabaseSettings? _db; +private bool _appReady, _dbReady; + +_appMonitor.OnChange(newApp => { + _app = newApp; + _appReady = true; + TryRebuild(); +}); + +_dbMonitor.OnChange(newDb => { + _db = newDb; + _dbReady = true; + TryRebuild(); +}); + +void TryRebuild() { + if (_appReady && _dbReady) { + RebuildClient(_app, _db); + _appReady = _dbReady = false; + } +} +``` + +**Rejected because:** +- Boilerplate for every subscriber +- Race conditions (what if only one changes?) +- No transactional rollback +- No hash-based change detection +- Doesn't scale to 3+ types + +### Alternative 2: Polling with Locks + +```csharp +private readonly object _lock = new(); +private (AppSettings, DatabaseSettings) _snapshot; + +// Poll every 100ms +while (true) { + var newApp = LoadApp(); + var newDb = LoadDb(); + + lock (_lock) { + _snapshot = (newApp, newDb); + } + + await Task.Delay(100); +} +``` + +**Rejected because:** +- Wastes CPU cycles polling +- 100ms latency for changes +- No reactive push notifications +- Still no atomicity guarantee (lock only helps readers) + +### Alternative 3: Rx CombineLatest (Naive) + +```csharp +Observable.CombineLatest( + appObservable, + dbObservable, + (app, db) => (app, db)) +``` + +**Rejected because:** +- Emits on **every** source change (spurious emissions) +- No hash-based change detection +- No transactional rollback on failure +- Doesn't integrate with our recompute pipeline + +### Alternative 4: Event Sourcing + +```csharp +public record ConfigChanged(Type ConfigType, object NewValue, long Version); + +// Emit events, rebuild snapshots +``` + +**Rejected because:** +- Massive architectural change +- Requires event store +- Overkill for configuration (not business events) +- No built-in Rx integration + +--- + +## Usage Examples + +### Example 1: Single Configuration (Change-Based) + +```csharp +public class ApiClient +{ + public ApiClient(IReactiveConfig config, ILogger logger) + { + config.Subscribe(newSettings => + { + logger.LogInformation("API config changed: {Url}", newSettings.ApiUrl); + RebuildClient(newSettings); + }); + } +} +``` + +**Behavior:** +- Emits **only when AppSettings value changes** (hash-based) +- No emission if recompute produces same AppSettings +- Automatic on initialization (BehaviorSubject) + +### Example 2: Atomic Multi-Config (Tuple) + +```csharp +public class DatabasePool +{ + public DatabasePool( + IReactiveConfig<(AppSettings App, DatabaseSettings Db, CacheSettings Cache)> config, + ILogger logger) + { + config.Subscribe(tuple => + { + var (app, db, cache) = tuple; + + logger.LogInformation( + "Config changed atomically: {AppUrl}, {DbConn}, {CacheTtl}", + app.ApiUrl, db.ConnectionString, cache.TtlSeconds); + + // GUARANTEED: All three are consistent (same recompute pass) + RebuildPool(app, db, cache); + }); + } +} +``` + +**Behavior:** +- Emits **when any member changes** +- **All members are from the same snapshot** (atomic) +- If only `AppSettings` changed, still get all three (but only `AppSettings` has a new reference) + +### Example 3: Health Monitoring Integration + +```csharp +public class ConfigHealthService +{ + public ConfigHealthService( + IReactiveConfig<(AppSettings, DatabaseSettings)> config, + ConfigManager configManager) + { + // Monitor config changes + config.Subscribe(tuple => + { + var (app, db) = tuple; + ValidateConsistency(app, db); + }); + + // Check recompute health after a change + config.Subscribe(_ => + { + if (configManager.HealthStatus == HealthStatus.Unhealthy) + { + AlertOps("Configuration recompute failed!"); + } + }); + } +} +``` + +`ConfigManager` exposes the current health as `HealthStatus` +(`Unknown`/`Healthy`/`Degraded`/`Unhealthy`) plus the `IsHealthy` convenience +flag. A failed required rule leaves the last good configuration in place and sets +`HealthStatus` to `Unhealthy`. + +--- + +## Performance Characteristics + +### Benchmarks (Typical Scenario) + +**Recompute Transaction:** +- 3 rules (File + Env + HTTP) +- 3 config types +- Total time: ~50-200ms (dominated by HTTP polling) + +**Change Detection:** +- Reference comparison per type (`DistinctUntilChanged(ReferenceEquals)`): O(1), effectively free +- No hashing or serialization on the emit path +- **Negligible** compared to provider I/O + +**Emission Overhead:** +- Subject.OnNext: ~10-50 microseconds per subscriber +- 10 subscribers: ~100-500 microseconds total +- **Trivial** overhead + +**Memory per Type:** +- One cached `BackplaneReactiveConfig` wrapper + its type projection +- No per-type hash storage, no per-pass subject +- A single shared snapshot subject backs all types + +**Typical app (10 config types, 20 subscribers):** +- Memory: one snapshot subject + ~10 cached wrappers/projections = negligible +- Recompute time: ~50-200ms (provider I/O) +- Change detection: O(1) reference compares (effectively free) +- Emission time: ~1-10ms (notification) + +**Conclusion:** Performance overhead is **negligible** compared to correctness benefits. + +--- + +## Testing Strategy + +**Unit Tests:** +- Atomic emission on multi-config change +- No emission when a type's instance reference is unchanged +- Rollback on required rule failure + +**Integration Tests:** +- File change triggers atomic tuple update +- Failed HTTP poll rolls back entire transaction +- Concurrent subscribers see same snapshot + +**Property Tests:** +- No subscriber ever sees partial state +- A type emits if and only if it gets a new instance reference +- Transaction never commits partial updates + +--- + +## Migration Notes + +### From IOptionsMonitor (Microsoft) + +**Before:** +```csharp +public class MyService( + IOptionsMonitor appMonitor, + IOptionsMonitor dbMonitor) +{ + private AppSettings? _app; + private DatabaseSettings? _db; + + public MyService(...) + { + appMonitor.OnChange(a => _app = a); // Separate notifications + dbMonitor.OnChange(d => _db = d); // Race condition risk + } +} +``` + +**After:** +```csharp +public class MyService( + IReactiveConfig<(AppSettings, DatabaseSettings)> config) // Atomic tuple +{ + public MyService(...) + { + config.Subscribe(tuple => + { + var (app, db) = tuple; // Always consistent + RebuildState(app, db); + }); + } +} +``` + +### From System.Reactive (CombineLatest) + +**Before:** +```csharp +Observable.CombineLatest( + appObservable.DistinctUntilChanged(), // Manual change detection + dbObservable.DistinctUntilChanged(), + (app, db) => (app, db)) + .Subscribe(tuple => RebuildState(tuple.app, tuple.db)); +``` + +**After:** +```csharp +config.Subscribe(tuple => +{ + var (app, db) = tuple; // Built-in reference-equality change detection + RebuildState(app, db); +}); +``` + +--- + +## Future Enhancements + +The following are aspirational sketches — **none are implemented yet**. + +**1. Snapshot Diffing API** + +```csharp +config.ObserveDiffs().Subscribe(diff => +{ + Console.WriteLine($"Changed properties: {diff.ChangedPaths}"); +}); +``` + +**2. Conditional Subscriptions** + +```csharp +config.SubscribeWhen(tuple => tuple.App.IsFeatureEnabled, tuple => +{ + // Only fires when condition is true AND value changed +}); +``` + +**3. Backpressure Control** + +```csharp +config.Sample(TimeSpan.FromSeconds(1)) // At most once per second + .Subscribe(tuple => /* ... */); +``` + +--- + +## References + +### Internal +- `Core/MasterBackplane.cs` - Snapshot subject + per-type reference-equality projections +- `Reactive/ReactiveConfigManager.cs` - Backplane-backed wrapper cache (`BackplaneReactiveConfig`) +- `Reactive/ReactiveConfigurationFactory.cs` - Tuple flattening (reflection) over the backplane +- `Reactive/ReactiveTupleConfig.cs` - Tuple wrapper +- `Core/ConfigurationEngine.cs` - Recompute transaction (BeginUpdate/CommitUpdate/RollbackUpdate) + +### External +- [System.Reactive Documentation](https://github.com/dotnet/reactive) +- [Rx Design Guidelines](https://github.com/dotnet/reactive/blob/main/Rx.NET/Documentation/DesignGuidelines.md) +- [BehaviorSubject Semantics](https://reactivex.io/documentation/subject.html) + +### Articles +- [Reactive Configuration Part 1](https://dev.to/bwi/reactive-strongly-typed-configuration-in-net-introducing-cocoarconfiguration-v30-3gbn) +- [Config-Aware Rules Part 2](https://dev.to/bwi/config-aware-rules-in-net-the-power-feature-of-cocoarconfiguration-part-2-2ibk) + +--- + +## Conclusion + +The Reactive System's complexity is **intentional and necessary** to provide atomic consistency guarantees that are impossible with standard patterns like `IOptionsMonitor`. + +**Key Insight:** +Configuration changes are **transactions**, not isolated events. Subscribers must see consistent snapshots or risk undefined behavior. + +**The Alternative:** +Manual coordination with `IOptionsMonitor` is error-prone, doesn't scale, and fundamentally cannot provide atomicity guarantees. + +**The Decision:** +Accept ~250 lines of well-tested reactive infrastructure to provide **bulletproof atomic updates** that work correctly in production under concurrent load. + +--- + +**Status:** ✅ Accepted and Implemented +**Complexity Justified:** Yes - Atomic consistency is non-negotiable +**Next Review:** If alternative patterns emerge that provide atomicity without complexity + + +## Revision History + +| Date | Version | Changes | Author | +|------|---------|---------|--------| +| 2024-09-14 | 1.0 | Initial ADR documenting atomic reactive design | Core Team | + +--- diff --git a/website/adr/ADR-003-provider-consistency-empty-objects.md b/website/adr/ADR-003-provider-consistency-empty-objects.md index 3dedc7d..3cae1f9 100644 --- a/website/adr/ADR-003-provider-consistency-empty-objects.md +++ b/website/adr/ADR-003-provider-consistency-empty-objects.md @@ -1,387 +1,387 @@ -# ADR-003: Fix Provider Inconsistency - Optional Rules Always Return Objects - -**Status:** Accepted -**Date:** 2025-01-11 -**Decision Makers:** Core Team -**Type:** Bug Fix -**Related:** PART2 Article (Optional vs Required Rules) - ---- - -## Context - -Cocoar.Configuration had a **bug where providers handled missing or unavailable data inconsistently**, leading to unpredictable behavior and requiring workarounds. - -### The Problem: Inconsistent Provider Behavior - -Currently, providers handle "no data" scenarios differently based on their source type: - -**Collection-Based Providers** (Always succeed with empty): -```csharp -// EnvironmentVariableProvider -rule.For().FromEnvironment("APP_") -// No matching env vars → Returns {} → Config with C# defaults - -// CommandLineArgumentProvider -rule.For().FromCommandLine("--app:") -// No matching args → Returns {} → Config with C# defaults -``` - -**Source-Based Providers** (Throw when source unavailable): -```csharp -// FileSourceProvider -rule.For().FromFile("config.json") -// File doesn't exist → Throws FileNotFoundException → null (unavailable) - -// HttpProvider -rule.For().FromHttp("http://api/config") -// Endpoint down → Throws → null (unavailable) -``` - -### Real-World Impact - -**User reports inability to access configuration:** - -```csharp -builder.AddCocoarConfiguration(rule => [ - rule.For().FromFile("config.json") -]); - -var config = builder.GetCocoarConfigManager().GetConfig(); -// config is NULL when file doesn't exist -``` - -**Current workarounds:** - -```csharp -// Workaround 1: Add fake environment rule -rule.For().FromFile("config.json"), -rule.For().FromEnvironment("NONEXISTENT_") // Hack: always returns {} -``` - -```csharp -// Workaround 2: Explicit FromStatic for defaults -rule.For().FromStatic(_ => new Config()), // Explicit defaults -rule.For().FromFile("config.json") -``` - -The first workaround is **implicit and unclear**. The second is better but required for every optional configuration. - -### Semantic Confusion - -The system conflates two orthogonal concepts: - -1. **Source Availability**: Does the data source exist/respond? -2. **Data Availability**: Does the source contain data for this type? - -Example scenarios that are semantically different but treated the same: - -```csharp -rule.For().FromFile("config.json").Select("App") -``` - -- File doesn't exist → Throws → null -- File exists, "App" section missing → Throws KeyNotFoundException → null -- File exists, "App" is `{}` → Returns empty object - -**All three should behave differently!** - -### Current "Last Known Good" Behavior - -The system has **asymmetric failure handling**: - -**Required Rules** (`Required: true`): -- Provider throws → Exception propagates to `RecomputeAllConfigurationsSafe` -- Entire recompute is **rolled back** via `_state.RollbackUpdate()` -- **All types preserve their previous values** from `_configs` dictionary -- App continues with last known good configuration for all types - -**Optional Rules** (`Required: false`, default): -- Provider throws → `HandleFailure()` skips the rule's contribution -- `LastJsonContribution` is left `null` for that rule -- Recompute **continues** with other rules -- If this was the **only rule** for a type → Type not in `mergedConfigs` → **GetConfig returns null** - -From PART2 article (`.local/dev.to/PART2...`): - -> **When an optional rule fails:** -> - Rule is skipped for that recompute -> - App uses **last known good** value for that type **(if none exists, that config type is unavailable)** - -This behavior is **intentional per documentation**, but creates the problem: **"last known good" only exists if the rule succeeded at least once.** - -**First-time failures have no history** → null instead of defaults. - ---- - -## Decision - -**All providers return empty JSON objects (`{}`) when they have no data, regardless of reason. Health monitoring tracks source availability separately.** - -This fixes the inconsistency bug and aligns all providers with the correct "graceful degradation" behavior that was already working for collection-based providers. - -### Core Principle - -**Data Flow** (always flows): -- Provider → Always returns valid JSON (possibly `{}`) -- RuleManager → Always contributes data (include: true) -- Type → Always available with at least C# defaults - -**Health Flow** (tracks issues): -- Provider throws → Exception caught by RuleManager -- RuleManager → Tracks exception in `LastFailureException` -- Health Status → Degraded with error details -- Multiple issues can be tracked per rule - -### Behavior Changes - -| Scenario | Old Behavior | New Behavior | -|----------|-------------|--------------| -| File doesn't exist | Throws → Skip → null | Returns `{}` → Object with defaults, Health = Degraded | -| HTTP endpoint down | Throws → Skip → null | Returns `{}` → Object with defaults, Health = Degraded | -| No matching env vars | Returns `{}` → Object | Returns `{}` → Object, Health = Healthy | -| Select path missing | Throws → Skip → null | Returns `{}` → Object with defaults, Health = Degraded | - -### Benefits - -**1. Consistency** -```csharp -// All providers work the same way -rule.For().FromFile("config.json") // Always returns object -rule.For().FromEnvironment("APP_") // Always returns object -rule.For().FromHttp("http://api") // Always returns object -``` - -**2. Predictability** -```csharp -var config = manager.GetConfig(); -// Never null if rule is defined -// C# property defaults always present (even on first failure) -``` - -**3. True "Last Known Good" Semantics** -```csharp -// Before: First failure → null (no history), second failure → last good value -// After: Always has value (empty object with C# defaults is the baseline) - -// Optional file rule fails on startup: -rule.For().FromFile("config.json") -// OLD: null (no last good) → app must handle null -// NEW: Config with C# defaults → app always works, health shows Degraded - -// File becomes available later → reactive update merges over defaults -// File fails again → keeps last merged value (true last known good) -``` - -**4. No Hacks Needed** -```csharp -// Before: Hack to get empty object -rule.For().FromFile("config.json"), -rule.For().FromEnvironment("FAKE_PREFIX_") // ❌ Unclear intent - -// After: Explicit if you want custom defaults -rule.For().FromStatic(_ => new Config { /* custom defaults */ }), -rule.For().FromFile("config.json") // ✅ Clear intent -``` - -**5. Better Observability** -```csharp -// Data still flows (Config has C# defaults), but health reflects the real issue. -// Overall status is derived from per-rule outcomes by the health tracker: -manager.HealthStatus; // HealthStatus.Degraded — an optional rule failed -manager.IsHealthy; // false - -// Per-rule detail is tracked on the rule manager: -// LastOutcome → RuleExecutionOutcome.Failed -// LastFailureException → FileNotFoundException("config.json") -``` - -**6. Graceful Degradation** -```csharp -// App continues with defaults while source is unavailable -// Auto-recovers when source comes back online (reactive updates) -// "Last known good" becomes meaningful: empty object → first merge → subsequent merges -``` - -### Implementation Approach - -**Change in RuleManager:** - -```csharp -// Before: -private ReadOnlyMemory HandleFailure(Exception ex) -{ - LastOutcome = RuleExecutionOutcome.Failed; - LastFailureException = ex; - - if (Required) - { - throw new InvalidOperationException(...); - } - - _logger.OptionalRuleFailed(ex, ...); - // ❌ Skipped the rule's contribution → type may be absent → null -} - -// After: -private ReadOnlyMemory HandleFailure(Exception ex) -{ - LastOutcome = RuleExecutionOutcome.Failed; - LastFailureException = ex; // ✅ Still tracked for health - - if (Required) - { - throw new InvalidOperationException(...); - } - - _logger.OptionalRuleFailed(ex, ...); - return EmptyObjectResult(); // ✅ Contributes "{}"u8 → object with C# defaults -} -``` - -**Similarly for HandleSelectFailure** (when Select path missing on optional rules). - -**Important distinction for `Select(...)` paths:** -- **Required rules**: Missing Select path still causes hard failure and rolls back entire recompute to last known good (preserves required rule safety guarantees) -- **Optional rules**: Missing Select path returns `{}` and marks rule as Degraded in health - -This maintains required rules as a strong guardrail against misconfiguration while giving optional rules graceful degradation. - -**`include: false` Reserved for `.When()` Only:** -```csharp -rule.For().FromFile("premium.json") - .When(accessor => accessor.GetRequiredConfig().IsPremium) -// When condition = false → include: false (intentional semantic skip) -// Provider not called, no health impact -``` - -### Impact on GetRequiredConfig - -With this change, `GetRequiredConfig()` throws only when: -- No rules are defined for type `T` -- Interface `T` is not exposed via `ExposeAs()` - -It becomes a **static configuration safety check** rather than a runtime availability check. - ---- - -## Consequences - -### Positive - -✅ **Bug fixed** - All providers now behave identically (as intended) -✅ **Predictability** - Types are always available if configured -✅ **No workarounds needed** - Eliminates environment var hacks -✅ **No null checks** - Simpler consumer code -✅ **Better defaults** - C# property defaults always present -✅ **Richer health** - Separate concern from data flow -✅ **True graceful degradation** - Apps continue with defaults during failures - -### Potential Impact - -⚠️ **Behavioral change** - Code checking for null to detect optional rule failures will no longer see null -⚠️ **Documentation update** - PART2 article needs revision to reflect correct behavior - -### Not a Breaking Change (In Practice) - -Users who were working around the bug by checking for null to detect failures may need to adjust, but this is not a breaking change because: -- The documented intent was "graceful degradation" for optional rules -- Collection providers already demonstrated the correct behavior (returning `{}`) -- The null return was inconsistent and required hacky workarounds -- All 349 tests passed without modification after the fix -- No legitimate use case for "optional rule returns null" that isn't better served by health monitoring - -### Migration (If Needed) - -Replace null checks (which were a workaround for the bug) with the proper health API: - -```csharp -var config = manager.GetConfig(); -UseConfig(config); // Always works, may have defaults - -// Proper way to check if the configuration is healthy: -if (!manager.IsHealthy) -{ - // manager.HealthStatus is Degraded when an optional rule failed. - // Per-rule detail (LastOutcome, LastFailureException) is exposed through - // the rule managers for diagnostics and ConfigHub observability. - _logger.LogWarning("Configuration is degraded: {Status}", manager.HealthStatus); -} -``` - -**Note:** Most code won't need changes - checking for null was a workaround for the bug, and most users either: -1. Used DI injection (never saw null) -2. Used the config directly (relied on defaults) -3. Had workarounds like adding `FromEnvironment("FAKE_")` rules (no longer needed) - -### Testing Impact - -Existing tests checking for `null` from optional rules will need updates: - -```csharp -// Before: -var result = manager.GetConfig(); -Assert.Null(result); // File doesn't exist - -// After: -var result = manager.GetConfig(); -Assert.NotNull(result); // Returns empty object -Assert.Equal(default, result.SomeProperty); // C# defaults present - -// Check health instead: -Assert.Equal(HealthStatus.Degraded, manager.HealthStatus); -Assert.False(manager.IsHealthy); -``` - ---- - -## Alternatives Considered - -### 1. Keep Current Behavior, Document FromStatic Pattern - -**Decision:** Reject -**Reason:** Doesn't solve the EnvironmentVariable workaround hack, maintains inconsistency - -### 2. Make Providers Return `null` or `{}` - -**Decision:** Reject -**Reason:** Provider API becomes ambiguous, mixes concerns - -### 3. Change Only FileSourceProvider to Return `{}` - -**Decision:** Reject -**Reason:** Partial solution, doesn't address root cause - -### 4. Add `.WithDefaults()` Fluent API - -```csharp -rule.For().FromFile("config.json") - .WithDefaults(new Config { /* ... */ }) -``` - -**Decision:** Defer -**Reason:** Could be added later as enhancement, doesn't solve core consistency issue - ---- - -## Related Issues - -- **Original bug report:** Single optional rule (FromFile) with missing file returns null inconsistently -- **Workaround that revealed the bug:** Adding FromEnvironment with fake prefix creates empty object (exposing that collection providers were already correct) -- **Design principle:** Collection providers (Env/CLI) already demonstrated the correct behavior - ---- - -## References - -- `.local/dev.to/PART2-config-aware-rules-in-net-the-power-feature-of-cocoarconfiguration-part-2.md` (Lines 46-85, 175-200) -- `src/Cocoar.Configuration/Rules/RuleManager.cs` (HandleFailure, HandleSelectFailure methods) -- `src/Cocoar.Configuration/Providers/` (Provider implementations) - ---- - -## Notes - -This ADR documents a **bug fix** that corrects inconsistent provider behavior. While framed as an architectural decision, it's fundamentally fixing a defect where source-based providers (File, HTTP) had different failure semantics than collection-based providers (Environment, CommandLine). - -The key insight: **Separation of concerns** - data flow (always flows) vs health monitoring (tracks issues separately) - was always the intended design, but source-based providers weren't implementing it correctly. +# ADR-003: Fix Provider Inconsistency - Optional Rules Always Return Objects + +**Status:** Accepted +**Date:** 2025-01-11 +**Decision Makers:** Core Team +**Type:** Bug Fix +**Related:** PART2 Article (Optional vs Required Rules) + +--- + +## Context + +Cocoar.Configuration had a **bug where providers handled missing or unavailable data inconsistently**, leading to unpredictable behavior and requiring workarounds. + +### The Problem: Inconsistent Provider Behavior + +Currently, providers handle "no data" scenarios differently based on their source type: + +**Collection-Based Providers** (Always succeed with empty): +```csharp +// EnvironmentVariableProvider +rule.For().FromEnvironment("APP_") +// No matching env vars → Returns {} → Config with C# defaults + +// CommandLineArgumentProvider +rule.For().FromCommandLine("--app:") +// No matching args → Returns {} → Config with C# defaults +``` + +**Source-Based Providers** (Throw when source unavailable): +```csharp +// FileSourceProvider +rule.For().FromFile("config.json") +// File doesn't exist → Throws FileNotFoundException → null (unavailable) + +// HttpProvider +rule.For().FromHttp("http://api/config") +// Endpoint down → Throws → null (unavailable) +``` + +### Real-World Impact + +**User reports inability to access configuration:** + +```csharp +builder.AddCocoarConfiguration(rule => [ + rule.For().FromFile("config.json") +]); + +var config = builder.GetCocoarConfigManager().GetConfig(); +// config is NULL when file doesn't exist +``` + +**Current workarounds:** + +```csharp +// Workaround 1: Add fake environment rule +rule.For().FromFile("config.json"), +rule.For().FromEnvironment("NONEXISTENT_") // Hack: always returns {} +``` + +```csharp +// Workaround 2: Explicit FromStatic for defaults +rule.For().FromStatic(_ => new Config()), // Explicit defaults +rule.For().FromFile("config.json") +``` + +The first workaround is **implicit and unclear**. The second is better but required for every optional configuration. + +### Semantic Confusion + +The system conflates two orthogonal concepts: + +1. **Source Availability**: Does the data source exist/respond? +2. **Data Availability**: Does the source contain data for this type? + +Example scenarios that are semantically different but treated the same: + +```csharp +rule.For().FromFile("config.json").Select("App") +``` + +- File doesn't exist → Throws → null +- File exists, "App" section missing → Throws KeyNotFoundException → null +- File exists, "App" is `{}` → Returns empty object + +**All three should behave differently!** + +### Current "Last Known Good" Behavior + +The system has **asymmetric failure handling**: + +**Required Rules** (`Required: true`): +- Provider throws → Exception propagates to `RecomputeAllConfigurationsSafe` +- Entire recompute is **rolled back** via `_state.RollbackUpdate()` +- **All types preserve their previous values** from `_configs` dictionary +- App continues with last known good configuration for all types + +**Optional Rules** (`Required: false`, default): +- Provider throws → `HandleFailure()` skips the rule's contribution +- `LastJsonContribution` is left `null` for that rule +- Recompute **continues** with other rules +- If this was the **only rule** for a type → Type not in `mergedConfigs` → **GetConfig returns null** + +From PART2 article (`.local/dev.to/PART2...`): + +> **When an optional rule fails:** +> - Rule is skipped for that recompute +> - App uses **last known good** value for that type **(if none exists, that config type is unavailable)** + +This behavior is **intentional per documentation**, but creates the problem: **"last known good" only exists if the rule succeeded at least once.** + +**First-time failures have no history** → null instead of defaults. + +--- + +## Decision + +**All providers return empty JSON objects (`{}`) when they have no data, regardless of reason. Health monitoring tracks source availability separately.** + +This fixes the inconsistency bug and aligns all providers with the correct "graceful degradation" behavior that was already working for collection-based providers. + +### Core Principle + +**Data Flow** (always flows): +- Provider → Always returns valid JSON (possibly `{}`) +- RuleManager → Always contributes data (include: true) +- Type → Always available with at least C# defaults + +**Health Flow** (tracks issues): +- Provider throws → Exception caught by RuleManager +- RuleManager → Tracks exception in `LastFailureException` +- Health Status → Degraded with error details +- Multiple issues can be tracked per rule + +### Behavior Changes + +| Scenario | Old Behavior | New Behavior | +|----------|-------------|--------------| +| File doesn't exist | Throws → Skip → null | Returns `{}` → Object with defaults, Health = Degraded | +| HTTP endpoint down | Throws → Skip → null | Returns `{}` → Object with defaults, Health = Degraded | +| No matching env vars | Returns `{}` → Object | Returns `{}` → Object, Health = Healthy | +| Select path missing | Throws → Skip → null | Returns `{}` → Object with defaults, Health = Degraded | + +### Benefits + +**1. Consistency** +```csharp +// All providers work the same way +rule.For().FromFile("config.json") // Always returns object +rule.For().FromEnvironment("APP_") // Always returns object +rule.For().FromHttp("http://api") // Always returns object +``` + +**2. Predictability** +```csharp +var config = manager.GetConfig(); +// Never null if rule is defined +// C# property defaults always present (even on first failure) +``` + +**3. True "Last Known Good" Semantics** +```csharp +// Before: First failure → null (no history), second failure → last good value +// After: Always has value (empty object with C# defaults is the baseline) + +// Optional file rule fails on startup: +rule.For().FromFile("config.json") +// OLD: null (no last good) → app must handle null +// NEW: Config with C# defaults → app always works, health shows Degraded + +// File becomes available later → reactive update merges over defaults +// File fails again → keeps last merged value (true last known good) +``` + +**4. No Hacks Needed** +```csharp +// Before: Hack to get empty object +rule.For().FromFile("config.json"), +rule.For().FromEnvironment("FAKE_PREFIX_") // ❌ Unclear intent + +// After: Explicit if you want custom defaults +rule.For().FromStatic(_ => new Config { /* custom defaults */ }), +rule.For().FromFile("config.json") // ✅ Clear intent +``` + +**5. Better Observability** +```csharp +// Data still flows (Config has C# defaults), but health reflects the real issue. +// Overall status is derived from per-rule outcomes by the health tracker: +manager.HealthStatus; // HealthStatus.Degraded — an optional rule failed +manager.IsHealthy; // false + +// Per-rule detail is tracked on the rule manager: +// LastOutcome → RuleExecutionOutcome.Failed +// LastFailureException → FileNotFoundException("config.json") +``` + +**6. Graceful Degradation** +```csharp +// App continues with defaults while source is unavailable +// Auto-recovers when source comes back online (reactive updates) +// "Last known good" becomes meaningful: empty object → first merge → subsequent merges +``` + +### Implementation Approach + +**Change in RuleManager:** + +```csharp +// Before: +private ReadOnlyMemory HandleFailure(Exception ex) +{ + LastOutcome = RuleExecutionOutcome.Failed; + LastFailureException = ex; + + if (Required) + { + throw new InvalidOperationException(...); + } + + _logger.OptionalRuleFailed(ex, ...); + // ❌ Skipped the rule's contribution → type may be absent → null +} + +// After: +private ReadOnlyMemory HandleFailure(Exception ex) +{ + LastOutcome = RuleExecutionOutcome.Failed; + LastFailureException = ex; // ✅ Still tracked for health + + if (Required) + { + throw new InvalidOperationException(...); + } + + _logger.OptionalRuleFailed(ex, ...); + return EmptyObjectResult(); // ✅ Contributes "{}"u8 → object with C# defaults +} +``` + +**Similarly for HandleSelectFailure** (when Select path missing on optional rules). + +**Important distinction for `Select(...)` paths:** +- **Required rules**: Missing Select path still causes hard failure and rolls back entire recompute to last known good (preserves required rule safety guarantees) +- **Optional rules**: Missing Select path returns `{}` and marks rule as Degraded in health + +This maintains required rules as a strong guardrail against misconfiguration while giving optional rules graceful degradation. + +**`include: false` Reserved for `.When()` Only:** +```csharp +rule.For().FromFile("premium.json") + .When(accessor => accessor.GetRequiredConfig().IsPremium) +// When condition = false → include: false (intentional semantic skip) +// Provider not called, no health impact +``` + +### Impact on GetRequiredConfig + +With this change, `GetRequiredConfig()` throws only when: +- No rules are defined for type `T` +- Interface `T` is not exposed via `ExposeAs()` + +It becomes a **static configuration safety check** rather than a runtime availability check. + +--- + +## Consequences + +### Positive + +✅ **Bug fixed** - All providers now behave identically (as intended) +✅ **Predictability** - Types are always available if configured +✅ **No workarounds needed** - Eliminates environment var hacks +✅ **No null checks** - Simpler consumer code +✅ **Better defaults** - C# property defaults always present +✅ **Richer health** - Separate concern from data flow +✅ **True graceful degradation** - Apps continue with defaults during failures + +### Potential Impact + +⚠️ **Behavioral change** - Code checking for null to detect optional rule failures will no longer see null +⚠️ **Documentation update** - PART2 article needs revision to reflect correct behavior + +### Not a Breaking Change (In Practice) + +Users who were working around the bug by checking for null to detect failures may need to adjust, but this is not a breaking change because: +- The documented intent was "graceful degradation" for optional rules +- Collection providers already demonstrated the correct behavior (returning `{}`) +- The null return was inconsistent and required hacky workarounds +- All 349 tests passed without modification after the fix +- No legitimate use case for "optional rule returns null" that isn't better served by health monitoring + +### Migration (If Needed) + +Replace null checks (which were a workaround for the bug) with the proper health API: + +```csharp +var config = manager.GetConfig(); +UseConfig(config); // Always works, may have defaults + +// Proper way to check if the configuration is healthy: +if (!manager.IsHealthy) +{ + // manager.HealthStatus is Degraded when an optional rule failed. + // Per-rule detail (LastOutcome, LastFailureException) is exposed through + // the rule managers for diagnostics and ConfigHub observability. + _logger.LogWarning("Configuration is degraded: {Status}", manager.HealthStatus); +} +``` + +**Note:** Most code won't need changes - checking for null was a workaround for the bug, and most users either: +1. Used DI injection (never saw null) +2. Used the config directly (relied on defaults) +3. Had workarounds like adding `FromEnvironment("FAKE_")` rules (no longer needed) + +### Testing Impact + +Existing tests checking for `null` from optional rules will need updates: + +```csharp +// Before: +var result = manager.GetConfig(); +Assert.Null(result); // File doesn't exist + +// After: +var result = manager.GetConfig(); +Assert.NotNull(result); // Returns empty object +Assert.Equal(default, result.SomeProperty); // C# defaults present + +// Check health instead: +Assert.Equal(HealthStatus.Degraded, manager.HealthStatus); +Assert.False(manager.IsHealthy); +``` + +--- + +## Alternatives Considered + +### 1. Keep Current Behavior, Document FromStatic Pattern + +**Decision:** Reject +**Reason:** Doesn't solve the EnvironmentVariable workaround hack, maintains inconsistency + +### 2. Make Providers Return `null` or `{}` + +**Decision:** Reject +**Reason:** Provider API becomes ambiguous, mixes concerns + +### 3. Change Only FileSourceProvider to Return `{}` + +**Decision:** Reject +**Reason:** Partial solution, doesn't address root cause + +### 4. Add `.WithDefaults()` Fluent API + +```csharp +rule.For().FromFile("config.json") + .WithDefaults(new Config { /* ... */ }) +``` + +**Decision:** Defer +**Reason:** Could be added later as enhancement, doesn't solve core consistency issue + +--- + +## Related Issues + +- **Original bug report:** Single optional rule (FromFile) with missing file returns null inconsistently +- **Workaround that revealed the bug:** Adding FromEnvironment with fake prefix creates empty object (exposing that collection providers were already correct) +- **Design principle:** Collection providers (Env/CLI) already demonstrated the correct behavior + +--- + +## References + +- `.local/dev.to/PART2-config-aware-rules-in-net-the-power-feature-of-cocoarconfiguration-part-2.md` (Lines 46-85, 175-200) +- `src/Cocoar.Configuration/Rules/RuleManager.cs` (HandleFailure, HandleSelectFailure methods) +- `src/Cocoar.Configuration/Providers/` (Provider implementations) + +--- + +## Notes + +This ADR documents a **bug fix** that corrects inconsistent provider behavior. While framed as an architectural decision, it's fundamentally fixing a defect where source-based providers (File, HTTP) had different failure semantics than collection-based providers (Environment, CommandLine). + +The key insight: **Separation of concerns** - data flow (always flows) vs health monitoring (tracks issues separately) - was always the intended design, but source-based providers weren't implementing it correctly. diff --git a/website/guide/configuration/config-aware.md b/website/guide/configuration/config-aware.md index 7a28fec..b52783c 100644 --- a/website/guide/configuration/config-aware.md +++ b/website/guide/configuration/config-aware.md @@ -1,177 +1,177 @@ -# Config-Aware Rules - -Rules can read the results of earlier rules to make decisions. This turns the rule list into a pipeline where configuration drives configuration. - -## The Idea - -Consider a multi-tenant application. You load tenant settings first, then use the tenant's region to determine which API endpoint to poll: - -```csharp -rule => [ - rule.For().FromFile("tenant.json"), - - rule.For().FromHttp(accessor => - { - var tenant = accessor.GetConfig(); - return new HttpRuleOptions( - $"https://{tenant.Region}.api.example.com/config", - pollInterval: TimeSpan.FromMinutes(5)); - }), -] -``` - -The second rule doesn't hardcode a URL — it derives it from `TenantSettings`, which was loaded by the first rule. When the tenant file changes and the region changes, the HTTP rule automatically switches endpoints. - -## IConfigurationAccessor - -Every rule factory receives an `IConfigurationAccessor`. This interface provides access to configuration from all rules that have already executed: - -```csharp -public interface IConfigurationAccessor -{ - T? GetConfig() where T : class; - bool TryGetConfig(out T? value) where T : class; -} -``` - -| Method | Behavior | -|---|---| -| `GetConfig()` | Returns the config instance. Throws if no rule is registered for `T`. | -| `TryGetConfig()` | Returns `true` and the instance if available, `false` otherwise. | - -::: warning Rule Order Matters -The accessor only sees types from rules that appear **before** the current rule. If you reference a type from a later rule, `GetConfig()` throws because the type isn't loaded yet. The Roslyn analyzer **COCFG002** catches this at compile time. -::: - -## Where It Works - -The accessor is available in **every** provider factory overload and in `.When()`: - -### Dynamic file paths - -```csharp -rule.For().FromFile(accessor => -{ - var tenant = accessor.GetConfig(); - return FileSourceRuleOptions.FromFilePath($"tenants/{tenant.TenantId}.json"); -}) -``` - -### Dynamic HTTP endpoints - -```csharp -rule.For().FromHttp(accessor => -{ - var tenant = accessor.GetConfig(); - return new HttpRuleOptions( - $"https://{tenant.Region}.config.example.com/api", - pollInterval: TimeSpan.FromMinutes(5)); -}) -``` - -### Dynamic environment prefixes - -```csharp -rule.For().FromEnvironment(accessor => -{ - var tenant = accessor.GetConfig(); - return new EnvironmentVariableRuleOptions($"TENANT_{tenant.TenantId}_"); -}) -``` - -### Conditional execution - -```csharp -rule.For().FromFile("premium.json") - .When(accessor => accessor.GetConfig().IsPremium) -``` - -See [Conditional Rules](/guide/configuration/conditional-rules) for the full `.When()` documentation. - -### Derived values - -```csharp -rule.For().FromStatic(accessor => -{ - var app = accessor.GetConfig(); - var db = accessor.GetConfig(); - return new ComputedConfig - { - ConnectionString = $"Server={db.Host};Database={app.AppName}_db" - }; -}) -``` - -## Re-evaluation on Change - -Config-aware factories are re-evaluated during every recompute. When the upstream config changes, the dependent rule sees the new values and adapts: - -1. `tenant.json` changes — region goes from `us-east` to `eu-west` -2. Engine starts recompute, re-evaluates all rules in order -3. The HTTP polling rule's factory runs again, reads the new region -4. It returns a new URL → the provider switches to the EU endpoint -5. New config is fetched from the EU endpoint - -This happens automatically. No manual wiring, no event handlers, no polling logic. - -## Patterns - -### Feature-gated sources - -Load additional configuration only when a feature is enabled: - -```csharp -rule => [ - rule.For().FromFile("appsettings.json"), - - rule.For().FromHttp(accessor => - { - var app = accessor.GetConfig(); - return new HttpRuleOptions( - app.ExperimentalEndpoint, - pollInterval: TimeSpan.FromMinutes(10)); - }) - .When(accessor => accessor.GetConfig().EnableExperiments), -] -``` - -The `.When()` and the factory both use the accessor. The rule only executes when the feature is enabled, and the URL comes from config. - -### Multi-tenant overrides - -Base config + tenant-specific overrides from different sources: - -```csharp -rule => [ - rule.For().FromFile("tenant.json").Required(), - - rule.For().FromFile("appsettings.json").Required(), - - rule.For().FromHttp(accessor => - { - var tenant = accessor.GetConfig(); - return new HttpRuleOptions( - $"https://config.example.com/tenants/{tenant.TenantId}", - pollInterval: TimeSpan.FromMinutes(5)); - }) - .When(accessor => accessor.GetConfig().HasCustomConfig), -] -``` - -### Safe access with TryGetConfig - -When you're unsure whether a type has rules defined: - -```csharp -rule.For().FromFile(accessor => -{ - if (accessor.TryGetConfig(out var tenant)) - return FileSourceRuleOptions.FromFilePath($"overrides/{tenant.TenantId}.json"); - - return FileSourceRuleOptions.FromFilePath("overrides/default.json"); -}) -``` - -## How It Differs from Conditional Rules - -[Conditional Rules](/guide/configuration/conditional-rules) (`.When()`) are one application of the accessor — they decide **whether** a rule runs. Config-aware rules are the broader concept: the accessor can influence **what** to load, **where** to load it from, and **how** to configure the provider. `.When()` is a boolean gate; the accessor in factory overloads controls everything else. +# Config-Aware Rules + +Rules can read the results of earlier rules to make decisions. This turns the rule list into a pipeline where configuration drives configuration. + +## The Idea + +Consider a multi-tenant application. You load tenant settings first, then use the tenant's region to determine which API endpoint to poll: + +```csharp +rule => [ + rule.For().FromFile("tenant.json"), + + rule.For().FromHttp(accessor => + { + var tenant = accessor.GetConfig(); + return new HttpRuleOptions( + $"https://{tenant.Region}.api.example.com/config", + pollInterval: TimeSpan.FromMinutes(5)); + }), +] +``` + +The second rule doesn't hardcode a URL — it derives it from `TenantSettings`, which was loaded by the first rule. When the tenant file changes and the region changes, the HTTP rule automatically switches endpoints. + +## IConfigurationAccessor + +Every rule factory receives an `IConfigurationAccessor`. This interface provides access to configuration from all rules that have already executed: + +```csharp +public interface IConfigurationAccessor +{ + T? GetConfig() where T : class; + bool TryGetConfig(out T? value) where T : class; +} +``` + +| Method | Behavior | +|---|---| +| `GetConfig()` | Returns the config instance. Throws if no rule is registered for `T`. | +| `TryGetConfig()` | Returns `true` and the instance if available, `false` otherwise. | + +::: warning Rule Order Matters +The accessor only sees types from rules that appear **before** the current rule. If you reference a type from a later rule, `GetConfig()` throws because the type isn't loaded yet. The Roslyn analyzer **COCFG002** catches this at compile time. +::: + +## Where It Works + +The accessor is available in **every** provider factory overload and in `.When()`: + +### Dynamic file paths + +```csharp +rule.For().FromFile(accessor => +{ + var tenant = accessor.GetConfig(); + return FileSourceRuleOptions.FromFilePath($"tenants/{tenant.TenantId}.json"); +}) +``` + +### Dynamic HTTP endpoints + +```csharp +rule.For().FromHttp(accessor => +{ + var tenant = accessor.GetConfig(); + return new HttpRuleOptions( + $"https://{tenant.Region}.config.example.com/api", + pollInterval: TimeSpan.FromMinutes(5)); +}) +``` + +### Dynamic environment prefixes + +```csharp +rule.For().FromEnvironment(accessor => +{ + var tenant = accessor.GetConfig(); + return new EnvironmentVariableRuleOptions($"TENANT_{tenant.TenantId}_"); +}) +``` + +### Conditional execution + +```csharp +rule.For().FromFile("premium.json") + .When(accessor => accessor.GetConfig().IsPremium) +``` + +See [Conditional Rules](/guide/configuration/conditional-rules) for the full `.When()` documentation. + +### Derived values + +```csharp +rule.For().FromStatic(accessor => +{ + var app = accessor.GetConfig(); + var db = accessor.GetConfig(); + return new ComputedConfig + { + ConnectionString = $"Server={db.Host};Database={app.AppName}_db" + }; +}) +``` + +## Re-evaluation on Change + +Config-aware factories are re-evaluated during every recompute. When the upstream config changes, the dependent rule sees the new values and adapts: + +1. `tenant.json` changes — region goes from `us-east` to `eu-west` +2. Engine starts recompute, re-evaluates all rules in order +3. The HTTP polling rule's factory runs again, reads the new region +4. It returns a new URL → the provider switches to the EU endpoint +5. New config is fetched from the EU endpoint + +This happens automatically. No manual wiring, no event handlers, no polling logic. + +## Patterns + +### Feature-gated sources + +Load additional configuration only when a feature is enabled: + +```csharp +rule => [ + rule.For().FromFile("appsettings.json"), + + rule.For().FromHttp(accessor => + { + var app = accessor.GetConfig(); + return new HttpRuleOptions( + app.ExperimentalEndpoint, + pollInterval: TimeSpan.FromMinutes(10)); + }) + .When(accessor => accessor.GetConfig().EnableExperiments), +] +``` + +The `.When()` and the factory both use the accessor. The rule only executes when the feature is enabled, and the URL comes from config. + +### Multi-tenant overrides + +Base config + tenant-specific overrides from different sources: + +```csharp +rule => [ + rule.For().FromFile("tenant.json").Required(), + + rule.For().FromFile("appsettings.json").Required(), + + rule.For().FromHttp(accessor => + { + var tenant = accessor.GetConfig(); + return new HttpRuleOptions( + $"https://config.example.com/tenants/{tenant.TenantId}", + pollInterval: TimeSpan.FromMinutes(5)); + }) + .When(accessor => accessor.GetConfig().HasCustomConfig), +] +``` + +### Safe access with TryGetConfig + +When you're unsure whether a type has rules defined: + +```csharp +rule.For().FromFile(accessor => +{ + if (accessor.TryGetConfig(out var tenant)) + return FileSourceRuleOptions.FromFilePath($"overrides/{tenant.TenantId}.json"); + + return FileSourceRuleOptions.FromFilePath("overrides/default.json"); +}) +``` + +## How It Differs from Conditional Rules + +[Conditional Rules](/guide/configuration/conditional-rules) (`.When()`) are one application of the accessor — they decide **whether** a rule runs. Config-aware rules are the broader concept: the accessor can influence **what** to load, **where** to load it from, and **how** to configure the provider. `.When()` is a boolean gate; the accessor in factory overloads controls everything else. diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md index d7d55b4..650aba6 100644 --- a/website/guide/getting-started.md +++ b/website/guide/getting-started.md @@ -1,141 +1,141 @@ -# Getting Started - -## Install - -Pick the package that matches your scenario — each one includes everything above it: - -```shell -dotnet add package Cocoar.Configuration # Core library (console apps, no DI) -dotnet add package Cocoar.Configuration.DI # ↑ + Microsoft.Extensions.DI integration -dotnet add package Cocoar.Configuration.AspNetCore # ↑ + health endpoints, feature flag endpoints -``` - -You only need **one** of these — install the highest one you need. - -Optional packages for additional providers: - -```shell -dotnet add package Cocoar.Configuration.Http # Remote config via HTTP -dotnet add package Cocoar.Configuration.MicrosoftAdapter # Bridge existing IConfiguration -``` - -## Your First Configuration - -### 1. Define a configuration class - -```csharp -public class AppSettings -{ - public string AppName { get; set; } = "MyApp"; - public int MaxRetries { get; set; } = 3; - public bool EnableLogging { get; set; } = true; -} -``` - -No base class, no attributes, no interfaces. Just a plain C# class. - -### 2. Create a JSON config file - -**appsettings.json:** -```json -{ - "AppName": "My Application", - "MaxRetries": 5, - "EnableLogging": true -} -``` - -### 3. Wire it up - -::: code-group - -```csharp [ASP.NET Core] -var builder = WebApplication.CreateBuilder(args); - -builder.AddCocoarConfiguration(c => c - .UseConfiguration(rule => [ - rule.For().FromFile("appsettings.json") - ])); - -var app = builder.Build(); - -// Inject directly — no IOptions wrapper -app.MapGet("/settings", (AppSettings settings) => new -{ - settings.AppName, - settings.MaxRetries -}); - -app.Run(); -``` - -```csharp [Console App] -using var manager = ConfigManager.Create(c => c - .UseConfiguration(rule => [ - rule.For().FromFile("appsettings.json") - ])); - -var settings = manager.GetConfig(); -Console.WriteLine($"App: {settings.AppName}, Retries: {settings.MaxRetries}"); -``` - -::: - -That's it. `AppSettings` is loaded and ready to inject. - -## Layering Multiple Sources - -The real power comes from layering. Rules execute in order — last write wins: - -```csharp -builder.AddCocoarConfiguration(c => c - .UseConfiguration(rule => [ - rule.For().FromFile("appsettings.json"), // Base - rule.For().FromFile("appsettings.Production.json"), // Override per environment - rule.For().FromEnvironment("APP_"), // Override from env vars - ])); -``` - -With this setup: -- `appsettings.json` provides defaults -- `appsettings.Production.json` overrides what it sets -- Environment variables like `APP_MaxRetries=10` override everything - -Properties merge at the JSON level. A later rule only overrides the properties it defines — everything else keeps the value from earlier rules. - -## Live Reloading - -When a file changes on disk, configuration updates automatically. Subscribe to changes: - -```csharp -public class NotificationService(IReactiveConfig config) -{ - public void Start() - { - // Called immediately with current value, then on every change - config.Subscribe(settings => - { - Console.WriteLine($"Config updated: MaxRetries={settings.MaxRetries}"); - }); - } -} -``` - -`IReactiveConfig` is automatically registered in DI for every configuration type. - -## What Happens Under the Hood - -When you call `ConfigManager.Create()` or `AddCocoarConfiguration()`: - -1. **Rules are evaluated** in order — each provider fetches its data (reads file, scans env vars, etc.) -2. **JSON is merged** — later rules overlay earlier ones, property by property -3. **Types are deserialized** — the merged JSON becomes your strongly-typed C# object -4. **Change detection starts** — file watchers, polling timers, etc. monitor for changes -5. **Updates are atomic** — when a source changes, the full recompute runs and all subscribers get the new snapshot at once - -## Next Steps - -- [Rules & Layering](/guide/configuration/rules) — Deep dive into the rule system -- [Providers](/guide/providers/overview) — All available configuration sources -- [Reactive Updates](/guide/reactive/basics) — Subscribe to live config changes -- [DI Integration](/guide/di/setup) — Lifetimes, type exposure, ASP.NET Core setup +# Getting Started + +## Install + +Pick the package that matches your scenario — each one includes everything above it: + +```shell +dotnet add package Cocoar.Configuration # Core library (console apps, no DI) +dotnet add package Cocoar.Configuration.DI # ↑ + Microsoft.Extensions.DI integration +dotnet add package Cocoar.Configuration.AspNetCore # ↑ + health endpoints, feature flag endpoints +``` + +You only need **one** of these — install the highest one you need. + +Optional packages for additional providers: + +```shell +dotnet add package Cocoar.Configuration.Http # Remote config via HTTP +dotnet add package Cocoar.Configuration.MicrosoftAdapter # Bridge existing IConfiguration +``` + +## Your First Configuration + +### 1. Define a configuration class + +```csharp +public class AppSettings +{ + public string AppName { get; set; } = "MyApp"; + public int MaxRetries { get; set; } = 3; + public bool EnableLogging { get; set; } = true; +} +``` + +No base class, no attributes, no interfaces. Just a plain C# class. + +### 2. Create a JSON config file + +**appsettings.json:** +```json +{ + "AppName": "My Application", + "MaxRetries": 5, + "EnableLogging": true +} +``` + +### 3. Wire it up + +::: code-group + +```csharp [ASP.NET Core] +var builder = WebApplication.CreateBuilder(args); + +builder.AddCocoarConfiguration(c => c + .UseConfiguration(rule => [ + rule.For().FromFile("appsettings.json") + ])); + +var app = builder.Build(); + +// Inject directly — no IOptions wrapper +app.MapGet("/settings", (AppSettings settings) => new +{ + settings.AppName, + settings.MaxRetries +}); + +app.Run(); +``` + +```csharp [Console App] +using var manager = ConfigManager.Create(c => c + .UseConfiguration(rule => [ + rule.For().FromFile("appsettings.json") + ])); + +var settings = manager.GetConfig(); +Console.WriteLine($"App: {settings.AppName}, Retries: {settings.MaxRetries}"); +``` + +::: + +That's it. `AppSettings` is loaded and ready to inject. + +## Layering Multiple Sources + +The real power comes from layering. Rules execute in order — last write wins: + +```csharp +builder.AddCocoarConfiguration(c => c + .UseConfiguration(rule => [ + rule.For().FromFile("appsettings.json"), // Base + rule.For().FromFile("appsettings.Production.json"), // Override per environment + rule.For().FromEnvironment("APP_"), // Override from env vars + ])); +``` + +With this setup: +- `appsettings.json` provides defaults +- `appsettings.Production.json` overrides what it sets +- Environment variables like `APP_MaxRetries=10` override everything + +Properties merge at the JSON level. A later rule only overrides the properties it defines — everything else keeps the value from earlier rules. + +## Live Reloading + +When a file changes on disk, configuration updates automatically. Subscribe to changes: + +```csharp +public class NotificationService(IReactiveConfig config) +{ + public void Start() + { + // Called immediately with current value, then on every change + config.Subscribe(settings => + { + Console.WriteLine($"Config updated: MaxRetries={settings.MaxRetries}"); + }); + } +} +``` + +`IReactiveConfig` is automatically registered in DI for every configuration type. + +## What Happens Under the Hood + +When you call `ConfigManager.Create()` or `AddCocoarConfiguration()`: + +1. **Rules are evaluated** in order — each provider fetches its data (reads file, scans env vars, etc.) +2. **JSON is merged** — later rules overlay earlier ones, property by property +3. **Types are deserialized** — the merged JSON becomes your strongly-typed C# object +4. **Change detection starts** — file watchers, polling timers, etc. monitor for changes +5. **Updates are atomic** — when a source changes, the full recompute runs and all subscribers get the new snapshot at once + +## Next Steps + +- [Rules & Layering](/guide/configuration/rules) — Deep dive into the rule system +- [Providers](/guide/providers/overview) — All available configuration sources +- [Reactive Updates](/guide/reactive/basics) — Subscribe to live config changes +- [DI Integration](/guide/di/setup) — Lifetimes, type exposure, ASP.NET Core setup diff --git a/website/guide/migration/v3-to-v4.md b/website/guide/migration/v3-to-v4.md index 0ae55c4..1cdcbcb 100644 --- a/website/guide/migration/v3-to-v4.md +++ b/website/guide/migration/v3-to-v4.md @@ -1,27 +1,27 @@ -# Migration v3 → v4 - -v3.x to v4.0 was an incremental release. There are **no breaking changes** to the public API. - -## What Changed - -v4.0 added new capabilities without modifying existing APIs: - -- **Testing Configuration Overrides** — `CocoarTestConfiguration` with `AsyncLocal` isolation -- **Secrets Package** — `Secret` with X.509 hybrid encryption -- **Secrets CLI** — `cocoar-secrets` global tool for encrypting/decrypting -- **Roslyn Analyzers** — COCFG001–006 for compile-time validation - -## Internal Breaking Change - -The **provider contract** changed from `JsonElement` to `byte[]`: - -- `FetchConfigurationAsync` → `FetchConfigurationBytesAsync` (returns `byte[]`) -- `Changes` → `ChangesAsBytes` (emits `byte[]`) - -This only affects you if you built a **custom provider** against the v3 contract. Built-in providers were updated automatically. - -## Migration - -For most applications: update the NuGet package version. No code changes required. - -If you have a custom provider, update the two method signatures to use `byte[]` instead of `JsonElement`. See [Building Custom Providers](/guide/providers/custom) for the current contract. +# Migration v3 → v4 + +v3.x to v4.0 was an incremental release. There are **no breaking changes** to the public API. + +## What Changed + +v4.0 added new capabilities without modifying existing APIs: + +- **Testing Configuration Overrides** — `CocoarTestConfiguration` with `AsyncLocal` isolation +- **Secrets Package** — `Secret` with X.509 hybrid encryption +- **Secrets CLI** — `cocoar-secrets` global tool for encrypting/decrypting +- **Roslyn Analyzers** — COCFG001–006 for compile-time validation + +## Internal Breaking Change + +The **provider contract** changed from `JsonElement` to `byte[]`: + +- `FetchConfigurationAsync` → `FetchConfigurationBytesAsync` (returns `byte[]`) +- `Changes` → `ChangesAsBytes` (emits `byte[]`) + +This only affects you if you built a **custom provider** against the v3 contract. Built-in providers were updated automatically. + +## Migration + +For most applications: update the NuGet package version. No code changes required. + +If you have a custom provider, update the two method signatures to use `byte[]` instead of `JsonElement`. See [Building Custom Providers](/guide/providers/custom) for the current contract. diff --git a/website/guide/roadmap.md b/website/guide/roadmap.md index d60a1a0..ba09876 100644 --- a/website/guide/roadmap.md +++ b/website/guide/roadmap.md @@ -1,3 +1,3 @@ -# What's Next - -See the full [Roadmap](/roadmap/overview) for what's planned — including ConfigHub, cloud providers, database provider, and more. +# What's Next + +See the full [Roadmap](/roadmap/overview) for what's planned — including ConfigHub, cloud providers, database provider, and more. diff --git a/website/guide/why-cocoar.md b/website/guide/why-cocoar.md index f59b125..e747fbb 100644 --- a/website/guide/why-cocoar.md +++ b/website/guide/why-cocoar.md @@ -1,65 +1,65 @@ -# Why Cocoar.Configuration? - -## The Problem with IOptions - -Microsoft's `IConfiguration` and `IOptions` work, but they come with friction: - -```csharp -// Microsoft: Setup is ceremony -builder.Services.Configure(builder.Configuration.GetSection("App")); - -// Microsoft: Injection requires unwrapping -public class MyService(IOptions options) -{ - var settings = options.Value; // Unwrap every time -} -``` - -- You must remember `Configure()` for every type -- Consumers need `IOptions`, `IOptionsSnapshot`, or `IOptionsMonitor` — different wrappers for different lifetimes -- Layering multiple sources (file + environment + remote) requires manual `IConfigurationBuilder` wiring -- No atomic multi-config updates — if two config types need to change together, you can get inconsistent reads -- Change notification requires subscribing to `IOptionsMonitor` with manual callback management - -## The Cocoar Approach - -```csharp -// Cocoar: Setup is one line per type -builder.AddCocoarConfiguration(c => c - .UseConfiguration(rule => [ - rule.For().FromFile("appsettings.json").Select("App") - ])); - -// Cocoar: Inject directly — no wrapper -public class MyService(AppSettings settings) -{ - // Just use it -} -``` - -### What You Get - -| Capability | IOptions | Cocoar | -|---|---|---| -| Direct injection | `IOptions` wrapper | `T` directly | -| Layering | Manual builder wiring | Rules in order, last write wins | -| Reactive updates | `IOptionsMonitor` | `IReactiveConfig` | -| Atomic multi-config | Not supported | `IReactiveConfig<(T1, T2)>` | -| Conditional rules | Not supported | `.When(accessor => ...)` | -| Required vs optional | Manual validation | `.Required()` with automatic rollback | -| Health monitoring | Not built in | Per-rule status, degraded/unhealthy tracking | -| Feature flags | Separate library | Built in | -| Secrets | No memory safety | `Secret` with automatic zeroization | -| Compile-time validation | Not available | Roslyn analyzers (COCFG001-006) | - -### Design Principles - -**Explicit layering.** Rules execute in defined order and merge property by property — later rules overlay earlier ones. You read the rule list top to bottom and know exactly what happens. - -**Reactive by default.** Every configuration type automatically gets an `IReactiveConfig` in DI. Subscribe once and receive updates whenever config changes — file modifications, HTTP poll results, environment changes. - -**Atomic updates.** When multiple config types need to stay in sync, use `IReactiveConfig<(T1, T2, T3)>`. All types update together in one snapshot — you never see a mix of old and new values. - -**Fail-safe behavior.** Required rules roll back the entire recompute on failure — your app keeps the last known good config. Optional rules that fail contribute nothing — values set by earlier rules remain unchanged, and the failure is tracked in health. - -**Zero ceremony.** Define a class, add a rule, inject it. No `Configure()` registration, no options wrappers, no `GetSection()` calls. +# Why Cocoar.Configuration? + +## The Problem with IOptions + +Microsoft's `IConfiguration` and `IOptions` work, but they come with friction: + +```csharp +// Microsoft: Setup is ceremony +builder.Services.Configure(builder.Configuration.GetSection("App")); + +// Microsoft: Injection requires unwrapping +public class MyService(IOptions options) +{ + var settings = options.Value; // Unwrap every time +} +``` + +- You must remember `Configure()` for every type +- Consumers need `IOptions`, `IOptionsSnapshot`, or `IOptionsMonitor` — different wrappers for different lifetimes +- Layering multiple sources (file + environment + remote) requires manual `IConfigurationBuilder` wiring +- No atomic multi-config updates — if two config types need to change together, you can get inconsistent reads +- Change notification requires subscribing to `IOptionsMonitor` with manual callback management + +## The Cocoar Approach + +```csharp +// Cocoar: Setup is one line per type +builder.AddCocoarConfiguration(c => c + .UseConfiguration(rule => [ + rule.For().FromFile("appsettings.json").Select("App") + ])); + +// Cocoar: Inject directly — no wrapper +public class MyService(AppSettings settings) +{ + // Just use it +} +``` + +### What You Get + +| Capability | IOptions | Cocoar | +|---|---|---| +| Direct injection | `IOptions` wrapper | `T` directly | +| Layering | Manual builder wiring | Rules in order, last write wins | +| Reactive updates | `IOptionsMonitor` | `IReactiveConfig` | +| Atomic multi-config | Not supported | `IReactiveConfig<(T1, T2)>` | +| Conditional rules | Not supported | `.When(accessor => ...)` | +| Required vs optional | Manual validation | `.Required()` with automatic rollback | +| Health monitoring | Not built in | Per-rule status, degraded/unhealthy tracking | +| Feature flags | Separate library | Built in | +| Secrets | No memory safety | `Secret` with automatic zeroization | +| Compile-time validation | Not available | Roslyn analyzers (COCFG001-006) | + +### Design Principles + +**Explicit layering.** Rules execute in defined order and merge property by property — later rules overlay earlier ones. You read the rule list top to bottom and know exactly what happens. + +**Reactive by default.** Every configuration type automatically gets an `IReactiveConfig` in DI. Subscribe once and receive updates whenever config changes — file modifications, HTTP poll results, environment changes. + +**Atomic updates.** When multiple config types need to stay in sync, use `IReactiveConfig<(T1, T2, T3)>`. All types update together in one snapshot — you never see a mix of old and new values. + +**Fail-safe behavior.** Required rules roll back the entire recompute on failure — your app keeps the last known good config. Optional rules that fail contribute nothing — values set by earlier rules remain unchanged, and the failure is tracked in health. + +**Zero ceremony.** Define a class, add a rule, inject it. No `Configure()` registration, no options wrappers, no `GetSection()` calls. diff --git a/website/index.md b/website/index.md index 73a1495..d2a53a9 100644 --- a/website/index.md +++ b/website/index.md @@ -1,48 +1,48 @@ ---- -layout: home - -hero: - name: Cocoar.Configuration - text: Reactive Configuration for .NET - tagline: Strongly-typed, layered, reactive. Zero ceremony configuration that updates itself. - image: - light: /layers.svg - dark: /layers_dark.svg - alt: Configuration layering - actions: - - theme: brand - text: Get Started - link: /guide/getting-started - - theme: alt - text: Why Cocoar? - link: /guide/why-cocoar - - theme: alt - text: GitHub - link: https://github.com/cocoar-dev/Cocoar.Configuration - -features: - - icon: |- - - title: Reactive by Default - details: Subscribe to config changes automatically. No manual IOptionsMonitor wiring. IReactiveConfig<T> updates in real time. - - icon: |- - - title: Zero Ceremony - details: Define a class, add a rule, inject it. No Configure<T>() calls, no IOptions<T> wrappers. Just your POCO. - - icon: |- - - title: Memory-Safe Secrets - details: Secret<T> with automatic zeroization. Pre-encrypted envelopes decrypted only on Open(). RSA-OAEP + AES-256-GCM. - - icon: |- - - title: Feature Flags & Entitlements - details: Strongly-typed flags with expiry health, entitlements for plan enforcement. Source-generated descriptors. - - icon: |- - - title: Atomic Updates - details: All config types update together or not at all. IReactiveConfig<(T1, T2)> guarantees consistent snapshots — no mix of old and new values. - - icon: |- - - title: Explicit Layering - details: Rules execute in order, last write wins. File, environment, command-line, HTTP — layer any source with full control. ---- +--- +layout: home + +hero: + name: Cocoar.Configuration + text: Reactive Configuration for .NET + tagline: Strongly-typed, layered, reactive. Zero ceremony configuration that updates itself. + image: + light: /layers.svg + dark: /layers_dark.svg + alt: Configuration layering + actions: + - theme: brand + text: Get Started + link: /guide/getting-started + - theme: alt + text: Why Cocoar? + link: /guide/why-cocoar + - theme: alt + text: GitHub + link: https://github.com/cocoar-dev/Cocoar.Configuration + +features: + - icon: |- + + title: Reactive by Default + details: Subscribe to config changes automatically. No manual IOptionsMonitor wiring. IReactiveConfig<T> updates in real time. + - icon: |- + + title: Zero Ceremony + details: Define a class, add a rule, inject it. No Configure<T>() calls, no IOptions<T> wrappers. Just your POCO. + - icon: |- + + title: Memory-Safe Secrets + details: Secret<T> with automatic zeroization. Pre-encrypted envelopes decrypted only on Open(). RSA-OAEP + AES-256-GCM. + - icon: |- + + title: Feature Flags & Entitlements + details: Strongly-typed flags with expiry health, entitlements for plan enforcement. Source-generated descriptors. + - icon: |- + + title: Atomic Updates + details: All config types update together or not at all. IReactiveConfig<(T1, T2)> guarantees consistent snapshots — no mix of old and new values. + - icon: |- + + title: Explicit Layering + details: Rules execute in order, last write wins. File, environment, command-line, HTTP — layer any source with full control. +--- diff --git a/website/roadmap/cloud-providers.md b/website/roadmap/cloud-providers.md index 2aa4a77..0864281 100644 --- a/website/roadmap/cloud-providers.md +++ b/website/roadmap/cloud-providers.md @@ -1,63 +1,63 @@ -# Cloud Providers - -Native providers for Azure Key Vault and AWS Secrets Manager — so you can load secrets and configuration from your existing cloud KMS without building a custom provider. - -## Why - -Today, `Secret` uses X.509 certificates for encryption. That works well for single deployments and on-premise setups. But many teams already have secrets in Azure Key Vault or AWS Secrets Manager and don't want to migrate them into certificate-encrypted JSON files. - -Cloud providers bridge this gap: keep your secrets where they are, load them through Cocoar's rule system. - -## Azure Key Vault Provider - -```csharp -rule.For().FromAzureKeyVault(vault => -{ - vault.VaultUri = new Uri("https://my-vault.vault.azure.net/"); - vault.SecretName = "db-config"; -}) -``` - -Planned capabilities: -- Load individual secrets or entire secret groups -- Automatic refresh on secret rotation (via Key Vault change notifications) -- Managed Identity authentication (no credentials in config) -- Works alongside file-based rules — Key Vault overrides local defaults via normal layering - -## AWS Secrets Manager Provider - -```csharp -rule.For().FromAwsSecretsManager(aws => -{ - aws.SecretId = "prod/db-config"; - aws.Region = "eu-central-1"; -}) -``` - -Planned capabilities: -- Load secrets by ID or ARN -- Automatic rotation support -- IAM role-based authentication -- Regional failover support - -## How They Fit In - -Cloud providers are just providers — they plug into the existing rule system. You can layer them freely: - -```csharp -rule => [ - rule.For().FromFile("appsettings.json"), // Base config - rule.For().FromAzureKeyVault(v => v.SecretName = "app"), // Cloud overrides - rule.For().FromEnvironment("APP_"), // Local overrides -] -``` - -Same merge semantics, same health monitoring, same reactive updates. - -## Can't Wait? - -The [custom provider contract](/guide/providers/custom) is two methods. If you need Azure Key Vault or AWS today, you can build a provider in ~200 lines. The planned native providers will offer a polished, tested, production-ready experience — but you're not blocked. - -## Status - -Planned. These are the highest-priority items on the roadmap — they're the most common request and the primary adoption blocker for teams with existing cloud infrastructure. +# Cloud Providers + +Native providers for Azure Key Vault and AWS Secrets Manager — so you can load secrets and configuration from your existing cloud KMS without building a custom provider. + +## Why + +Today, `Secret` uses X.509 certificates for encryption. That works well for single deployments and on-premise setups. But many teams already have secrets in Azure Key Vault or AWS Secrets Manager and don't want to migrate them into certificate-encrypted JSON files. + +Cloud providers bridge this gap: keep your secrets where they are, load them through Cocoar's rule system. + +## Azure Key Vault Provider + +```csharp +rule.For().FromAzureKeyVault(vault => +{ + vault.VaultUri = new Uri("https://my-vault.vault.azure.net/"); + vault.SecretName = "db-config"; +}) +``` + +Planned capabilities: +- Load individual secrets or entire secret groups +- Automatic refresh on secret rotation (via Key Vault change notifications) +- Managed Identity authentication (no credentials in config) +- Works alongside file-based rules — Key Vault overrides local defaults via normal layering + +## AWS Secrets Manager Provider + +```csharp +rule.For().FromAwsSecretsManager(aws => +{ + aws.SecretId = "prod/db-config"; + aws.Region = "eu-central-1"; +}) +``` + +Planned capabilities: +- Load secrets by ID or ARN +- Automatic rotation support +- IAM role-based authentication +- Regional failover support + +## How They Fit In + +Cloud providers are just providers — they plug into the existing rule system. You can layer them freely: + +```csharp +rule => [ + rule.For().FromFile("appsettings.json"), // Base config + rule.For().FromAzureKeyVault(v => v.SecretName = "app"), // Cloud overrides + rule.For().FromEnvironment("APP_"), // Local overrides +] +``` + +Same merge semantics, same health monitoring, same reactive updates. + +## Can't Wait? + +The [custom provider contract](/guide/providers/custom) is two methods. If you need Azure Key Vault or AWS today, you can build a provider in ~200 lines. The planned native providers will offer a polished, tested, production-ready experience — but you're not blocked. + +## Status + +Planned. These are the highest-priority items on the roadmap — they're the most common request and the primary adoption blocker for teams with existing cloud infrastructure. diff --git a/website/roadmap/confighub.md b/website/roadmap/confighub.md index d834834..acf739e 100644 --- a/website/roadmap/confighub.md +++ b/website/roadmap/confighub.md @@ -1,78 +1,78 @@ -# ConfigHub - -The most common question: *"This is great, but how do I manage configuration across 100 instances without SSH-ing into each one?"* - -**ConfigHub** is the answer — a management portal for Cocoar.Configuration deployments. - -## The Problem - -Cocoar.Configuration handles the runtime side: loading, merging, reacting, encrypting. But when you operate dozens or hundreds of instances, the operational questions are different: - -- How do I push a config change to all production instances? -- Which instances have expired feature flags? -- When was the last certificate rotation, and which instances still use the old cert? -- A customer reports an issue — what's their current configuration state? - -File-based config with manual deploys doesn't scale. You need a control plane. - -## What ConfigHub Provides - -### Configuration Management -Push config changes to instances or groups of instances without redeployment. Version history, diff view, rollback. - -### Secrets Lifecycle -Certificate management, automated key rotation, encrypted secret distribution. No more managing N certificates for N instances manually — ConfigHub handles the lifecycle centrally and distributes keys to instances. - -### Feature Flag Control -Enable/disable flags per instance, tenant, or environment. See which flags are active, which are expired, audit who changed what and when. - -### Health Dashboard -Real-time health across all instances. Drill down from fleet overview to individual rule failures. Alert on degraded/unhealthy state before customers notice. - -### Telemetry -Rich per-rule health snapshots, recompute timing, provider error rates, configuration drift detection — beyond what OpenTelemetry counters provide. - -## Architecture - -ConfigHub connects to your instances via the standard provider model. It's just another configuration source — the library doesn't know or care whether the bytes come from a file, HTTP endpoint, or ConfigHub: - -```csharp -builder.AddCocoarConfiguration(c => c - .UseConfiguration(rule => [ - rule.For().FromFile("appsettings.json"), // Local defaults - rule.For().FromConfigHub(), // Remote overrides from ConfigHub - ])); -``` - -The `FromConfigHub()` provider uses the existing reactive pipeline — changes pushed from ConfigHub trigger the same recompute/merge/notify cycle as a file change. No special runtime behavior. - -### Data Flow - -``` -ConfigHub Portal Your Instances -┌──────────────┐ ┌──────────────────┐ -│ Dashboard │ │ ConfigManager │ -│ Config UI │ ── push/pull ──→ │ FromConfigHub() │ -│ Flag Control│ │ reactive merge │ -│ Health View │ ←── telemetry ── │ OpenTelemetry │ -└──────────────┘ └──────────────────┘ -``` - -Instances report health via standard OpenTelemetry. ConfigHub connects via OTLP — no proprietary agent or sidecar. - -## Licensing - -| | Library | ConfigHub | -|---|---|---| -| **License** | Apache-2.0 (free, forever) | Commercial (free tier available) | -| **What you get** | Full config, flags, entitlements, secrets, providers, analyzers | Management UI, push delivery, cert lifecycle, telemetry dashboards | -| **Dependency** | Standalone | Needs the library | -| **Required?** | N/A | No — the library works fully without it | - -The library does **not** phone home, require a license key, or degrade without ConfigHub. It's a complete product on its own. ConfigHub is for teams that need the operational layer. - -## Status - -ConfigHub is in the design phase. Architecture, data model, and provider protocol are being defined. A private preview is planned after the cloud providers ship. - -If you're interested in early access, watch the [GitHub repository](https://github.com/cocoar-dev/Cocoar.Configuration) for announcements. +# ConfigHub + +The most common question: *"This is great, but how do I manage configuration across 100 instances without SSH-ing into each one?"* + +**ConfigHub** is the answer — a management portal for Cocoar.Configuration deployments. + +## The Problem + +Cocoar.Configuration handles the runtime side: loading, merging, reacting, encrypting. But when you operate dozens or hundreds of instances, the operational questions are different: + +- How do I push a config change to all production instances? +- Which instances have expired feature flags? +- When was the last certificate rotation, and which instances still use the old cert? +- A customer reports an issue — what's their current configuration state? + +File-based config with manual deploys doesn't scale. You need a control plane. + +## What ConfigHub Provides + +### Configuration Management +Push config changes to instances or groups of instances without redeployment. Version history, diff view, rollback. + +### Secrets Lifecycle +Certificate management, automated key rotation, encrypted secret distribution. No more managing N certificates for N instances manually — ConfigHub handles the lifecycle centrally and distributes keys to instances. + +### Feature Flag Control +Enable/disable flags per instance, tenant, or environment. See which flags are active, which are expired, audit who changed what and when. + +### Health Dashboard +Real-time health across all instances. Drill down from fleet overview to individual rule failures. Alert on degraded/unhealthy state before customers notice. + +### Telemetry +Rich per-rule health snapshots, recompute timing, provider error rates, configuration drift detection — beyond what OpenTelemetry counters provide. + +## Architecture + +ConfigHub connects to your instances via the standard provider model. It's just another configuration source — the library doesn't know or care whether the bytes come from a file, HTTP endpoint, or ConfigHub: + +```csharp +builder.AddCocoarConfiguration(c => c + .UseConfiguration(rule => [ + rule.For().FromFile("appsettings.json"), // Local defaults + rule.For().FromConfigHub(), // Remote overrides from ConfigHub + ])); +``` + +The `FromConfigHub()` provider uses the existing reactive pipeline — changes pushed from ConfigHub trigger the same recompute/merge/notify cycle as a file change. No special runtime behavior. + +### Data Flow + +``` +ConfigHub Portal Your Instances +┌──────────────┐ ┌──────────────────┐ +│ Dashboard │ │ ConfigManager │ +│ Config UI │ ── push/pull ──→ │ FromConfigHub() │ +│ Flag Control│ │ reactive merge │ +│ Health View │ ←── telemetry ── │ OpenTelemetry │ +└──────────────┘ └──────────────────┘ +``` + +Instances report health via standard OpenTelemetry. ConfigHub connects via OTLP — no proprietary agent or sidecar. + +## Licensing + +| | Library | ConfigHub | +|---|---|---| +| **License** | Apache-2.0 (free, forever) | Commercial (free tier available) | +| **What you get** | Full config, flags, entitlements, secrets, providers, analyzers | Management UI, push delivery, cert lifecycle, telemetry dashboards | +| **Dependency** | Standalone | Needs the library | +| **Required?** | N/A | No — the library works fully without it | + +The library does **not** phone home, require a license key, or degrade without ConfigHub. It's a complete product on its own. ConfigHub is for teams that need the operational layer. + +## Status + +ConfigHub is in the design phase. Architecture, data model, and provider protocol are being defined. A private preview is planned after the cloud providers ship. + +If you're interested in early access, watch the [GitHub repository](https://github.com/cocoar-dev/Cocoar.Configuration) for announcements. diff --git a/website/roadmap/database-provider.md b/website/roadmap/database-provider.md index faa2686..8ebd240 100644 --- a/website/roadmap/database-provider.md +++ b/website/roadmap/database-provider.md @@ -1,69 +1,69 @@ -# Database Provider - -A native provider for loading configuration from relational databases — SQL Server, PostgreSQL, MySQL, and others via ADO.NET. - -## Why - -Many multi-tenant applications store per-tenant configuration in the database. Today, this requires a [custom provider](/guide/providers/custom). A native database provider would make this a one-liner. - -## Planned API - -```csharp -rule.For().FromDatabase(db => -{ - db.ConnectionString = "Server=localhost;Database=myapp"; - db.Query = "SELECT config_json FROM tenant_config WHERE tenant_id = @tenantId"; - db.Parameters = new { tenantId = currentTenant.Id }; - db.PollInterval = TimeSpan.FromMinutes(1); -}) -``` - -Or with config-aware connection strings: - -```csharp -rule.For().FromDatabase(accessor => -{ - var app = accessor.GetConfig(); - return new DatabaseRuleOptions - { - ConnectionString = app.DatabaseConnectionString, - Query = "SELECT config_json FROM tenant_config WHERE tenant_id = @id", - Parameters = new { id = app.TenantId }, - }; -}) -``` - -## Planned Capabilities - -- **Any ADO.NET provider** — SQL Server, PostgreSQL, MySQL, SQLite via standard `DbConnection` -- **JSON column support** — query returns a JSON string, merged into the config pipeline -- **Polling for changes** — configurable poll interval with hash-based change detection -- **Config-aware queries** — derive connection strings and query parameters from earlier rules -- **Change notifications** — optional SQL dependency / listen-notify support for push-based updates (PostgreSQL `LISTEN/NOTIFY`, SQL Server `SqlDependency`) - -## Use Case: Multi-Tenant Configuration - -The primary use case is ISVs that operate per-customer instances. Each customer has configuration stored in a shared or per-customer database: - -```csharp -rule => [ - rule.For().FromFile("appsettings.json"), // Base defaults - rule.For().FromFile("tenant.json"), // Which tenant is this? - rule.For().FromDatabase(accessor => // Tenant-specific config - { - var tenant = accessor.GetConfig(); - return new DatabaseRuleOptions - { - ConnectionString = tenant.ConfigDbConnectionString, - Query = "SELECT config FROM tenants WHERE id = @id", - Parameters = new { id = tenant.TenantId }, - }; - }), -] -``` - -The tenant's database config overrides the file defaults — same merge semantics as any other provider. - -## Status - -Planned. This is the second-highest priority after cloud providers. +# Database Provider + +A native provider for loading configuration from relational databases — SQL Server, PostgreSQL, MySQL, and others via ADO.NET. + +## Why + +Many multi-tenant applications store per-tenant configuration in the database. Today, this requires a [custom provider](/guide/providers/custom). A native database provider would make this a one-liner. + +## Planned API + +```csharp +rule.For().FromDatabase(db => +{ + db.ConnectionString = "Server=localhost;Database=myapp"; + db.Query = "SELECT config_json FROM tenant_config WHERE tenant_id = @tenantId"; + db.Parameters = new { tenantId = currentTenant.Id }; + db.PollInterval = TimeSpan.FromMinutes(1); +}) +``` + +Or with config-aware connection strings: + +```csharp +rule.For().FromDatabase(accessor => +{ + var app = accessor.GetConfig(); + return new DatabaseRuleOptions + { + ConnectionString = app.DatabaseConnectionString, + Query = "SELECT config_json FROM tenant_config WHERE tenant_id = @id", + Parameters = new { id = app.TenantId }, + }; +}) +``` + +## Planned Capabilities + +- **Any ADO.NET provider** — SQL Server, PostgreSQL, MySQL, SQLite via standard `DbConnection` +- **JSON column support** — query returns a JSON string, merged into the config pipeline +- **Polling for changes** — configurable poll interval with hash-based change detection +- **Config-aware queries** — derive connection strings and query parameters from earlier rules +- **Change notifications** — optional SQL dependency / listen-notify support for push-based updates (PostgreSQL `LISTEN/NOTIFY`, SQL Server `SqlDependency`) + +## Use Case: Multi-Tenant Configuration + +The primary use case is ISVs that operate per-customer instances. Each customer has configuration stored in a shared or per-customer database: + +```csharp +rule => [ + rule.For().FromFile("appsettings.json"), // Base defaults + rule.For().FromFile("tenant.json"), // Which tenant is this? + rule.For().FromDatabase(accessor => // Tenant-specific config + { + var tenant = accessor.GetConfig(); + return new DatabaseRuleOptions + { + ConnectionString = tenant.ConfigDbConnectionString, + Query = "SELECT config FROM tenants WHERE id = @id", + Parameters = new { id = tenant.TenantId }, + }; + }), +] +``` + +The tenant's database config overrides the file defaults — same merge semantics as any other provider. + +## Status + +Planned. This is the second-highest priority after cloud providers. diff --git a/website/roadmap/overview.md b/website/roadmap/overview.md index c169025..c6c8f00 100644 --- a/website/roadmap/overview.md +++ b/website/roadmap/overview.md @@ -1,31 +1,31 @@ -# Roadmap - -Cocoar.Configuration is the open-source foundation — fully functional today for configuration, feature flags, entitlements, and secrets. Here's what we're building next. - -## At a Glance - -| Initiative | Status | Impact | -|---|---|---| -| [ConfigHub](/roadmap/confighub) | In Design | Management portal for config, secrets, and flags at scale | -| [Cloud Providers](/roadmap/cloud-providers) | Planned | Azure Key Vault, AWS Secrets Manager | -| [Database Provider](/roadmap/database-provider) | Planned | Tenant-specific config from SQL | - -## Priority Order - -We don't publish specific dates — we ship when it's ready. Rough priority: - -1. **Cloud Providers** (Azure Key Vault + AWS) — door openers for enterprise adoption -2. **Database Provider** — unlocks tenant-specific config from SQL -3. **ConfigHub private preview** — management portal for at-scale deployments - -## Current Limitations - -These are things the library does not do today: - -- **No management UI** — you manage config files, environment variables, and JSON directly. [ConfigHub](/roadmap/confighub) will address this. -- **No cloud KMS integration** — Azure Key Vault and AWS Secrets Manager require a [custom provider](/guide/providers/custom) today. [Native providers](/roadmap/cloud-providers) are planned. -- **No database provider** — tenant config from SQL requires a custom provider. [Native SQL support](/roadmap/database-provider) is planned. - -## Open Source Commitment - -The library is and stays **Apache-2.0**. Everything needed to run Cocoar.Configuration on your own is free, forever. ConfigHub is a separate commercial product for teams that need operational tooling at scale — the library does not require it. +# Roadmap + +Cocoar.Configuration is the open-source foundation — fully functional today for configuration, feature flags, entitlements, and secrets. Here's what we're building next. + +## At a Glance + +| Initiative | Status | Impact | +|---|---|---| +| [ConfigHub](/roadmap/confighub) | In Design | Management portal for config, secrets, and flags at scale | +| [Cloud Providers](/roadmap/cloud-providers) | Planned | Azure Key Vault, AWS Secrets Manager | +| [Database Provider](/roadmap/database-provider) | Planned | Tenant-specific config from SQL | + +## Priority Order + +We don't publish specific dates — we ship when it's ready. Rough priority: + +1. **Cloud Providers** (Azure Key Vault + AWS) — door openers for enterprise adoption +2. **Database Provider** — unlocks tenant-specific config from SQL +3. **ConfigHub private preview** — management portal for at-scale deployments + +## Current Limitations + +These are things the library does not do today: + +- **No management UI** — you manage config files, environment variables, and JSON directly. [ConfigHub](/roadmap/confighub) will address this. +- **No cloud KMS integration** — Azure Key Vault and AWS Secrets Manager require a [custom provider](/guide/providers/custom) today. [Native providers](/roadmap/cloud-providers) are planned. +- **No database provider** — tenant config from SQL requires a custom provider. [Native SQL support](/roadmap/database-provider) is planned. + +## Open Source Commitment + +The library is and stays **Apache-2.0**. Everything needed to run Cocoar.Configuration on your own is free, forever. ConfigHub is a separate commercial product for teams that need operational tooling at scale — the library does not require it.