diff --git a/CoverageResults/2f995bed-6a04-41f7-869e-8202020b2959/coverage.cobertura.xml b/CoverageResults/2f995bed-6a04-41f7-869e-8202020b2959/coverage.cobertura.xml new file mode 100644 index 0000000..2c40f1a --- /dev/null +++ b/CoverageResults/2f995bed-6a04-41f7-869e-8202020b2959/coverage.cobertura.xml @@ -0,0 +1,1858 @@ + + + + C:\Work\Projects\Published\FeatureOne\FeatureOne\src\FeatureOne\ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CoverageResults2/9b766267-cb66-45dc-b88c-9597939dfa8d/coverage.cobertura.xml b/CoverageResults2/9b766267-cb66-45dc-b88c-9597939dfa8d/coverage.cobertura.xml new file mode 100644 index 0000000..e22097c --- /dev/null +++ b/CoverageResults2/9b766267-cb66-45dc-b88c-9597939dfa8d/coverage.cobertura.xml @@ -0,0 +1,1858 @@ + + + + C:\Work\Projects\Published\FeatureOne\FeatureOne\src\FeatureOne\ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CoverageResults3/870b732e-9338-4793-ad0c-19c55d4963c8/coverage.cobertura.xml b/CoverageResults3/870b732e-9338-4793-ad0c-19c55d4963c8/coverage.cobertura.xml new file mode 100644 index 0000000..e119abd --- /dev/null +++ b/CoverageResults3/870b732e-9338-4793-ad0c-19c55d4963c8/coverage.cobertura.xml @@ -0,0 +1,1858 @@ + + + + C:\Work\Projects\Published\FeatureOne\FeatureOne\src\FeatureOne\ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/DeveloperGuide.md b/DeveloperGuide.md index afab0e1..ba07655 100644 --- a/DeveloperGuide.md +++ b/DeveloperGuide.md @@ -96,18 +96,18 @@ var feature = new Feature #### ii. Regex Condition `Regex` condition allows evaluating a regex expression against specified user claim value to enable a given feature. -Below is the serialized representation of toggle with regex condition. +Below is the serialized representation of toggle with regex condition. ``` { - "dashboard_widget":{ - "toggle":{ - + "dashboard_widget":{ + "toggle":{ + "conditions":[{ "type":"Regex", -- Regex Condition "claim":"email", -- Claim 'email' to be used for evaluation. "expression":"*@gbk.com" -- Regex expression to be used for evaluation. - }] - } + }] + } } } ``` @@ -119,8 +119,8 @@ var feature = new Feature Name ="dashboard_widget", // Feature Name Toggle = new Toggle // Toggle definition { - Operator = Operator.Any, - Conditions = new[] + Operator = Operator.Any, + Conditions = new[] { // Regex condition that evalues role of user to be administrator to enable the feature. new RegexCondition { Claim = "role", Expression = "administrator" } @@ -129,6 +129,60 @@ var feature = new Feature } ``` +#### iii. Relational Condition +`Relational` condition (class `RelationalCondition`) allows evaluating a user claim value against a fixed value using a relational operator. This is useful for enabling features based on user tiers, roles, or any string-comparable claim. + +Supported operators (`RelationalOperator` enum): + +| Operator | Description | +|---|---| +| `Equals` | Claim value equals the configured value | +| `NotEquals` | Claim value does not equal the configured value | +| `GreaterThan` | Claim value is lexicographically greater than the configured value | +| `GreaterThanOrEqual` | Claim value is lexicographically greater than or equal to the configured value | +| `LessThanOrEqual` | Claim value is lexicographically less than or equal to the configured value | +| `LessThan` | Defined in enum but **not yet implemented** — always returns `false` | + +> **Note:** String comparison is ordinal (via `string.Compare`). Both the claim value and the configured value are trimmed of leading/trailing whitespace before comparison. + +Below is the serialized representation of a toggle with a logical condition. +``` +{ + "dashboard_widget":{ + "toggle":{ + "operator":"any", + "conditions":[{ + "type":"Relational", -- Relational Condition + "claim":"tier", -- Claim name to evaluate + "operator":"GreaterThanOrEqual", -- Relational operator + "value":"gold" -- Value to compare the claim against + }] + } + } +} +``` +C# representation of a feature with a logical condition toggle is +``` +var feature = new Feature +{ + Name = "dashboard_widget", // Feature Name + Toggle = new Toggle // Toggle definition + { + Operator = Operator.Any, + Conditions = new[] + { + // Relational condition — enable feature for users with tier >= "gold" (lexicographic order). + new RelationalCondition + { + Claim = "tier", + Operator = RelationalOperator.GreaterThanOrEqual, + Value = "gold" + } + } + } +} +``` + ### Step 3. Implement Storage Provider. To use FeatureOne, you need to provide implementation for `Storage Provider` to get all the feature toggles from storage medium of choice. Implement `IStorageProvider` interface to return feature toggles from storage. diff --git a/GitVersion.yml b/GitVersion.yml index 996834d..990af2a 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,4 +1,4 @@ -next-version: 5.1.0 +next-version: 5.2.0 tag-prefix: '[vV]' mode: ContinuousDeployment branches: diff --git a/License.md b/License.md index e3a0025..0207e1c 100644 --- a/License.md +++ b/License.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Code Shayk +Copyright (c) 2026 Code Shayk Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 5e0da80..5121589 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,19 @@ - -# feature FeatureOne v5.1.0 +# feature-flag FeatureOne v5.2.0 [![GitHub Release](https://img.shields.io/github/v/release/CodeShayk/FeatureOne?logo=github&sort=semver)](https://github.com/CodeShayk/FeatureOne/releases/latest) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/CodeShayk/FeatureOne/blob/master/License.md) [![build-master](https://github.com/CodeShayk/FeatureOne/actions/workflows/Build-Master.yml/badge.svg)](https://github.com/CodeShayk/FeatureOne/actions/workflows/Build-Master.yml) [![CodeQL](https://github.com/CodeShayk/FeatureOne/actions/workflows/codeql.yml/badge.svg)](https://github.com/CodeShayk/FeatureOne/actions/workflows/codeql.yml) -[![.Net](https://img.shields.io/badge/.Net_Framework-4.6.2-blue)](https://dotnet.microsoft.com/en-us/download/dotnet-framework/net46) -[![.Net](https://img.shields.io/badge/.Net_Standard-2.1-blue)](https://dotnet.microsoft.com/en-us/download/netstandard/2.1) +[![.Net](https://img.shields.io/badge/.Net_Standard-2.1-green)](https://dotnet.microsoft.com/en-us/download/netstandard/2.1) [![.Net](https://img.shields.io/badge/.Net-9.0-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) +[![.Net](https://img.shields.io/badge/.Net-10.0-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) .Net Library to implement feature toggles. -- #### Nuget Packages | Package | Latest | Details | | --------| --------| --------| -|FeatureOne |[![NuGet version](https://badge.fury.io/nu/FeatureOne.svg)](https://badge.fury.io/nu/FeatureOne) | Provides core functionality to implement feature toggles with `no` backend storage provider. Needs package consumer to provide `IStorageProvider` implementation. Ideal for use case that requires custom storage backend. **v5.1.0**: Security fixes, DI integration, DateRangeCondition. | -|FeatureOne.SQL| [![NuGet version](https://badge.fury.io/nu/FeatureOne.SQL.svg)](https://badge.fury.io/nu/FeatureOne.SQL) | Provides SQL storage provider for implementing feature toggles using `SQL` backend. **v5.1.0**: Security fixes, DI integration, enhanced configuration. | -|FeatureOne.File |[![NuGet version](https://badge.fury.io/nu/FeatureOne.File.svg)](https://badge.fury.io/nu/FeatureOne.File) | Provides File storage provider for implementing feature toggles using `File System` backend. **v5.1.0**: Security fixes, DI integration, enhanced configuration. | +|FeatureOne |[![NuGet version](https://badge.fury.io/nu/FeatureOne.svg)](https://badge.fury.io/nu/FeatureOne) | Provides core functionality to implement feature toggles with `no` backend storage provider. Needs package consumer to provide `IStorageProvider` implementation. Ideal for use case that requires custom storage backend. **v5.2.0**: RelationalCondition, net10.0 support, package upgrades, expanded test coverage. | +|FeatureOne.SQL| [![NuGet version](https://badge.fury.io/nu/FeatureOne.SQL.svg)](https://badge.fury.io/nu/FeatureOne.SQL) | Provides SQL storage provider for implementing feature toggles using `SQL` backend. **v5.2.0**: net10.0 support, package upgrades. | +|FeatureOne.File |[![NuGet version](https://badge.fury.io/nu/FeatureOne.File.svg)](https://badge.fury.io/nu/FeatureOne.File) | Provides File storage provider for implementing feature toggles using `File System` backend. **v5.2.0**: net10.0 support, package upgrades. | ## Concept ### What is a feature toggle? @@ -62,6 +61,8 @@ The following previous versions are available: | Version | Release Notes | | ----------------------------------------------------------------| ----------------------------------------------------------------------| +| [`v5.2.0`](https://github.com/CodeShayk/FeatureOne/tree/v5.2.0) | [Notes](https://github.com/CodeShayk/FeatureOne/releases/tag/v5.2.0) | +| [`v5.1.0`](https://github.com/CodeShayk/FeatureOne/tree/v5.1.0) | [Notes](https://github.com/CodeShayk/FeatureOne/releases/tag/v5.1.0) | | [`v5.0.0`](https://github.com/CodeShayk/FeatureOne/tree/v5.0.0) | [Notes](https://github.com/CodeShayk/FeatureOne/releases/tag/v5.0.0) | | [`v4.0.0`](https://github.com/CodeShayk/FeatureOne/tree/v4.0.0) | [Notes](https://github.com/CodeShayk/FeatureOne/releases/tag/v4.0.0) | | [`v3.0.0`](https://github.com/CodeShayk/FeatureOne/tree/v3.0.0) | [Notes](https://github.com/CodeShayk/FeatureOne/releases/tag/v3.0.0) | @@ -73,6 +74,7 @@ The following previous versions are available: |--------|-------------|------|-------------|---------------------| | v5.0.0 | Previous | Initial | Core feature toggle functionality | N/A (Initial release) | | v5.1.0 | Nov 03, 2025 | Minor | **Security fixes** (ReDoS protection, secure type loading), **architectural improvements** (prefix matching, dependency injection), **new features** (DateRangeCondition, configuration validation), **DI integration** | High - maintains all existing functionality with minor security-related behavioral changes | +| v5.2.0 | Mar 18, 2026 | Minor | **New condition** (RelationalCondition with 5 relational operators), **target framework** (added net10.0, removed netstandard2.0 and net8.0), **package upgrades** (all MS packages to 10.0.5), **expanded test coverage** (98%+ line coverage) | High - fully backward compatible, additive changes only | ## Credits Thank you for reading. Please fork, explore, contribute and report. Happy Coding !! :) diff --git a/TestResults/3e827287-0829-480d-b301-36a5886cfe40/coverage.cobertura.xml b/TestResults/3e827287-0829-480d-b301-36a5886cfe40/coverage.cobertura.xml new file mode 100644 index 0000000..994ac43 --- /dev/null +++ b/TestResults/3e827287-0829-480d-b301-36a5886cfe40/coverage.cobertura.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/TestResults2/731c4b10-be51-46c5-a9a8-57f4cd2e3da9/coverage.opencover.xml b/TestResults2/731c4b10-be51-46c5-a9a8-57f4cd2e3da9/coverage.opencover.xml new file mode 100644 index 0000000..fd73e0e --- /dev/null +++ b/TestResults2/731c4b10-be51-46c5-a9a8-57f4cd2e3da9/coverage.opencover.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/FeatureOne.File/FeatureOne.File.csproj b/src/FeatureOne.File/FeatureOne.File.csproj index c422f28..0f80bb5 100644 --- a/src/FeatureOne.File/FeatureOne.File.csproj +++ b/src/FeatureOne.File/FeatureOne.File.csproj @@ -1,7 +1,7 @@  - net462;netstandard2.1;net9.0 + netstandard2.1;net9.0;net10.0 disable True False @@ -22,25 +22,22 @@ https://github.com/codeshayk/FeatureOne git feature-toggle; feature-flag; feature-flags; feature-toggles; featureOne; File-system; File-Backend; File-Toggles; - 5.1.0 + 5.2.0 License.md - ninja-icon-16.png + feature-flag.png - Release Notes v5.1.0. - Targets .Net Framework 4.6.2, .NetStandard 2.1 and .Net 9.0 + Release Notes v5.2.0. - Targets .NetStandard 2.1, .Net 9.0 and .Net 10.0 Library to Implement Feature Toggles to hide/show program features with File system storage. - Security Fixes: - - Fixed RegexCondition ReDoS (Regular Expression Denial of Service) vulnerability with timeout validation - - Secured dynamic type loading in ConditionDeserializer with explicit safe type registry - - Architectural Improvements: - - Fixed FindStartsWith implementation for actual prefix matching - - Implemented proper dependency injection patterns with null validation - + New Features: - - Added DateRangeCondition for time-based feature toggles - - Added Configuration Validation System with clear error messages - - Provides Out of box Simple and Regex toggle conditions. + - Added RelationalCondition for claim-based relational comparisons (Equals, NotEquals, GreaterThan, GreaterThanOrEqual, LessThanOrEqual) + + Framework and Package Updates: + - Added net10.0 target framework + - Removed netstandard2.0 and net8.0 target frameworks + - Upgraded all Microsoft packages to 10.0.5 + + Provides Out of box Simple, Regex, DateRange and Relational toggle conditions. Provides Out of box support for File system storage provider to store toggles on disk file. Provides the support for default memory caching via configuration. Provides extensibility for custom implementations ie. @@ -56,7 +53,7 @@ True \ - + True \ @@ -71,8 +68,7 @@ - - + diff --git a/src/FeatureOne.SQL/FeatureOne.SQL.csproj b/src/FeatureOne.SQL/FeatureOne.SQL.csproj index 41d3fcb..34bbf56 100644 --- a/src/FeatureOne.SQL/FeatureOne.SQL.csproj +++ b/src/FeatureOne.SQL/FeatureOne.SQL.csproj @@ -1,7 +1,7 @@  - net462;netstandard2.1;net9.0 + netstandard2.1;net9.0;net10.0 disable disable True @@ -23,26 +23,23 @@ https://github.com/CodeShayk/FeatureOne git feature-toggle; feature-flag; feature-flags; feature-toggles; featureOne; SQL-Backend; SQL-Toggles; SQL - 5.1.0 + 5.2.0 License.md - ninja-icon-16.png + feature-flag.png - Release Notes v5.1.0. - Targets .Net Framework 4.6.2, .NetStandard 2.1 and .Net 9.0 + Release Notes v5.2.0. - Targets .NetStandard 2.1, .Net 9.0 and .Net 10.0 Library to Implement Feature Toggles to hide/show program features with SQL storage. - Security Fixes: - - Fixed RegexCondition ReDoS (Regular Expression Denial of Service) vulnerability with timeout validation - - Secured dynamic type loading in ConditionDeserializer with explicit safe type registry - - Architectural Improvements: - - Fixed FindStartsWith implementation for actual prefix matching - - Implemented proper dependency injection patterns with null validation - + New Features: - - Added DateRangeCondition for time-based feature toggles - - Added Configuration Validation System with clear error messages - + - Added RelationalCondition for claim-based relational comparisons (Equals, NotEquals, GreaterThan, GreaterThanOrEqual, LessThanOrEqual) + + Framework and Package Updates: + - Added net10.0 target framework + - Removed netstandard2.0 and net8.0 target frameworks + - Upgraded all Microsoft packages to 10.0.5 + Supports configuring all Db providers - MSSQL, SQLite, ODBC, OLEDB, MySQL, PostgreSQL. - Provides Out of box Simple and Regex toggle conditions. + Provides Out of box Simple, Regex, DateRange, and Relational toggle conditions. Provides the support for default memory caching via configuration. Provides extensibility for custom implementations ie. -- Provides extensibility for implementing custom toggle conditions for bespoke use cases. @@ -58,7 +55,7 @@ True \ - + True \ @@ -69,10 +66,8 @@ - - - - + + diff --git a/src/FeatureOne/AssemblyInfo.cs b/src/FeatureOne/AssemblyInfo.cs index 1132093..9b501a5 100644 --- a/src/FeatureOne/AssemblyInfo.cs +++ b/src/FeatureOne/AssemblyInfo.cs @@ -13,14 +13,14 @@ [assembly: System.Reflection.AssemblyCompanyAttribute("Code Shayk")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] -[assembly: System.Reflection.AssemblyCopyrightAttribute("2024")] +[assembly: System.Reflection.AssemblyCopyrightAttribute("2026")] [assembly: System.Reflection.AssemblyDescriptionAttribute(".Net Library to implement feature toggles.")] -[assembly: System.Reflection.AssemblyFileVersionAttribute("4.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("4.0.0")] +[assembly: System.Reflection.AssemblyFileVersionAttribute("5.2.0.0")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("5.2.0")] [assembly: System.Reflection.AssemblyProductAttribute("FeatureOne")] [assembly: System.Reflection.AssemblyTitleAttribute("FeatureOne")] -[assembly: System.Reflection.AssemblyVersionAttribute("4.0.0.0")] -[assembly: System.Reflection.AssemblyMetadataAttribute("RepositoryUrl", "https://github.com/TechNinjaLabs/FeatureOne")] +[assembly: System.Reflection.AssemblyVersionAttribute("5.2.0.0")] +[assembly: System.Reflection.AssemblyMetadataAttribute("RepositoryUrl", "https://github.com/CodeShayk/FeatureOne")] // Generated by the MSBuild WriteCodeFragment class. diff --git a/src/FeatureOne/Core/Toggles/Conditions/RelationalCondition.cs b/src/FeatureOne/Core/Toggles/Conditions/RelationalCondition.cs new file mode 100644 index 0000000..f8cc0be --- /dev/null +++ b/src/FeatureOne/Core/Toggles/Conditions/RelationalCondition.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; + +namespace FeatureOne.Core.Toggles.Conditions +{ + public class RelationalCondition : ICondition + { + public string Claim { get; set; } + public RelationalOperator Operator { get; set; } + public string Value { get; set; } + + public bool Evaluate(IDictionary claims) + { + if (claims == null) + return false; + + if (!claims.Any(x => x.Key != null && x.Key.Equals(Claim))) + return false; + + var claimValue = claims.First(x => x.Key.Equals(Claim)).Value?.Trim() ?? string.Empty; + var comparisonValue = Value?.Trim() ?? string.Empty; + + switch (Operator) + { + case RelationalOperator.Equals: + return claimValue == comparisonValue; + case RelationalOperator.NotEquals: + return claimValue != comparisonValue; + case RelationalOperator.GreaterThan: + return string.Compare(claimValue, comparisonValue) > 0; + case RelationalOperator.GreaterThanOrEqual: + return string.Compare(claimValue, comparisonValue) >= 0; + case RelationalOperator.LessThanOrEqual: + return string.Compare(claimValue, comparisonValue) <= 0; + default: + return false; + } + } + } + public enum RelationalOperator + { + Equals, + NotEquals, + GreaterThan, + LessThan, + GreaterThanOrEqual, + LessThanOrEqual + } +} \ No newline at end of file diff --git a/src/FeatureOne/FeatureOne.csproj b/src/FeatureOne/FeatureOne.csproj index 7127c29..6531a82 100644 --- a/src/FeatureOne/FeatureOne.csproj +++ b/src/FeatureOne/FeatureOne.csproj @@ -1,7 +1,7 @@ - net462;netstandard2.1;net9.0 + netstandard2.1;net9.0;net10.0 True False AssemblyInfo.cs @@ -21,25 +21,25 @@ https://github.com/CodeShayk/FeatureOne git feature-toggle; feature-flag; feature-flags; feature-toggles; featureOne - 5.1.0 + 5.2.0 LICENSE.md - ninja-icon-16.png + feature-flag.png - Release Notes v5.1.0 Core Functionality :- Targets .Net Framework 4.6.2, .NetStandard 2.1 and .Net 9.0 + Release Notes v5.2.0 Core Functionality :- Targets .NetStandard 2.1, .Net 9.0 and .Net 10.0 Library to Implement Feature Toggles to hide/show program features. Does not contain storage provider. - Security Fixes: - - Fixed RegexCondition ReDoS (Regular Expression Denial of Service) vulnerability with timeout validation - - Secured dynamic type loading in ConditionDeserializer with explicit safe type registry - - Architectural Improvements: - - Fixed FindStartsWith implementation for actual prefix matching - - Implemented proper dependency injection patterns with null validation - + New Features: - - Added DateRangeCondition for time-based feature toggles - - Added Configuration Validation System with clear error messages - - Provides Out of box Simple and Regex toggle conditions. + - Added RelationalCondition for claim-based relational comparisons (Equals, NotEquals, GreaterThan, GreaterThanOrEqual, LessThanOrEqual) + + Framework and Package Updates: + - Added net10.0 target framework + - Removed netstandard2.0 and net8.0 target frameworks + - Upgraded all Microsoft packages to 10.0.5 + + Test Coverage: + - Expanded unit test coverage to 98%+ line coverage + + Provides Out of box Simple, Regex and Relational toggle conditions. Provides extensibility for custom implementations ie. -- No storage exists by default. Requires `IStorageProvider` implementation to plugin in backend data store for stored features. -- Provides extensibility to implement custom toggle conditions for bespoke use cases. @@ -49,10 +49,10 @@ - - - - + + + + @@ -60,7 +60,7 @@ True \ - + True \ diff --git a/src/FeatureOne/Json/ConditionDeserializer.cs b/src/FeatureOne/Json/ConditionDeserializer.cs index 875ad91..e8d737c 100644 --- a/src/FeatureOne/Json/ConditionDeserializer.cs +++ b/src/FeatureOne/Json/ConditionDeserializer.cs @@ -19,7 +19,9 @@ public class ConditionDeserializer : IConditionDeserializer { "Regex", typeof(RegexCondition) }, { "RegexCondition", typeof(RegexCondition) }, { "DateRange", typeof(DateRangeCondition) }, - { "DateRangeCondition", typeof(DateRangeCondition) } + { "DateRangeCondition", typeof(DateRangeCondition) }, + { "Relational", typeof(RelationalCondition) }, + { "RelationalCondition", typeof(RelationalCondition) } }; public ICondition Deserialize(JsonObject condition) diff --git a/test/FeatureOne.File.Tests/E2eTests/End2EndTests.File.cs b/test/FeatureOne.File.Tests/E2eTests/End2EndTests.File.cs index 00534dd..029c991 100644 --- a/test/FeatureOne.File.Tests/E2eTests/End2EndTests.File.cs +++ b/test/FeatureOne.File.Tests/E2eTests/End2EndTests.File.cs @@ -44,5 +44,21 @@ public void TestForGBKDashboardToBeEnabledForUsersWithGBKEmails() enabled = Features.Current.IsEnabled("gbk_dashboard", user2_claims); Assert.That(enabled == true); } + + [Test] + public void TestForTierFeatureToBeEnabledForGoldAndAbove() + { + var bronze_claims = new[] { new Claim("tier", "bronze") }; + var enabled = Features.Current.IsEnabled("tier_feature", bronze_claims); + Assert.That(enabled == false); + + var gold_claims = new[] { new Claim("tier", "gold") }; + enabled = Features.Current.IsEnabled("tier_feature", gold_claims); + Assert.That(enabled == true); + + var platinum_claims = new[] { new Claim("tier", "platinum") }; + enabled = Features.Current.IsEnabled("tier_feature", platinum_claims); + Assert.That(enabled == true); + } } } \ No newline at end of file diff --git a/test/FeatureOne.File.Tests/FeatureOne.File.Tests.csproj b/test/FeatureOne.File.Tests/FeatureOne.File.Tests.csproj index f629960..46c3d15 100644 --- a/test/FeatureOne.File.Tests/FeatureOne.File.Tests.csproj +++ b/test/FeatureOne.File.Tests/FeatureOne.File.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable @@ -10,15 +10,15 @@ - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/FeatureOne.File.Tests/Features.json b/test/FeatureOne.File.Tests/Features.json index d43d28d..8b3aa7c 100644 --- a/test/FeatureOne.File.Tests/Features.json +++ b/test/FeatureOne.File.Tests/Features.json @@ -24,5 +24,17 @@ } ] } + }, + "tier_feature": { + "toggle": { + "conditions": [ + { + "type": "Relational", + "claim": "tier", + "operator": "GreaterThanOrEqual", + "value": "gold" + } + ] + } } } \ No newline at end of file diff --git a/test/FeatureOne.SQL.Tests/FeatureOne.SQL.Tests.csproj b/test/FeatureOne.SQL.Tests/FeatureOne.SQL.Tests.csproj index be682fe..d46a2c7 100644 --- a/test/FeatureOne.SQL.Tests/FeatureOne.SQL.Tests.csproj +++ b/test/FeatureOne.SQL.Tests/FeatureOne.SQL.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable @@ -10,21 +10,20 @@ - - + - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/test/FeatureOne.SQL.Tests/UnitTests/RelationalConditionSQLTests.cs b/test/FeatureOne.SQL.Tests/UnitTests/RelationalConditionSQLTests.cs new file mode 100644 index 0000000..424d9b1 --- /dev/null +++ b/test/FeatureOne.SQL.Tests/UnitTests/RelationalConditionSQLTests.cs @@ -0,0 +1,62 @@ +using System.Security.Claims; +using FeatureOne.Core.Stores; +using FeatureOne.Json; +using FeatureOne.SQL.StorageProvider; +using Moq; + +namespace FeatureOne.SQL.Tests.UnitTests +{ + [TestFixture] + public class RelationalConditionSQLTests + { + private Features _features; + + [OneTimeSetUp] + public void OneTimeSetup() + { + var repository = new Mock(); + + repository.Setup(x => x.GetByName(It.Is(n => n.StartsWith("tier_feature")))) + .Returns(new[] + { + new DbRecord + { + Name = "tier_feature", + Toggle = @"{""conditions"":[{""type"":""Relational"",""claim"":""tier"",""operator"":""GreaterThanOrEqual"",""value"":""gold""}]}" + } + }); + + var provider = new SQLStorageProvider(repository.Object, new ToggleDeserializer(new ConditionDeserializer()), new FeatureOne.Cache.FeatureCache(), null); + + _features = new Features(new FeatureStore(provider)); + } + + [Test] + public void TierFeature_WhenTierIsBronze_ShouldBeDisabled() + { + var claims = new[] { new Claim("tier", "bronze") }; + Assert.That(_features.IsEnabled("tier_feature", claims), Is.False); + } + + [Test] + public void TierFeature_WhenTierIsGold_ShouldBeEnabled() + { + var claims = new[] { new Claim("tier", "gold") }; + Assert.That(_features.IsEnabled("tier_feature", claims), Is.True); + } + + [Test] + public void TierFeature_WhenTierIsPlatinum_ShouldBeEnabled() + { + var claims = new[] { new Claim("tier", "platinum") }; + Assert.That(_features.IsEnabled("tier_feature", claims), Is.True); + } + + [Test] + public void TierFeature_WhenNoTierClaim_ShouldBeDisabled() + { + var claims = new[] { new Claim("email", "user@example.com") }; + Assert.That(_features.IsEnabled("tier_feature", claims), Is.False); + } + } +} diff --git a/test/FeatureOne.Tests/Cache/CacheTests.cs b/test/FeatureOne.Tests/Cache/CacheTests.cs new file mode 100644 index 0000000..8959576 --- /dev/null +++ b/test/FeatureOne.Tests/Cache/CacheTests.cs @@ -0,0 +1,77 @@ +using System.Runtime.Caching; +using FeatureOne.Cache; + +namespace FeatureOne.Tests.Cache; + +[TestFixture] +public class CacheTests +{ + [Test] + public void CacheSettings_DefaultValues_ShouldBeCorrect() + { + var settings = new CacheSettings(); + + Assert.That(settings.EnableCache, Is.False); + Assert.That(settings.Expiry, Is.Not.Null); + Assert.That(settings.Expiry.InMinutes, Is.EqualTo(60)); + Assert.That(settings.Expiry.Type, Is.EqualTo(CacheExpiryType.Absolute)); + } + + [Test] + public void CacheSettings_SetProperties_ShouldWork() + { + var expiry = new CacheExpiry { InMinutes = 30, Type = CacheExpiryType.Sliding }; + var settings = new CacheSettings { EnableCache = true, Expiry = expiry }; + + Assert.That(settings.EnableCache, Is.True); + Assert.That(settings.Expiry.InMinutes, Is.EqualTo(30)); + Assert.That(settings.Expiry.Type, Is.EqualTo(CacheExpiryType.Sliding)); + } + + [Test] + public void ExpiryPolicyExtension_AbsoluteExpiry_ShouldReturnAbsolutePolicy() + { + var expiry = new CacheExpiry { InMinutes = 10, Type = CacheExpiryType.Absolute }; + + var policy = expiry.GetPolicy(); + + Assert.That(policy, Is.Not.Null); + Assert.That(policy.AbsoluteExpiration, Is.Not.EqualTo(DateTimeOffset.MinValue)); + Assert.That(policy.SlidingExpiration, Is.EqualTo(TimeSpan.Zero)); + } + + [Test] + public void ExpiryPolicyExtension_SlidingExpiry_ShouldReturnSlidingPolicy() + { + var expiry = new CacheExpiry { InMinutes = 15, Type = CacheExpiryType.Sliding }; + + var policy = expiry.GetPolicy(); + + Assert.That(policy, Is.Not.Null); + Assert.That(policy.SlidingExpiration, Is.EqualTo(TimeSpan.FromMinutes(15))); + } + + [Test] + public void FeatureCache_AddAndGet_ShouldWork() + { + var cache = new FeatureCache(); + var key = $"test-key-{Guid.NewGuid()}"; + var value = new object(); + var policy = new CacheItemPolicy { AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(10) }; + + cache.Add(key, value, policy); + var result = cache.Get(key); + + Assert.That(result, Is.EqualTo(value)); + } + + [Test] + public void FeatureCache_GetNonExistentKey_ShouldReturnNull() + { + var cache = new FeatureCache(); + + var result = cache.Get($"non-existent-key-{Guid.NewGuid()}"); + + Assert.That(result, Is.Null); + } +} diff --git a/test/FeatureOne.Tests/Core/FeatureCoverageTests.cs b/test/FeatureOne.Tests/Core/FeatureCoverageTests.cs new file mode 100644 index 0000000..a45ef7c --- /dev/null +++ b/test/FeatureOne.Tests/Core/FeatureCoverageTests.cs @@ -0,0 +1,30 @@ +namespace FeatureOne.Tests.Core; + +[TestFixture] +public class FeatureCoverageTests +{ + // Derived class to exercise protected constructor + private class TestableFeature : Feature + { + public TestableFeature() : base() + { + } + + public void SetNameAndToggle(FeatureName name, IToggle toggle) + { + Name = name; + Toggle = toggle; + } + } + + [Test] + public void Feature_ProtectedConstructor_ShouldCreateInstance() + { + var feature = new TestableFeature(); + feature.SetNameAndToggle(new FeatureName("TestFeature"), + new Toggle(Operator.Any, new SimpleCondition { IsEnabled = true })); + + Assert.That(feature.Name.Value, Is.EqualTo("TestFeature")); + Assert.That(feature.IsEnabled(new Dictionary()), Is.True); + } +} diff --git a/test/FeatureOne.Tests/Core/LambdaComparerTest.cs b/test/FeatureOne.Tests/Core/LambdaComparerTest.cs new file mode 100644 index 0000000..21aa5b5 --- /dev/null +++ b/test/FeatureOne.Tests/Core/LambdaComparerTest.cs @@ -0,0 +1,29 @@ +namespace FeatureOne.Tests.Core; + +[TestFixture] +public class LambdaComparerTest +{ + [Test] + public void LambdaComparer_Equals_ShouldUseProvidedFunction() + { + var comparer = new LambdaComparer((x, y) => x.Equals(y, StringComparison.OrdinalIgnoreCase)); + + Assert.That(comparer.Equals("Hello", "hello"), Is.True); + Assert.That(comparer.Equals("Hello", "World"), Is.False); + } + + [Test] + public void LambdaComparer_GetHashCode_ShouldReturnObjectHashCode() + { + var comparer = new LambdaComparer((x, y) => x == y); + var value = "test"; + + Assert.That(comparer.GetHashCode(value), Is.EqualTo(value.GetHashCode())); + } + + [Test] + public void LambdaComparer_NullEqualityFunction_ShouldThrow() + { + Assert.Throws(() => new LambdaComparer(null)); + } +} diff --git a/test/FeatureOne.Tests/Core/NullStoreProviderTest.cs b/test/FeatureOne.Tests/Core/NullStoreProviderTest.cs new file mode 100644 index 0000000..13074a0 --- /dev/null +++ b/test/FeatureOne.Tests/Core/NullStoreProviderTest.cs @@ -0,0 +1,27 @@ +namespace FeatureOne.Tests.Core; + +[TestFixture] +public class NullStoreProviderTest +{ + [Test] + public void NullStoreProvider_GetByName_ShouldReturnEmpty() + { + var provider = new NullStoreProvider(); + + var result = provider.GetByName("AnyFeature"); + + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.Empty); + } + + [Test] + public void NullStoreProvider_GetByName_WithNullName_ShouldReturnEmpty() + { + var provider = new NullStoreProvider(); + + var result = provider.GetByName(null); + + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.Empty); + } +} diff --git a/test/FeatureOne.Tests/Core/ToggleCoverageTests.cs b/test/FeatureOne.Tests/Core/ToggleCoverageTests.cs new file mode 100644 index 0000000..086dd87 --- /dev/null +++ b/test/FeatureOne.Tests/Core/ToggleCoverageTests.cs @@ -0,0 +1,46 @@ +namespace FeatureOne.Tests.Core; + +[TestFixture] +public class ToggleCoverageTests +{ + [Test] + public void Toggle_DefaultConstructor_ShouldCreateWithAnyOperatorAndEmptyConditions() + { + var toggle = new Toggle(); + + Assert.That(toggle.Operator, Is.EqualTo(Operator.Any)); + Assert.That(toggle.Conditions, Is.Not.Null); + Assert.That(toggle.Conditions, Is.Empty); + } + + [Test] + public void Toggle_WithNullConditionsArray_ShouldUseEmptyArray() + { + var toggle = new Toggle(Operator.All, (ICondition[])null); + + Assert.That(toggle.Conditions, Is.Not.Null); + Assert.That(toggle.Conditions, Is.Empty); + } + + [Test] + public void Toggle_Run_WithNullConditions_ShouldReturnFalse() + { + // Set Conditions to null via the property setter after construction + var toggle = new Toggle(Operator.Any, new SimpleCondition { IsEnabled = true }); + toggle.Conditions = null; + + var result = toggle.Run(new Dictionary()); + + Assert.That(result, Is.False); + } + + [Test] + public void Toggle_Run_WithEmptyConditions_AndAnyOperator_ShouldReturnFalse() + { + var toggle = new Toggle(); // default - empty conditions + + var result = toggle.Run(new Dictionary()); + + Assert.That(result, Is.False); + } +} diff --git a/test/FeatureOne.Tests/Extensions/FeatureOneServiceExtensionsTests.cs b/test/FeatureOne.Tests/Extensions/FeatureOneServiceExtensionsTests.cs new file mode 100644 index 0000000..231ccd0 --- /dev/null +++ b/test/FeatureOne.Tests/Extensions/FeatureOneServiceExtensionsTests.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace FeatureOne.Tests.Extensions; + +[TestFixture] +public class FeatureOneServiceExtensionsTests +{ + [Test] + public void AddFeatureOne_WithNullFactory_ShouldThrow() + { + var services = new ServiceCollection(); + + Assert.Throws(() => services.AddFeatureOne(null)); + } +} diff --git a/test/FeatureOne.Tests/FeatureOne.Tests.csproj b/test/FeatureOne.Tests/FeatureOne.Tests.csproj index 2ffbfa7..82005ff 100644 --- a/test/FeatureOne.Tests/FeatureOne.Tests.csproj +++ b/test/FeatureOne.Tests/FeatureOne.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable @@ -9,18 +9,18 @@ - - - - + + + + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/FeatureOne.Tests/FeaturesEdgeCaseTests.cs b/test/FeatureOne.Tests/FeaturesEdgeCaseTests.cs new file mode 100644 index 0000000..66da503 --- /dev/null +++ b/test/FeatureOne.Tests/FeaturesEdgeCaseTests.cs @@ -0,0 +1,60 @@ +using Moq; + +namespace FeatureOne.Tests; + +[TestFixture] +public class FeaturesEdgeCaseTests +{ + [Test] + public void IsEnabled_WithNullClaimsDictionary_ShouldStillEvaluateFeature() + { + // Arrange + var mockStore = new Mock(); + var testFeature = new Feature(new FeatureName("TestFeature"), + new Toggle(Operator.Any, new SimpleCondition { IsEnabled = true })); + + mockStore.Setup(s => s.FindStartsWith("TestFeature")).Returns(new[] { testFeature }); + + var features = new Features(mockStore.Object); + + // Act - pass null claims dictionary + var result = features.IsEnabled("TestFeature", (IDictionary)null); + + // Assert - should still evaluate (SimpleCondition doesn't care about claims) + Assert.That(result, Is.True); + } + + [Test] + public void IsEnabled_WithInvalidFeatureName_ShouldReturnFalse() + { + // Arrange + var mockStore = new Mock(); + var mockLogger = new Mock(); + var features = new Features(mockStore.Object, mockLogger.Object); + + // Act - pass a name with invalid characters + var result = features.IsEnabled("Invalid Feature Name With Spaces"); + + // Assert + Assert.That(result, Is.False); + mockStore.Verify(s => s.FindStartsWith(It.IsAny()), Times.Never); + } + + [Test] + public void IsEnabled_WhenStoreReturnsEmptyList_ShouldReturnFalse() + { + // Arrange + var mockStore = new Mock(); + var mockLogger = new Mock(); + + mockStore.Setup(s => s.FindStartsWith(It.IsAny())).Returns(Array.Empty()); + + var features = new Features(mockStore.Object, mockLogger.Object); + + // Act + var result = features.IsEnabled("ValidFeatureName"); + + // Assert + Assert.That(result, Is.False); + } +} diff --git a/test/FeatureOne.Tests/Stores/FeatureStoreEdgeCaseTests.cs b/test/FeatureOne.Tests/Stores/FeatureStoreEdgeCaseTests.cs new file mode 100644 index 0000000..e7f1119 --- /dev/null +++ b/test/FeatureOne.Tests/Stores/FeatureStoreEdgeCaseTests.cs @@ -0,0 +1,65 @@ +using Moq; + +namespace FeatureOne.Tests.Stores; + +[TestFixture] +public class FeatureStoreEdgeCaseTests +{ + [Test] + public void FindStartsWith_WhenProviderReturnsNull_ShouldReturnEmpty() + { + // Arrange + var mockProvider = new Mock(); + mockProvider.Setup(p => p.GetByName(It.IsAny())).Returns((IFeature[])null); + + var store = new FeatureStore(mockProvider.Object); + + // Act + var result = store.FindStartsWith("Feature").ToList(); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public void FindStartsWith_WhenProviderReturnsEmpty_ShouldReturnEmpty() + { + // Arrange + var mockProvider = new Mock(); + mockProvider.Setup(p => p.GetByName(It.IsAny())).Returns(Array.Empty()); + + var store = new FeatureStore(mockProvider.Object); + + // Act + var result = store.FindStartsWith("Feature").ToList(); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public void FindStartsWith_WhenProviderThrows_ShouldReturnEmpty() + { + // Arrange + var mockProvider = new Mock(); + mockProvider.Setup(p => p.GetByName(It.IsAny())).Throws(); + + var mockLogger = new Mock(); + var store = new FeatureStore(mockProvider.Object, mockLogger.Object); + + // Act + var result = store.FindStartsWith("Feature").ToList(); + + // Assert + Assert.That(result, Is.Empty); + mockLogger.Verify(l => l.Error(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void FeatureStore_ConstructorWithNullLogger_ShouldThrow() + { + var mockProvider = new Mock(); + + Assert.Throws(() => new FeatureStore(mockProvider.Object, null)); + } +} diff --git a/test/FeatureOne.Tests/Toggles/Conditions/RelationalConditionTests.cs b/test/FeatureOne.Tests/Toggles/Conditions/RelationalConditionTests.cs new file mode 100644 index 0000000..8c91acc --- /dev/null +++ b/test/FeatureOne.Tests/Toggles/Conditions/RelationalConditionTests.cs @@ -0,0 +1,212 @@ +using FeatureOne.Core.Toggles.Conditions; + +namespace FeatureOne.Tests.Toggles.Conditions; + +[TestFixture] +public class RelationalConditionTests +{ + // ────────────────────────────────────────────── + // Null / missing-claim guard tests + // ────────────────────────────────────────────── + + [Test] + public void Evaluate_WithNullClaims_ShouldReturnFalse() + { + var condition = new RelationalCondition { Claim = "role", Operator = RelationalOperator.Equals, Value = "admin" }; + + Assert.That(condition.Evaluate(null), Is.False); + } + + [Test] + public void Evaluate_WhenClaimNotPresent_ShouldReturnFalse() + { + var condition = new RelationalCondition { Claim = "role", Operator = RelationalOperator.Equals, Value = "admin" }; + var claims = new Dictionary { { "email", "user@example.com" } }; + + Assert.That(condition.Evaluate(claims), Is.False); + } + + // ────────────────────────────────────────────── + // Equals + // ────────────────────────────────────────────── + + [Test] + public void Evaluate_Equals_WhenValuesMatch_ShouldReturnTrue() + { + var condition = new RelationalCondition { Claim = "role", Operator = RelationalOperator.Equals, Value = "admin" }; + var claims = new Dictionary { { "role", "admin" } }; + + Assert.That(condition.Evaluate(claims), Is.True); + } + + [Test] + public void Evaluate_Equals_WhenValuesDiffer_ShouldReturnFalse() + { + var condition = new RelationalCondition { Claim = "role", Operator = RelationalOperator.Equals, Value = "admin" }; + var claims = new Dictionary { { "role", "user" } }; + + Assert.That(condition.Evaluate(claims), Is.False); + } + + [Test] + public void Evaluate_Equals_TrimsWhitespace() + { + var condition = new RelationalCondition { Claim = "role", Operator = RelationalOperator.Equals, Value = " admin " }; + var claims = new Dictionary { { "role", " admin " } }; + + Assert.That(condition.Evaluate(claims), Is.True); + } + + // ────────────────────────────────────────────── + // NotEquals + // ────────────────────────────────────────────── + + [Test] + public void Evaluate_NotEquals_WhenValuesDiffer_ShouldReturnTrue() + { + var condition = new RelationalCondition { Claim = "role", Operator = RelationalOperator.NotEquals, Value = "admin" }; + var claims = new Dictionary { { "role", "user" } }; + + Assert.That(condition.Evaluate(claims), Is.True); + } + + [Test] + public void Evaluate_NotEquals_WhenValuesMatch_ShouldReturnFalse() + { + var condition = new RelationalCondition { Claim = "role", Operator = RelationalOperator.NotEquals, Value = "admin" }; + var claims = new Dictionary { { "role", "admin" } }; + + Assert.That(condition.Evaluate(claims), Is.False); + } + + // ────────────────────────────────────────────── + // GreaterThan + // ────────────────────────────────────────────── + + [Test] + public void Evaluate_GreaterThan_WhenClaimIsGreater_ShouldReturnTrue() + { + var condition = new RelationalCondition { Claim = "tier", Operator = RelationalOperator.GreaterThan, Value = "bronze" }; + var claims = new Dictionary { { "tier", "gold" } }; + + Assert.That(condition.Evaluate(claims), Is.True); + } + + [Test] + public void Evaluate_GreaterThan_WhenClaimIsEqual_ShouldReturnFalse() + { + var condition = new RelationalCondition { Claim = "tier", Operator = RelationalOperator.GreaterThan, Value = "gold" }; + var claims = new Dictionary { { "tier", "gold" } }; + + Assert.That(condition.Evaluate(claims), Is.False); + } + + [Test] + public void Evaluate_GreaterThan_WhenClaimIsLess_ShouldReturnFalse() + { + var condition = new RelationalCondition { Claim = "tier", Operator = RelationalOperator.GreaterThan, Value = "gold" }; + var claims = new Dictionary { { "tier", "bronze" } }; + + Assert.That(condition.Evaluate(claims), Is.False); + } + + // ────────────────────────────────────────────── + // GreaterThanOrEqual + // ────────────────────────────────────────────── + + [Test] + public void Evaluate_GreaterThanOrEqual_WhenClaimIsGreater_ShouldReturnTrue() + { + var condition = new RelationalCondition { Claim = "tier", Operator = RelationalOperator.GreaterThanOrEqual, Value = "bronze" }; + var claims = new Dictionary { { "tier", "gold" } }; + + Assert.That(condition.Evaluate(claims), Is.True); + } + + [Test] + public void Evaluate_GreaterThanOrEqual_WhenClaimIsEqual_ShouldReturnTrue() + { + var condition = new RelationalCondition { Claim = "tier", Operator = RelationalOperator.GreaterThanOrEqual, Value = "gold" }; + var claims = new Dictionary { { "tier", "gold" } }; + + Assert.That(condition.Evaluate(claims), Is.True); + } + + [Test] + public void Evaluate_GreaterThanOrEqual_WhenClaimIsLess_ShouldReturnFalse() + { + var condition = new RelationalCondition { Claim = "tier", Operator = RelationalOperator.GreaterThanOrEqual, Value = "gold" }; + var claims = new Dictionary { { "tier", "bronze" } }; + + Assert.That(condition.Evaluate(claims), Is.False); + } + + // ────────────────────────────────────────────── + // LessThanOrEqual + // ────────────────────────────────────────────── + + [Test] + public void Evaluate_LessThanOrEqual_WhenClaimIsLess_ShouldReturnTrue() + { + var condition = new RelationalCondition { Claim = "tier", Operator = RelationalOperator.LessThanOrEqual, Value = "gold" }; + var claims = new Dictionary { { "tier", "bronze" } }; + + Assert.That(condition.Evaluate(claims), Is.True); + } + + [Test] + public void Evaluate_LessThanOrEqual_WhenClaimIsEqual_ShouldReturnTrue() + { + var condition = new RelationalCondition { Claim = "tier", Operator = RelationalOperator.LessThanOrEqual, Value = "gold" }; + var claims = new Dictionary { { "tier", "gold" } }; + + Assert.That(condition.Evaluate(claims), Is.True); + } + + [Test] + public void Evaluate_LessThanOrEqual_WhenClaimIsGreater_ShouldReturnFalse() + { + var condition = new RelationalCondition { Claim = "tier", Operator = RelationalOperator.LessThanOrEqual, Value = "bronze" }; + var claims = new Dictionary { { "tier", "gold" } }; + + Assert.That(condition.Evaluate(claims), Is.False); + } + + // ────────────────────────────────────────────── + // LessThan — defined in enum but not in switch; + // falls through to default and returns false. + // ────────────────────────────────────────────── + + [Test] + public void Evaluate_LessThan_ReturnsDefaultFalse() + { + // LessThan is not handled in the switch statement; default branch returns false. + var condition = new RelationalCondition { Claim = "tier", Operator = RelationalOperator.LessThan, Value = "gold" }; + var claims = new Dictionary { { "tier", "bronze" } }; + + Assert.That(condition.Evaluate(claims), Is.False); + } + + // ────────────────────────────────────────────── + // Null value edge cases + // ────────────────────────────────────────────── + + [Test] + public void Evaluate_Equals_WhenClaimValueIsNull_TreatsAsEmptyString() + { + // null claim value is normalised to "" by the ?. Trim() ?? "" guard + var condition = new RelationalCondition { Claim = "role", Operator = RelationalOperator.Equals, Value = "" }; + var claims = new Dictionary { { "role", null } }; + + Assert.That(condition.Evaluate(claims), Is.True); + } + + [Test] + public void Evaluate_Equals_WhenConditionValueIsNull_TreatsAsEmptyString() + { + var condition = new RelationalCondition { Claim = "role", Operator = RelationalOperator.Equals, Value = null }; + var claims = new Dictionary { { "role", "" } }; + + Assert.That(condition.Evaluate(claims), Is.True); + } +} diff --git a/test/FeatureOne.Tests/Validation/ConfigurationValidatorCoverageTests.cs b/test/FeatureOne.Tests/Validation/ConfigurationValidatorCoverageTests.cs new file mode 100644 index 0000000..da5dab8 --- /dev/null +++ b/test/FeatureOne.Tests/Validation/ConfigurationValidatorCoverageTests.cs @@ -0,0 +1,118 @@ +namespace FeatureOne.Tests.Validation; + +[TestFixture] +public class ConfigurationValidatorCoverageTests +{ + private ConfigurationValidator _validator; + + [SetUp] + public void Setup() + { + _validator = new ConfigurationValidator(); + } + + [Test] + public void ValidateCondition_WithPatternHavingDoubleQuantifiers_ShouldFail() + { + // Pattern with double quantifiers like (abc)+* triggers the second check + var condition = new RegexCondition + { + Claim = "test", + Expression = @"(abc)+*" // double quantifier: + followed by * + }; + + var result = _validator.ValidateCondition(condition); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void ValidateCondition_WithAlternationQuantifier_ShouldFail() + { + // Pattern with alternation like (a|b)+ triggers HasPotentiallyDangerousAlternation + var condition = new RegexCondition + { + Claim = "test", + Expression = @"(foo|bar)+" + }; + + var result = _validator.ValidateCondition(condition); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void ValidateCondition_WithDeeplyNestedGroups_ShouldFail() + { + // Pattern with more than 10 nesting levels triggers HasComplexNestedStructure + var condition = new RegexCondition + { + Claim = "test", + Expression = @"(((((((((((a)))))))))))" // 11 levels deep + }; + + var result = _validator.ValidateCondition(condition); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void ValidateCondition_WithTripleQuantifiers_ShouldFail() + { + // Pattern with three consecutive quantifiers triggers HasComplexNestedStructure + var condition = new RegexCondition + { + Claim = "test", + Expression = @"a+?*" // three consecutive quantifiers + }; + + var result = _validator.ValidateCondition(condition); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void ValidateCondition_WithNestedQuantifierGroup_ShouldFail() + { + // Pattern like (a*b)+ triggers HasSpecificDangerousPatterns nested quantifier check + var condition = new RegexCondition + { + Claim = "test", + Expression = @"(a*b)+" + }; + + var result = _validator.ValidateCondition(condition); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void ValidateCondition_WithConcatenatedSpecialChars_ShouldFail() + { + // Pattern with consecutive special regex chars (.*) triggers HasSpecificDangerousPatterns + var condition = new RegexCondition + { + Claim = "test", + Expression = @"a.*b" // .* is two consecutive special chars + }; + + var result = _validator.ValidateCondition(condition); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void ValidateCondition_WithSimpleSafePattern_ShouldPass() + { + // A simple safe pattern should pass all checks + var condition = new RegexCondition + { + Claim = "role", + Expression = @"^admin$" + }; + + var result = _validator.ValidateCondition(condition); + + Assert.That(result.IsValid, Is.True); + } +}