diff --git a/scanpipe/pipes/license_clarity.py b/scanpipe/pipes/compliance_thresholds.py similarity index 55% rename from scanpipe/pipes/license_clarity.py rename to scanpipe/pipes/compliance_thresholds.py index fa835fc0eb..2603c55730 100644 --- a/scanpipe/pipes/license_clarity.py +++ b/scanpipe/pipes/compliance_thresholds.py @@ -21,22 +21,40 @@ # Visit https://github.com/nexB/scancode.io for support and download. """ -License Clarity Thresholds Management +Thresholds Management for License Clarity and Scorecard Compliance This module provides an independent mechanism to read, validate, and evaluate -license clarity score thresholds from policy files. Unlike license policies -which are applied during scan processing, clarity thresholds are evaluated -post-scan during summary generation. +both license clarity and OpenSSF Scorecard score thresholds from policy files. +Unlike license and security policies which are applied during scan processing, +these thresholds are evaluated post-scan during summary generation and compliance +assessment. -The clarity thresholds system uses a simple key-value mapping where: -- Keys are integer threshold values (minimum scores) +The thresholds system uses simple key-value mappings where: +- Keys are numeric threshold values (minimum scores) - Values are compliance alert levels ('ok', 'warning', 'error') +License Clarity Thresholds: +- Keys: integer threshold values (minimum clarity scores, 0-100 scale) +- Represents license information completeness percentage + +Scorecard Compliance Thresholds: +- Keys: numeric threshold values (minimum scorecard scores, 0-10.0 scale) +- Represents OpenSSF security assessment (higher score = better security) + Example policies.yml structure: license_clarity_thresholds: - 80: ok # Scores >= 80 get 'ok' alert + 80: ok # Scores >= 80 get 'ok' alert 50: warning # Scores 50-79 get 'warning' alert + 0: error # Scores below 50 get 'error' alert + +scorecard_score_thresholds: + 9.0: ok # Scores >= 9.0 get 'ok' alert + 7.0: warning # Scores 7.0-8.9 get 'warning' alert + 0: error # Scores below 7.0 get 'error' alert + +Both threshold types follow the same evaluation logic but are tailored to their +specific scoring systems and use cases. """ from pathlib import Path @@ -54,58 +72,53 @@ def load_yaml_content(yaml_content): raise ValidationError(f"Policies file format error: {e}") -class ClarityThresholdsPolicy: - """ - Manages clarity score thresholds and compliance evaluation. +class BaseThresholdsPolicy: + """Base class for managing score thresholds and compliance evaluation.""" - This class reads clarity thresholds from a dictionary, validates them - against threshold configurations and determines compliance alerts based on - clarity scores. - """ + YAML_KEY = None + THRESHOLD_TYPE = float + POLICY_NAME = "thresholds" def __init__(self, threshold_dict): - """Initialize with validated threshold dictionary.""" self.thresholds = self.validate_thresholds(threshold_dict) - @staticmethod - def validate_thresholds(threshold_dict): + def validate_thresholds(self, threshold_dict): if not isinstance(threshold_dict, dict): - raise ValidationError( - "The `license_clarity_thresholds` must be a dictionary" - ) + raise ValidationError(f"The `{self.YAML_KEY}` must be a dictionary") + validated = {} seen = set() for key, value in threshold_dict.items(): try: - threshold = int(key) + threshold = self.THRESHOLD_TYPE(key) except (ValueError, TypeError): - raise ValidationError(f"Threshold keys must be integers, got: {key}") + type_name = ( + "integers" if issubclass(self.THRESHOLD_TYPE, int) else "numbers" + ) + raise ValidationError(f"Threshold keys must be {type_name}, got: {key}") + if threshold in seen: raise ValidationError(f"Duplicate threshold key: {threshold}") seen.add(threshold) + if value not in ["ok", "warning", "error"]: raise ValidationError( f"Compliance alert must be one of 'ok', 'warning', 'error', " f"got: {value}" ) validated[threshold] = value + sorted_keys = sorted(validated.keys(), reverse=True) if list(validated.keys()) != sorted_keys: raise ValidationError("Thresholds must be strictly descending") + return validated def get_alert_for_score(self, score): - """ - Determine compliance alert level for a given clarity score - - Returns: - str: Compliance alert level ('ok', 'warning', 'error') - - """ + """Determine compliance alert level for a given score.""" if score is None: return "error" - # Find the highest threshold that the score meets or exceeds applicable_thresholds = [t for t in self.thresholds if score >= t] if not applicable_thresholds: return "error" @@ -113,66 +126,49 @@ def get_alert_for_score(self, score): max_threshold = max(applicable_thresholds) return self.thresholds[max_threshold] - def get_thresholds_summary(self): - """ - Get a summary of configured thresholds for reporting - - Returns: - dict: Summary of thresholds and their alert levels - """ - return dict(sorted(self.thresholds.items(), reverse=True)) +# Specific implementations +class LicenseClarityThresholdsPolicy(BaseThresholdsPolicy): + YAML_KEY = "license_clarity_thresholds" + THRESHOLD_TYPE = int + POLICY_NAME = "license clarity thresholds" -def load_clarity_thresholds_from_yaml(yaml_content): - """ - Load clarity thresholds from YAML content. +class ScorecardThresholdsPolicy(BaseThresholdsPolicy): + YAML_KEY = "scorecard_score_thresholds" + THRESHOLD_TYPE = float + POLICY_NAME = "scorecard score thresholds" - Returns: - ClarityThresholdsPolicy: Configured policy object - """ +def load_thresholds_from_yaml(yaml_content, policy_class): + """Load thresholds from YAML.""" data = load_yaml_content(yaml_content) if not isinstance(data, dict): raise ValidationError("YAML content must be a dictionary.") - if "license_clarity_thresholds" not in data: + if policy_class.YAML_KEY not in data: raise ValidationError( - "Missing 'license_clarity_thresholds' key in policies file." + f"Missing '{policy_class.YAML_KEY}' key in policies file." ) - return ClarityThresholdsPolicy(data["license_clarity_thresholds"]) + return policy_class(data[policy_class.YAML_KEY]) -def load_clarity_thresholds_from_file(file_path): - """ - Load clarity thresholds from a YAML file. - - Returns: - ClarityThresholdsPolicy: Configured policy object or None if file not found - - """ +def load_thresholds_from_file(file_path, policy_class): + """Load thresholds from file.""" file_path = Path(file_path) - if not file_path.exists(): return try: yaml_content = file_path.read_text(encoding="utf-8") - return load_clarity_thresholds_from_yaml(yaml_content) + return load_thresholds_from_yaml(yaml_content, policy_class) except (OSError, UnicodeDecodeError) as e: raise ValidationError(f"Error reading file {file_path}: {e}") def get_project_clarity_thresholds(project): - """ - Get clarity thresholds for a project using the unified policy loading logic. - - Returns: - ClarityThresholdsPolicy or None: Policy object if thresholds are configured - - """ policies_dict = project.get_policies_dict() if not policies_dict: return @@ -181,4 +177,16 @@ def get_project_clarity_thresholds(project): if not clarity_thresholds: return - return ClarityThresholdsPolicy(clarity_thresholds) + return LicenseClarityThresholdsPolicy(clarity_thresholds) + + +def get_project_scorecard_thresholds(project): + policies_dict = project.get_policies_dict() + if not policies_dict: + return + + scorecard_thresholds = policies_dict.get("scorecard_score_thresholds") + if not scorecard_thresholds: + return + + return ScorecardThresholdsPolicy(scorecard_thresholds) diff --git a/scanpipe/pipes/scancode.py b/scanpipe/pipes/scancode.py index 93b20e7710..81ce29c13a 100644 --- a/scanpipe/pipes/scancode.py +++ b/scanpipe/pipes/scancode.py @@ -60,7 +60,7 @@ from scanpipe.models import DiscoveredDependency from scanpipe.models import DiscoveredPackage from scanpipe.pipes import flag -from scanpipe.pipes.license_clarity import get_project_clarity_thresholds +from scanpipe.pipes.compliance_thresholds import get_project_clarity_thresholds logger = logging.getLogger("scanpipe.pipes") diff --git a/scanpipe/tests/data/license_clarity/sample_thresholds.yml b/scanpipe/tests/data/compliance-thresholds/clarity_sample_thresholds.yml similarity index 100% rename from scanpipe/tests/data/license_clarity/sample_thresholds.yml rename to scanpipe/tests/data/compliance-thresholds/clarity_sample_thresholds.yml diff --git a/scanpipe/tests/data/compliance-thresholds/scorecard_sample_thresholds.yml b/scanpipe/tests/data/compliance-thresholds/scorecard_sample_thresholds.yml new file mode 100644 index 0000000000..2afb5153e6 --- /dev/null +++ b/scanpipe/tests/data/compliance-thresholds/scorecard_sample_thresholds.yml @@ -0,0 +1,4 @@ +scorecard_score_thresholds: + 9: ok + 7: warning + 0: error \ No newline at end of file diff --git a/scanpipe/tests/pipes/test_compliance_thresholds.py b/scanpipe/tests/pipes/test_compliance_thresholds.py new file mode 100644 index 0000000000..a225663b97 --- /dev/null +++ b/scanpipe/tests/pipes/test_compliance_thresholds.py @@ -0,0 +1,306 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# http://nexb.com and https://github.com/nexB/scancode.io +# The ScanCode.io software is licensed under the Apache License version 2.0. +# Data generated with ScanCode.io is provided as-is without warranties. +# ScanCode is a trademark of nexB Inc. +# +# You may not use this software except in compliance with the License. +# You may obtain a copy of the License at: http://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. +# +# Data Generated with ScanCode.io is provided on an "AS IS" BASIS, WITHOUT WARRANTIES +# OR CONDITIONS OF ANY KIND, either express or implied. No content created from +# ScanCode.io should be considered or used as legal advice. Consult an Attorney +# for any legal advice. +# +# ScanCode.io is a free software code scanning tool from nexB Inc. and others. +# Visit https://github.com/nexB/scancode.io for support and download. + +from pathlib import Path + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from scanpipe.pipes.compliance_thresholds import LicenseClarityThresholdsPolicy +from scanpipe.pipes.compliance_thresholds import ScorecardThresholdsPolicy +from scanpipe.pipes.compliance_thresholds import load_thresholds_from_file +from scanpipe.pipes.compliance_thresholds import load_thresholds_from_yaml + + +class LicenseClarityThresholdsPolicyTest(TestCase): + """Test LicenseClarityThresholdsPolicy class functionality.""" + + data = Path(__file__).parent.parent / "data" + + def test_valid_thresholds_initialization(self): + thresholds = {80: "ok", 50: "warning", 20: "error"} + policy = LicenseClarityThresholdsPolicy(thresholds) + self.assertEqual(policy.thresholds, thresholds) + + def test_string_keys_converted_to_integers(self): + thresholds = {"80": "ok", "50": "warning"} + policy = LicenseClarityThresholdsPolicy(thresholds) + expected = {80: "ok", 50: "warning"} + self.assertEqual(policy.thresholds, expected) + + def test_invalid_threshold_key_raises_error(self): + with self.assertRaises(ValidationError) as cm: + LicenseClarityThresholdsPolicy({"invalid": "ok"}) + self.assertIn("must be integers", str(cm.exception)) + + def test_invalid_alert_value_raises_error(self): + with self.assertRaises(ValidationError) as cm: + LicenseClarityThresholdsPolicy({80: "invalid"}) + self.assertIn("must be one of 'ok', 'warning', 'error'", str(cm.exception)) + + def test_non_dict_input_raises_error(self): + with self.assertRaises(ValidationError) as cm: + LicenseClarityThresholdsPolicy([80, 50]) + self.assertIn("must be a dictionary", str(cm.exception)) + + def test_duplicate_threshold_keys_raise_error(self): + with self.assertRaises(ValidationError) as cm: + LicenseClarityThresholdsPolicy({80: "ok", "80": "warning"}) + self.assertIn("Duplicate threshold key", str(cm.exception)) + + def test_overlapping_thresholds_wrong_order(self): + with self.assertRaises(ValidationError) as cm: + LicenseClarityThresholdsPolicy({70: "ok", 80: "warning"}) + self.assertIn("Thresholds must be strictly descending", str(cm.exception)) + + def test_float_threshold_keys(self): + thresholds = {80.5: "ok", 50.9: "warning"} + policy = LicenseClarityThresholdsPolicy(thresholds) + expected = {80: "ok", 50: "warning"} + self.assertEqual(policy.thresholds, expected) + + def test_negative_threshold_values(self): + thresholds = {50: "ok", 0: "warning", -10: "error"} + policy = LicenseClarityThresholdsPolicy(thresholds) + self.assertEqual(policy.get_alert_for_score(60), "ok") + self.assertEqual(policy.get_alert_for_score(25), "warning") + self.assertEqual(policy.get_alert_for_score(-5), "error") + self.assertEqual(policy.get_alert_for_score(-20), "error") + + def test_empty_thresholds_dict(self): + policy = LicenseClarityThresholdsPolicy({}) + self.assertEqual(policy.get_alert_for_score(100), "error") + self.assertEqual(policy.get_alert_for_score(50), "error") + self.assertEqual(policy.get_alert_for_score(0), "error") + self.assertEqual(policy.get_alert_for_score(None), "error") + + def test_very_high_threshold_values(self): + thresholds = {150: "ok", 100: "warning"} + policy = LicenseClarityThresholdsPolicy(thresholds) + self.assertEqual(policy.get_alert_for_score(100), "warning") + self.assertEqual(policy.get_alert_for_score(90), "error") + self.assertEqual(policy.get_alert_for_score(50), "error") + self.assertEqual(policy.get_alert_for_score(99), "error") + + # Policy logic via YAML string (mock policies.yml content) + def test_yaml_string_ok_and_warning(self): + yaml_content = """ +license_clarity_thresholds: + 90: ok + 30: warning +""" + policy = load_thresholds_from_yaml(yaml_content, LicenseClarityThresholdsPolicy) + self.assertEqual(policy.get_alert_for_score(95), "ok") + self.assertEqual(policy.get_alert_for_score(60), "warning") + self.assertEqual(policy.get_alert_for_score(20), "error") + + def test_yaml_string_single_threshold(self): + yaml_content = """ +license_clarity_thresholds: + 80: ok +""" + policy = load_thresholds_from_yaml(yaml_content, LicenseClarityThresholdsPolicy) + self.assertEqual(policy.get_alert_for_score(90), "ok") + self.assertEqual(policy.get_alert_for_score(79), "error") + + def test_yaml_string_invalid_alert(self): + yaml_content = """ +license_clarity_thresholds: + 80: great +""" + with self.assertRaises(ValidationError): + load_thresholds_from_yaml(yaml_content, LicenseClarityThresholdsPolicy) + + def test_yaml_string_invalid_key(self): + yaml_content = """ +license_clarity_thresholds: + eighty: ok +""" + with self.assertRaises(ValidationError): + load_thresholds_from_yaml(yaml_content, LicenseClarityThresholdsPolicy) + + def test_yaml_string_missing_key(self): + yaml_content = """ +license_policies: + - license_key: mit +""" + with self.assertRaises(ValidationError): + load_thresholds_from_yaml(yaml_content, LicenseClarityThresholdsPolicy) + + def test_yaml_string_invalid_yaml(self): + yaml_content = "license_clarity_thresholds: [80, 50" + with self.assertRaises(ValidationError): + load_thresholds_from_yaml(yaml_content, LicenseClarityThresholdsPolicy) + + def test_load_from_existing_file(self): + test_file = ( + self.data / "compliance-thresholds" / "clarity_sample_thresholds.yml" + ) + policy = load_thresholds_from_file(test_file, LicenseClarityThresholdsPolicy) + self.assertIsNotNone(policy) + self.assertEqual(policy.get_alert_for_score(95), "ok") + self.assertEqual(policy.get_alert_for_score(75), "warning") + self.assertEqual(policy.get_alert_for_score(50), "error") + + def test_load_from_nonexistent_file(self): + policy = load_thresholds_from_file( + "/nonexistent/file.yml", LicenseClarityThresholdsPolicy + ) + self.assertIsNone(policy) + + +class ScorecardThresholdsPolicyTest(TestCase): + """Test ScorecardThresholdsPolicy class functionality.""" + + data = Path(__file__).parent.parent / "data" + + def test_valid_thresholds_initialization(self): + thresholds = {9.0: "ok", 7.0: "warning", 0: "error"} + policy = ScorecardThresholdsPolicy(thresholds) + self.assertEqual(policy.thresholds, thresholds) + + def test_string_keys_converted_to_floats(self): + thresholds = {"9.0": "ok", "7.0": "warning"} + policy = ScorecardThresholdsPolicy(thresholds) + expected = {9.0: "ok", 7.0: "warning"} + self.assertEqual(policy.thresholds, expected) + + def test_invalid_threshold_key_raises_error(self): + with self.assertRaises(ValidationError) as cm: + ScorecardThresholdsPolicy({"invalid": "ok"}) + self.assertIn("must be numbers", str(cm.exception)) + + def test_invalid_alert_value_raises_error(self): + with self.assertRaises(ValidationError) as cm: + ScorecardThresholdsPolicy({9.0: "invalid"}) + self.assertIn("must be one of 'ok', 'warning', 'error'", str(cm.exception)) + + def test_non_dict_input_raises_error(self): + with self.assertRaises(ValidationError) as cm: + ScorecardThresholdsPolicy([9.0, 7.0]) + self.assertIn("must be a dictionary", str(cm.exception)) + + def test_duplicate_threshold_keys_raise_error(self): + with self.assertRaises(ValidationError) as cm: + ScorecardThresholdsPolicy({9.0: "ok", "9.0": "warning"}) + self.assertIn("Duplicate threshold key", str(cm.exception)) + + def test_overlapping_thresholds_wrong_order(self): + with self.assertRaises(ValidationError) as cm: + ScorecardThresholdsPolicy({7.0: "ok", 9.0: "warning"}) + self.assertIn("Thresholds must be strictly descending", str(cm.exception)) + + def test_float_threshold_keys(self): + thresholds = {9.5: "ok", 7.9: "warning"} + policy = ScorecardThresholdsPolicy(thresholds) + expected = {9.5: "ok", 7.9: "warning"} + self.assertEqual(policy.thresholds, expected) + + def test_negative_threshold_values(self): + thresholds = {5.0: "ok", 0: "warning", -1.0: "error"} + policy = ScorecardThresholdsPolicy(thresholds) + self.assertEqual(policy.get_alert_for_score(6.0), "ok") + self.assertEqual(policy.get_alert_for_score(2.5), "warning") + self.assertEqual(policy.get_alert_for_score(-0.5), "error") + self.assertEqual(policy.get_alert_for_score(-2.0), "error") + + def test_empty_thresholds_dict(self): + policy = ScorecardThresholdsPolicy({}) + self.assertEqual(policy.get_alert_for_score(10.0), "error") + self.assertEqual(policy.get_alert_for_score(5.0), "error") + self.assertEqual(policy.get_alert_for_score(0), "error") + self.assertEqual(policy.get_alert_for_score(None), "error") + + def test_very_high_threshold_values(self): + thresholds = {15.0: "ok", 10.0: "warning"} + policy = ScorecardThresholdsPolicy(thresholds) + self.assertEqual(policy.get_alert_for_score(10.0), "warning") + self.assertEqual(policy.get_alert_for_score(9.0), "error") + self.assertEqual(policy.get_alert_for_score(5.0), "error") + self.assertEqual(policy.get_alert_for_score(9.9), "error") + + # Policy logic via YAML string (mock policies.yml content) + def test_yaml_string_ok_and_warning(self): + yaml_content = """ +scorecard_score_thresholds: + 9.0: ok + 7.0: warning + 0: error +""" + policy = load_thresholds_from_yaml(yaml_content, ScorecardThresholdsPolicy) + self.assertEqual(policy.get_alert_for_score(9.5), "ok") + self.assertEqual(policy.get_alert_for_score(8.0), "warning") + self.assertEqual(policy.get_alert_for_score(2.0), "error") + + def test_yaml_string_single_threshold(self): + yaml_content = """ +scorecard_score_thresholds: + 8.0: ok +""" + policy = load_thresholds_from_yaml(yaml_content, ScorecardThresholdsPolicy) + self.assertEqual(policy.get_alert_for_score(9.0), "ok") + self.assertEqual(policy.get_alert_for_score(7.9), "error") + + def test_yaml_string_invalid_alert(self): + yaml_content = """ +scorecard_score_thresholds: + 8.0: great +""" + with self.assertRaises(ValidationError): + load_thresholds_from_yaml(yaml_content, ScorecardThresholdsPolicy) + + def test_yaml_string_invalid_key(self): + yaml_content = """ +scorecard_score_thresholds: + nine: ok +""" + with self.assertRaises(ValidationError): + load_thresholds_from_yaml(yaml_content, ScorecardThresholdsPolicy) + + def test_yaml_string_missing_key(self): + yaml_content = """ +license_policies: + - license_key: mit +""" + with self.assertRaises(ValidationError): + load_thresholds_from_yaml(yaml_content, ScorecardThresholdsPolicy) + + def test_yaml_string_invalid_yaml(self): + yaml_content = "scorecard_score_thresholds: [9.0, 7.0" + with self.assertRaises(ValidationError): + load_thresholds_from_yaml(yaml_content, ScorecardThresholdsPolicy) + + def test_load_from_existing_file(self): + test_file = ( + self.data / "compliance-thresholds" / "scorecard_sample_thresholds.yml" + ) + policy = load_thresholds_from_file(test_file, ScorecardThresholdsPolicy) + self.assertIsNotNone(policy) + self.assertEqual(policy.get_alert_for_score(9.5), "ok") + self.assertEqual(policy.get_alert_for_score(8.0), "warning") + self.assertEqual(policy.get_alert_for_score(5.0), "error") + + def test_load_from_nonexistent_file(self): + policy = load_thresholds_from_file( + "/nonexistent/file.yml", ScorecardThresholdsPolicy + ) + self.assertIsNone(policy) diff --git a/scanpipe/tests/pipes/test_license_clarity.py b/scanpipe/tests/pipes/test_license_clarity.py deleted file mode 100644 index f00723007b..0000000000 --- a/scanpipe/tests/pipes/test_license_clarity.py +++ /dev/null @@ -1,162 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# http://nexb.com and https://github.com/nexB/scancode.io -# The ScanCode.io software is licensed under the Apache License version 2.0. -# Data generated with ScanCode.io is provided as-is without warranties. -# ScanCode is a trademark of nexB Inc. -# -# You may not use this software except in compliance with the License. -# You may obtain a copy of the License at: http://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. -# -# Data Generated with ScanCode.io is provided on an "AS IS" BASIS, WITHOUT WARRANTIES -# OR CONDITIONS OF ANY KIND, either express or implied. No content created from -# ScanCode.io should be considered or used as legal advice. Consult an Attorney -# for any legal advice. -# -# ScanCode.io is a free software code scanning tool from nexB Inc. and others. -# Visit https://github.com/nexB/scancode.io for support and download. - -from pathlib import Path - -from django.core.exceptions import ValidationError -from django.test import TestCase - -from scanpipe.pipes.license_clarity import ClarityThresholdsPolicy -from scanpipe.pipes.license_clarity import load_clarity_thresholds_from_file -from scanpipe.pipes.license_clarity import load_clarity_thresholds_from_yaml - - -class ClarityThresholdsPolicyTest(TestCase): - data = Path(__file__).parent.parent / "data" - """Test ClarityThresholdsPolicy class functionality.""" - - def test_valid_thresholds_initialization(self): - thresholds = {80: "ok", 50: "warning", 20: "error"} - policy = ClarityThresholdsPolicy(thresholds) - self.assertEqual(policy.thresholds, thresholds) - - def test_string_keys_converted_to_integers(self): - thresholds = {"80": "ok", "50": "warning"} - policy = ClarityThresholdsPolicy(thresholds) - expected = {80: "ok", 50: "warning"} - self.assertEqual(policy.thresholds, expected) - - def test_invalid_threshold_key_raises_error(self): - with self.assertRaises(ValidationError) as cm: - ClarityThresholdsPolicy({"invalid": "ok"}) - self.assertIn("must be integers", str(cm.exception)) - - def test_invalid_alert_value_raises_error(self): - with self.assertRaises(ValidationError) as cm: - ClarityThresholdsPolicy({80: "invalid"}) - self.assertIn("must be one of 'ok', 'warning', 'error'", str(cm.exception)) - - def test_non_dict_input_raises_error(self): - with self.assertRaises(ValidationError) as cm: - ClarityThresholdsPolicy([80, 50]) - self.assertIn("must be a dictionary", str(cm.exception)) - - def test_duplicate_threshold_keys_raise_error(self): - with self.assertRaises(ValidationError) as cm: - ClarityThresholdsPolicy({80: "ok", "80": "warning"}) - self.assertIn("Duplicate threshold key", str(cm.exception)) - - def test_overlapping_thresholds_wrong_order(self): - with self.assertRaises(ValidationError) as cm: - ClarityThresholdsPolicy({70: "ok", 80: "warning"}) - self.assertIn("Thresholds must be strictly descending", str(cm.exception)) - - def test_float_threshold_keys(self): - thresholds = {80.5: "ok", 50.9: "warning"} - policy = ClarityThresholdsPolicy(thresholds) - expected = {80: "ok", 50: "warning"} - self.assertEqual(policy.thresholds, expected) - - def test_negative_threshold_values(self): - thresholds = {50: "ok", 0: "warning", -10: "error"} - policy = ClarityThresholdsPolicy(thresholds) - self.assertEqual(policy.get_alert_for_score(60), "ok") - self.assertEqual(policy.get_alert_for_score(25), "warning") - self.assertEqual(policy.get_alert_for_score(-5), "error") - self.assertEqual(policy.get_alert_for_score(-20), "error") - - def test_empty_thresholds_dict(self): - policy = ClarityThresholdsPolicy({}) - self.assertEqual(policy.get_alert_for_score(100), "error") - self.assertEqual(policy.get_alert_for_score(50), "error") - self.assertEqual(policy.get_alert_for_score(0), "error") - self.assertEqual(policy.get_alert_for_score(None), "error") - - def test_very_high_threshold_values(self): - thresholds = {150: "ok", 100: "warning"} - policy = ClarityThresholdsPolicy(thresholds) - self.assertEqual(policy.get_alert_for_score(100), "warning") - self.assertEqual(policy.get_alert_for_score(90), "error") - self.assertEqual(policy.get_alert_for_score(50), "error") - self.assertEqual(policy.get_alert_for_score(99), "error") - - # Policy logic via YAML string (mock policies.yml content) - def test_yaml_string_ok_and_warning(self): - yaml_content = """ -license_clarity_thresholds: - 90: ok - 30: warning -""" - policy = load_clarity_thresholds_from_yaml(yaml_content) - self.assertEqual(policy.get_alert_for_score(95), "ok") - self.assertEqual(policy.get_alert_for_score(60), "warning") - self.assertEqual(policy.get_alert_for_score(20), "error") - - def test_yaml_string_single_threshold(self): - yaml_content = """ -license_clarity_thresholds: - 80: ok -""" - policy = load_clarity_thresholds_from_yaml(yaml_content) - self.assertEqual(policy.get_alert_for_score(90), "ok") - self.assertEqual(policy.get_alert_for_score(79), "error") - - def test_yaml_string_invalid_alert(self): - yaml_content = """ -license_clarity_thresholds: - 80: great -""" - with self.assertRaises(ValidationError): - load_clarity_thresholds_from_yaml(yaml_content) - - def test_yaml_string_invalid_key(self): - yaml_content = """ -license_clarity_thresholds: - eighty: ok -""" - with self.assertRaises(ValidationError): - load_clarity_thresholds_from_yaml(yaml_content) - - def test_yaml_string_missing_key(self): - yaml_content = """ -license_policies: - - license_key: mit -""" - with self.assertRaises(ValidationError): - load_clarity_thresholds_from_yaml(yaml_content) - - def test_yaml_string_invalid_yaml(self): - yaml_content = "license_clarity_thresholds: [80, 50" - with self.assertRaises(ValidationError): - load_clarity_thresholds_from_yaml(yaml_content) - - def test_load_from_existing_file(self): - test_file = self.data / "license_clarity" / "sample_thresholds.yml" - policy = load_clarity_thresholds_from_file(test_file) - self.assertIsNotNone(policy) - self.assertEqual(policy.get_alert_for_score(95), "ok") - self.assertEqual(policy.get_alert_for_score(75), "warning") - self.assertEqual(policy.get_alert_for_score(50), "error") - - def test_load_from_nonexistent_file(self): - policy = load_clarity_thresholds_from_file("/nonexistent/file.yml") - self.assertIsNone(policy)