diff --git a/finbot/ctf/detectors/implementations/invoice_threshold_bypass.py b/finbot/ctf/detectors/implementations/invoice_threshold_bypass.py index 1641e927..8f865e34 100644 --- a/finbot/ctf/detectors/implementations/invoice_threshold_bypass.py +++ b/finbot/ctf/detectors/implementations/invoice_threshold_bypass.py @@ -43,7 +43,7 @@ class InvoiceThresholdBypassDetector(BaseDetector): def _validate_config(self) -> None: threshold = self.config.get("max_invoice_amount") - if threshold is not None: + if "max_invoice_amount" in self.config: if not isinstance(threshold, (int, float)) or threshold <= 0: raise ValueError("max_invoice_amount must be a positive number") diff --git a/finbot/ctf/detectors/implementations/invoice_trust_override.py b/finbot/ctf/detectors/implementations/invoice_trust_override.py index 26cb3db0..748a1e87 100644 --- a/finbot/ctf/detectors/implementations/invoice_trust_override.py +++ b/finbot/ctf/detectors/implementations/invoice_trust_override.py @@ -46,7 +46,7 @@ class InvoiceTrustOverrideDetector(BaseDetector): def _validate_config(self) -> None: min_amount = self.config.get("min_amount") - if min_amount is not None: + if "min_amount" in self.config: if not isinstance(min_amount, (int, float)) or min_amount <= 0: raise ValueError("min_amount must be a positive number") diff --git a/tests/unit/ctf/test_detector_numeric_config_validation.py b/tests/unit/ctf/test_detector_numeric_config_validation.py new file mode 100644 index 00000000..1f661737 --- /dev/null +++ b/tests/unit/ctf/test_detector_numeric_config_validation.py @@ -0,0 +1,67 @@ +""" +Numeric detector configuration validation tests. + +Covers the numeric detector config keys that are validated in +_validate_config() and then read back with a default in check_event(). +A key present with a None value has to be rejected at construction, +otherwise the default never applies and check_event() compares against +None, which raises TypeError and takes the detector out for the rest of +the process. + +This is the numeric counterpart of the list-typed keys covered in +test_detector_config_validation.py. +""" + +import pytest + +from finbot.ctf.detectors.implementations.invoice_threshold_bypass import ( + DEFAULT_MAX_INVOICE_AMOUNT, + InvoiceThresholdBypassDetector, +) +from finbot.ctf.detectors.implementations.invoice_trust_override import ( + DEFAULT_MIN_AMOUNT, + InvoiceTrustOverrideDetector, +) + +NUMERIC_CONFIG_KEYS = [ + (InvoiceThresholdBypassDetector, "max_invoice_amount", DEFAULT_MAX_INVOICE_AMOUNT), + (InvoiceTrustOverrideDetector, "min_amount", DEFAULT_MIN_AMOUNT), +] + + +class TestNoneRejected: + """A present-but-None numeric config must not reach check_event().""" + + @pytest.mark.parametrize("detector_cls, key, _default", NUMERIC_CONFIG_KEYS) + def test_none_value_rejected(self, detector_cls, key, _default) -> None: + with pytest.raises(ValueError, match="positive number"): + detector_cls(challenge_id="c", config={key: None}) + + @pytest.mark.parametrize("detector_cls, key, _default", NUMERIC_CONFIG_KEYS) + def test_non_numeric_still_rejected(self, detector_cls, key, _default) -> None: + with pytest.raises(ValueError, match="positive number"): + detector_cls(challenge_id="c", config={key: "not a number"}) + + @pytest.mark.parametrize("detector_cls, key, _default", NUMERIC_CONFIG_KEYS) + def test_non_positive_still_rejected(self, detector_cls, key, _default) -> None: + with pytest.raises(ValueError, match="positive number"): + detector_cls(challenge_id="c", config={key: 0}) + + +class TestValidConfigs: + """The accepted shapes keep working.""" + + @pytest.mark.parametrize("detector_cls, key, _default", NUMERIC_CONFIG_KEYS) + def test_positive_value_accepted(self, detector_cls, key, _default) -> None: + detector = detector_cls(challenge_id="c", config={key: 5000}) + assert detector.config[key] == 5000 + + @pytest.mark.parametrize("detector_cls, key, default", NUMERIC_CONFIG_KEYS) + def test_omitted_key_falls_back_to_default(self, detector_cls, key, default) -> None: + detector = detector_cls(challenge_id="c", config={}) + assert detector.config.get(key, default) == default + + @pytest.mark.parametrize("detector_cls, key, default", NUMERIC_CONFIG_KEYS) + def test_no_config_falls_back_to_default(self, detector_cls, key, default) -> None: + detector = detector_cls(challenge_id="c") + assert detector.config.get(key, default) == default