mirror of
https://github.com/krkn-chaos/krkn.git
synced 2026-08-25 09:27:36 +00:00
feat: add http trigger type for event-driven chaos (#1504)
* feat: add http trigger type for event-driven chaos Adds an http trigger type so krkn can wait for an HTTP endpoint to return an expected status before chaos injection. Completes the trigger types on top of the Phase 1 framework (#1484). - krkn/scenario_plugins/triggers/http_trigger.py: implements HttpTrigger - krkn/scenario_plugins/triggers/trigger_manager.py: registers "http" type - krkn/scenario_plugins/triggers/__init__.py: exports HttpTrigger - tests/test_triggers/test_http_trigger.py: 30 tests for HttpTrigger - scenarios/kube/trigger_http_example.yaml: example configuration Closes krkn-chaos/krkn#1498 Signed-off-by: swagatobauri <swagato731123@gmail.com> * fix(triggers): address code review feedback for http trigger - Add type validation for body_contains in http_trigger.py - Use mock_info.assert_not_called() for strict logging assertions in tests Signed-off-by: swagatobauri <swagato731123@gmail.com> * feat(krknctl): add http trigger arguments to krknctl input fields Wire HTTP triggers into krknctl-input.json so they are exposed as CLI flags by krknctl. Related to krkn-chaos/krkn#1498 Signed-off-by: swagatobauri <swagato731123@gmail.com> * test(triggers): add factory wiring and invalid body_contains tests Signed-off-by: swagatobauri <swagato731123@gmail.com> * fix(triggers): move trigger_http_example.yaml to config directory per review Signed-off-by: swagatobauri <swagato731123@gmail.com> * fix(triggers): move HttpTrigger import to top of file per review Signed-off-by: swagatobauri <swagato731123@gmail.com> --------- Signed-off-by: swagatobauri <swagato731123@gmail.com> Co-authored-by: Darshan Jain <darjain@redhat.com>
This commit is contained in:
co-authored by
Darshan Jain
parent
be88742816
commit
06954dd648
@@ -0,0 +1,15 @@
|
||||
kraken:
|
||||
chaos_scenarios:
|
||||
- pod_disruption_scenarios:
|
||||
- scenarios/kube/pod.yml
|
||||
|
||||
triggers:
|
||||
mode: all_of
|
||||
timeout: 300
|
||||
interval: 5
|
||||
on_timeout: skip
|
||||
conditions:
|
||||
- type: http
|
||||
url: "http://nginx.default.svc:8080/health"
|
||||
method: GET
|
||||
expected_status: 200
|
||||
@@ -706,5 +706,56 @@
|
||||
"default": "skip",
|
||||
"required": "false",
|
||||
"group": "triggers"
|
||||
},
|
||||
{
|
||||
"name": "trigger-http-url",
|
||||
"short_description": "Trigger HTTP URL",
|
||||
"description": "URL to poll for the HTTP trigger",
|
||||
"variable": "TRIGGER_HTTP_URL",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"required": "false",
|
||||
"group": "triggers"
|
||||
},
|
||||
{
|
||||
"name": "trigger-http-method",
|
||||
"short_description": "Trigger HTTP Method",
|
||||
"description": "HTTP method to use (GET, POST, etc.)",
|
||||
"variable": "TRIGGER_HTTP_METHOD",
|
||||
"type": "string",
|
||||
"default": "GET",
|
||||
"required": "false",
|
||||
"group": "triggers"
|
||||
},
|
||||
{
|
||||
"name": "trigger-http-expected-status",
|
||||
"short_description": "Trigger HTTP Expected Status",
|
||||
"description": "Expected HTTP status code",
|
||||
"variable": "TRIGGER_HTTP_EXPECTED_STATUS",
|
||||
"type": "number",
|
||||
"default": "200",
|
||||
"required": "false",
|
||||
"group": "triggers"
|
||||
},
|
||||
{
|
||||
"name": "trigger-http-bearer-token",
|
||||
"short_description": "Trigger HTTP Bearer Token",
|
||||
"description": "Bearer token for HTTP authentication",
|
||||
"variable": "TRIGGER_HTTP_BEARER_TOKEN",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"required": "false",
|
||||
"secret": "true",
|
||||
"group": "triggers"
|
||||
},
|
||||
{
|
||||
"name": "trigger-http-body-contains",
|
||||
"short_description": "Trigger HTTP Body Contains",
|
||||
"description": "Substring expected in the HTTP response body",
|
||||
"variable": "TRIGGER_HTTP_BODY_CONTAINS",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"required": "false",
|
||||
"group": "triggers"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
# limitations under the License.
|
||||
from krkn.scenario_plugins.triggers.abstract_trigger import AbstractTrigger
|
||||
from krkn.scenario_plugins.triggers.command_trigger import CommandTrigger
|
||||
from krkn.scenario_plugins.triggers.http_trigger import HttpTrigger
|
||||
from krkn.scenario_plugins.triggers.trigger_manager import TriggerManager
|
||||
|
||||
__all__ = ["AbstractTrigger", "CommandTrigger", "TriggerManager"]
|
||||
__all__ = ["AbstractTrigger", "CommandTrigger", "HttpTrigger", "TriggerManager"]
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Copyright 2025 The Krkn Authors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from krkn.scenario_plugins.triggers.abstract_trigger import AbstractTrigger
|
||||
|
||||
HTTP_REQUEST_TIMEOUT_SECONDS = 30
|
||||
|
||||
VALID_METHODS = {"DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"}
|
||||
|
||||
|
||||
class HttpTrigger(AbstractTrigger):
|
||||
"""Trigger that polls an HTTP endpoint and checks its response status."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self._url = config.get("url")
|
||||
if not self._url:
|
||||
raise ValueError("http trigger requires a 'url' field")
|
||||
|
||||
method = config.get("method", "GET")
|
||||
self._method = str(method).upper()
|
||||
if self._method not in VALID_METHODS:
|
||||
raise ValueError(
|
||||
f"http trigger method must be one of "
|
||||
f"{', '.join(sorted(VALID_METHODS))}, got {self._method!r}"
|
||||
)
|
||||
|
||||
try:
|
||||
self._expected_status = int(config.get("expected_status", 200))
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(
|
||||
f"expected_status must be an integer, "
|
||||
f"got {config.get('expected_status')!r}"
|
||||
)
|
||||
if not 100 <= self._expected_status <= 599:
|
||||
raise ValueError(
|
||||
f"expected_status must be a valid HTTP status code (100-599), "
|
||||
f"got {self._expected_status}"
|
||||
)
|
||||
|
||||
self._headers: dict = dict(config.get("headers") or {})
|
||||
bearer_token = config.get("bearer_token")
|
||||
if bearer_token:
|
||||
self._headers["Authorization"] = f"Bearer {bearer_token}"
|
||||
|
||||
body_contains = config.get("body_contains")
|
||||
if body_contains is not None and not isinstance(body_contains, str):
|
||||
raise ValueError(
|
||||
f"body_contains must be a string, got {type(body_contains).__name__}"
|
||||
)
|
||||
self._body_contains: str | None = body_contains
|
||||
self._last_result: bool | None = None
|
||||
|
||||
def evaluate(self) -> bool:
|
||||
try:
|
||||
with requests.Session() as session:
|
||||
response = session.request(
|
||||
self._method,
|
||||
self._url,
|
||||
headers=self._headers,
|
||||
timeout=HTTP_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
met = response.status_code == self._expected_status
|
||||
logging.debug(
|
||||
f"http trigger: status={response.status_code} "
|
||||
f"expected={self._expected_status} url='{self._url}'"
|
||||
)
|
||||
if met and self._body_contains is not None:
|
||||
met = self._body_contains in response.text
|
||||
if not met:
|
||||
logging.debug(
|
||||
f"http trigger: body_contains={self._body_contains!r} "
|
||||
f"not found in response"
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
logging.warning(
|
||||
f"http trigger timed out after {HTTP_REQUEST_TIMEOUT_SECONDS}s: "
|
||||
f"{self._url}"
|
||||
)
|
||||
met = False
|
||||
except requests.exceptions.ConnectionError:
|
||||
logging.warning(f"http trigger connection error: {self._url}")
|
||||
met = False
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"http trigger request error: {e}: {self._url}")
|
||||
met = False
|
||||
except Exception as e:
|
||||
logging.error(f"http trigger unexpected error: {e}: {self._url}")
|
||||
met = False
|
||||
|
||||
# Log only on state change
|
||||
if met != self._last_result:
|
||||
if met:
|
||||
logging.info(f"trigger condition satisfied: {self.describe()}")
|
||||
else:
|
||||
logging.info(f"trigger condition not satisfied: {self.describe()}")
|
||||
self._last_result = met
|
||||
return met
|
||||
|
||||
def describe(self) -> str:
|
||||
return (
|
||||
f"http trigger: {self._method} {self._url} "
|
||||
f"expect={self._expected_status}"
|
||||
)
|
||||
@@ -16,6 +16,7 @@ import time
|
||||
|
||||
from krkn.scenario_plugins.triggers.abstract_trigger import AbstractTrigger
|
||||
from krkn.scenario_plugins.triggers.command_trigger import CommandTrigger
|
||||
from krkn.scenario_plugins.triggers.http_trigger import HttpTrigger
|
||||
|
||||
VALID_MODES = {"all_of", "any_of"}
|
||||
VALID_ON_TIMEOUT = {"skip", "fail", "run_anyway"}
|
||||
@@ -69,13 +70,9 @@ class TriggerManager:
|
||||
)
|
||||
|
||||
if self._timeout <= 0:
|
||||
raise ValueError(
|
||||
f"timeout must be positive, got {self._timeout}"
|
||||
)
|
||||
raise ValueError(f"timeout must be positive, got {self._timeout}")
|
||||
if self._interval <= 0:
|
||||
raise ValueError(
|
||||
f"interval must be positive, got {self._interval}"
|
||||
)
|
||||
raise ValueError(f"interval must be positive, got {self._interval}")
|
||||
|
||||
self._triggers: list[AbstractTrigger] = []
|
||||
for condition in trigger_config["conditions"]:
|
||||
@@ -98,6 +95,9 @@ class TriggerManager:
|
||||
if trigger_type == "command":
|
||||
return CommandTrigger(condition_config)
|
||||
|
||||
if trigger_type == "http":
|
||||
return HttpTrigger(condition_config)
|
||||
|
||||
raise ValueError(f"unknown trigger type: '{trigger_type}'")
|
||||
|
||||
def wait_for_triggers(self) -> bool:
|
||||
@@ -119,9 +119,7 @@ class TriggerManager:
|
||||
self._trigger_states[i] = result
|
||||
results.append(result)
|
||||
|
||||
logging.debug(
|
||||
f"trigger poll: {[r for r in results]}"
|
||||
)
|
||||
logging.debug(f"trigger poll: {[r for r in results]}")
|
||||
|
||||
if self._mode == "all_of" and all(results):
|
||||
logging.info("all trigger conditions satisfied")
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Test suite for HttpTrigger class
|
||||
|
||||
Usage:
|
||||
python -m coverage run -a -m unittest tests/test_triggers/test_http_trigger.py -v
|
||||
|
||||
Assisted By: Antigravity
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from krkn.scenario_plugins.triggers.http_trigger import (
|
||||
HTTP_REQUEST_TIMEOUT_SECONDS, VALID_METHODS, HttpTrigger)
|
||||
|
||||
|
||||
class TestHttpTrigger(unittest.TestCase):
|
||||
|
||||
def _make_trigger(self, **overrides):
|
||||
"""Build an HttpTrigger with sensible defaults."""
|
||||
config = {"url": "http://example.com/health", "expected_status": 200}
|
||||
config.update(overrides)
|
||||
return HttpTrigger(config)
|
||||
|
||||
def _mock_response(self, status_code=200, text="ok"):
|
||||
"""Build a mock requests.Response."""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = status_code
|
||||
mock_resp.text = text
|
||||
return mock_resp
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# evaluate() tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_evaluate_success(self, mock_session_cls):
|
||||
"""Status matches expected_status -> returns True."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(200)
|
||||
)
|
||||
trigger = self._make_trigger(
|
||||
url="http://example.com/health", expected_status=200
|
||||
)
|
||||
|
||||
self.assertTrue(trigger.evaluate())
|
||||
mock_session_cls.return_value.__enter__.return_value.request.assert_called_once_with(
|
||||
"GET",
|
||||
"http://example.com/health",
|
||||
headers={},
|
||||
timeout=HTTP_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_evaluate_wrong_status(self, mock_session_cls):
|
||||
"""Status does not match expected_status -> returns False."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(503)
|
||||
)
|
||||
trigger = self._make_trigger(expected_status=200)
|
||||
|
||||
self.assertFalse(trigger.evaluate())
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_evaluate_body_contains_match(self, mock_session_cls):
|
||||
"""Status matches and body_contains substring found -> returns True."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(200, text='{"status": "healthy"}')
|
||||
)
|
||||
trigger = self._make_trigger(body_contains="healthy")
|
||||
|
||||
self.assertTrue(trigger.evaluate())
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_evaluate_body_contains_no_match(self, mock_session_cls):
|
||||
"""Status matches but body_contains substring not found -> returns False."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(200, text='{"status": "degraded"}')
|
||||
)
|
||||
trigger = self._make_trigger(body_contains="healthy")
|
||||
|
||||
self.assertFalse(trigger.evaluate())
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_evaluate_body_contains_skipped_on_wrong_status(self, mock_session_cls):
|
||||
"""body_contains is not checked when status does not match."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(503, text="healthy")
|
||||
)
|
||||
trigger = self._make_trigger(expected_status=200, body_contains="healthy")
|
||||
|
||||
# Should be False because status is wrong, body_contains not evaluated
|
||||
self.assertFalse(trigger.evaluate())
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_evaluate_connection_error(self, mock_session_cls):
|
||||
"""ConnectionError -> returns False, no exception propagated."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.side_effect = (
|
||||
requests.exceptions.ConnectionError("refused")
|
||||
)
|
||||
trigger = self._make_trigger()
|
||||
|
||||
self.assertFalse(trigger.evaluate())
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_evaluate_timeout(self, mock_session_cls):
|
||||
"""Timeout -> returns False, no exception propagated."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.side_effect = (
|
||||
requests.exceptions.Timeout("timed out")
|
||||
)
|
||||
trigger = self._make_trigger()
|
||||
|
||||
self.assertFalse(trigger.evaluate())
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_evaluate_request_exception(self, mock_session_cls):
|
||||
"""Generic RequestException -> returns False, no exception propagated."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.side_effect = (
|
||||
requests.exceptions.RequestException("generic error")
|
||||
)
|
||||
trigger = self._make_trigger()
|
||||
|
||||
self.assertFalse(trigger.evaluate())
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_evaluate_unexpected_exception(self, mock_session_cls):
|
||||
"""Unexpected exception -> returns False, no exception propagated."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.side_effect = (
|
||||
RuntimeError("something unexpected")
|
||||
)
|
||||
trigger = self._make_trigger()
|
||||
|
||||
self.assertFalse(trigger.evaluate())
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_bearer_token_sets_authorization_header(self, mock_session_cls):
|
||||
"""bearer_token -> Authorization: Bearer <token> header sent."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(200)
|
||||
)
|
||||
trigger = self._make_trigger(bearer_token="my-secret-token")
|
||||
|
||||
trigger.evaluate()
|
||||
call_kwargs = (
|
||||
mock_session_cls.return_value.__enter__.return_value.request.call_args[1]
|
||||
)
|
||||
self.assertEqual(
|
||||
call_kwargs["headers"]["Authorization"], "Bearer my-secret-token"
|
||||
)
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_custom_headers_merged(self, mock_session_cls):
|
||||
"""headers dict -> merged into request headers."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(200)
|
||||
)
|
||||
trigger = self._make_trigger(headers={"X-Custom": "value"})
|
||||
|
||||
trigger.evaluate()
|
||||
call_kwargs = (
|
||||
mock_session_cls.return_value.__enter__.return_value.request.call_args[1]
|
||||
)
|
||||
self.assertEqual(call_kwargs["headers"]["X-Custom"], "value")
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_bearer_token_overrides_authorization_in_headers(self, mock_session_cls):
|
||||
"""bearer_token applied after headers dict -> overrides any Authorization in headers."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(200)
|
||||
)
|
||||
trigger = self._make_trigger(
|
||||
headers={"Authorization": "Bearer old-token"},
|
||||
bearer_token="new-token",
|
||||
)
|
||||
|
||||
trigger.evaluate()
|
||||
call_kwargs = (
|
||||
mock_session_cls.return_value.__enter__.return_value.request.call_args[1]
|
||||
)
|
||||
self.assertEqual(call_kwargs["headers"]["Authorization"], "Bearer new-token")
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_method_override_post(self, mock_session_cls):
|
||||
"""method=POST -> POST used in session.request."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(201)
|
||||
)
|
||||
trigger = self._make_trigger(method="POST", expected_status=201)
|
||||
|
||||
trigger.evaluate()
|
||||
args = mock_session_cls.return_value.__enter__.return_value.request.call_args[0]
|
||||
self.assertEqual(args[0], "POST")
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_method_lowercased_input_normalized(self, mock_session_cls):
|
||||
"""method='get' (lowercase) -> normalised to 'GET'."""
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(200)
|
||||
)
|
||||
trigger = self._make_trigger(method="get")
|
||||
|
||||
self.assertEqual(trigger._method, "GET")
|
||||
trigger.evaluate()
|
||||
args = mock_session_cls.return_value.__enter__.return_value.request.call_args[0]
|
||||
self.assertEqual(args[0], "GET")
|
||||
|
||||
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
|
||||
def test_state_change_logging_not_satisfied_then_satisfied(self, mock_session_cls):
|
||||
"""State-change logging fires on transition, not on repeat."""
|
||||
trigger = self._make_trigger()
|
||||
|
||||
# First call: False -> logs "not satisfied"
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(503)
|
||||
)
|
||||
with self.assertLogs(level="INFO") as log_ctx:
|
||||
trigger.evaluate()
|
||||
self.assertTrue(
|
||||
any("trigger condition not satisfied" in line for line in log_ctx.output)
|
||||
)
|
||||
|
||||
# Second call: still False -> no INFO log (state unchanged)
|
||||
# (INFO is only emitted on change)
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(503)
|
||||
)
|
||||
initial_last = trigger._last_result
|
||||
with patch("logging.info") as mock_info:
|
||||
trigger.evaluate()
|
||||
mock_info.assert_not_called()
|
||||
self.assertEqual(trigger._last_result, initial_last) # state unchanged
|
||||
|
||||
# Third call: True -> logs "satisfied"
|
||||
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
|
||||
self._mock_response(200)
|
||||
)
|
||||
with self.assertLogs(level="INFO") as log_ctx:
|
||||
trigger.evaluate()
|
||||
self.assertTrue(
|
||||
any("trigger condition satisfied" in line for line in log_ctx.output)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# describe() tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_describe_default(self):
|
||||
"""describe() returns string with method, url, and expected status."""
|
||||
trigger = self._make_trigger(
|
||||
url="http://nginx.default.svc:8080/health",
|
||||
expected_status=200,
|
||||
)
|
||||
description = trigger.describe()
|
||||
|
||||
self.assertIn("http trigger", description)
|
||||
self.assertIn("GET", description)
|
||||
self.assertIn("http://nginx.default.svc:8080/health", description)
|
||||
self.assertIn("200", description)
|
||||
self.assertIsInstance(description, str)
|
||||
|
||||
def test_describe_post(self):
|
||||
"""describe() reflects method override."""
|
||||
trigger = self._make_trigger(method="POST", expected_status=201)
|
||||
description = trigger.describe()
|
||||
|
||||
self.assertIn("POST", description)
|
||||
self.assertIn("201", description)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_invalid_body_contains_raises(self):
|
||||
"""body_contains passed as non-string -> raises ValueError."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._make_trigger(body_contains={"key": "value"})
|
||||
self.assertIn("body_contains", str(ctx.exception))
|
||||
|
||||
def test_missing_url_raises(self):
|
||||
"""No 'url' field -> raises ValueError containing 'url'."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
HttpTrigger({})
|
||||
self.assertIn("url", str(ctx.exception).lower())
|
||||
|
||||
def test_empty_url_raises(self):
|
||||
"""url='' -> raises ValueError."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
HttpTrigger({"url": ""})
|
||||
self.assertIn("url", str(ctx.exception).lower())
|
||||
|
||||
def test_invalid_method_raises(self):
|
||||
"""method='BREW' -> raises ValueError."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._make_trigger(method="BREW")
|
||||
self.assertIn("BREW", str(ctx.exception))
|
||||
|
||||
def test_valid_methods_accepted(self):
|
||||
"""All methods in VALID_METHODS are accepted without raising."""
|
||||
for method in VALID_METHODS:
|
||||
trigger = self._make_trigger(method=method)
|
||||
self.assertEqual(trigger._method, method)
|
||||
|
||||
def test_expected_status_string_coerced_to_int(self):
|
||||
"""expected_status='200' (string) -> coerced to int 200."""
|
||||
trigger = self._make_trigger(expected_status="200")
|
||||
self.assertEqual(trigger._expected_status, 200)
|
||||
self.assertIsInstance(trigger._expected_status, int)
|
||||
|
||||
def test_expected_status_invalid_string_raises(self):
|
||||
"""expected_status='ok' -> raises ValueError."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._make_trigger(expected_status="ok")
|
||||
self.assertIn("expected_status", str(ctx.exception))
|
||||
|
||||
def test_expected_status_below_range_raises(self):
|
||||
"""expected_status=99 -> raises ValueError (valid range 100-599)."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._make_trigger(expected_status=99)
|
||||
self.assertIn("100", str(ctx.exception))
|
||||
|
||||
def test_expected_status_above_range_raises(self):
|
||||
"""expected_status=600 -> raises ValueError."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._make_trigger(expected_status=600)
|
||||
self.assertIn("599", str(ctx.exception))
|
||||
|
||||
def test_default_expected_status_is_200(self):
|
||||
"""No expected_status in config -> defaults to 200."""
|
||||
trigger = HttpTrigger({"url": "http://example.com"})
|
||||
self.assertEqual(trigger._expected_status, 200)
|
||||
|
||||
def test_default_method_is_get(self):
|
||||
"""No method in config -> defaults to GET."""
|
||||
trigger = HttpTrigger({"url": "http://example.com"})
|
||||
self.assertEqual(trigger._method, "GET")
|
||||
|
||||
def test_no_bearer_token_no_authorization_header(self):
|
||||
"""No bearer_token -> Authorization header not set."""
|
||||
trigger = self._make_trigger()
|
||||
self.assertNotIn("Authorization", trigger._headers)
|
||||
|
||||
def test_bearer_token_stored_in_headers(self):
|
||||
"""bearer_token -> stored as Authorization header on the trigger."""
|
||||
trigger = self._make_trigger(bearer_token="tok123")
|
||||
self.assertEqual(trigger._headers["Authorization"], "Bearer tok123")
|
||||
|
||||
def test_last_result_initialised_to_none(self):
|
||||
"""_last_result starts as None before first evaluate()."""
|
||||
trigger = self._make_trigger()
|
||||
self.assertIsNone(trigger._last_result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -208,6 +208,16 @@ class TestTriggerManager(unittest.TestCase):
|
||||
# Validation tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_factory_wiring_returns_http_trigger(self):
|
||||
"""TriggerManager with type: http returns an HttpTrigger."""
|
||||
config = _make_config(
|
||||
conditions=[{"type": "http", "url": "http://test.com"}],
|
||||
)
|
||||
manager = TriggerManager(config)
|
||||
from krkn.scenario_plugins.triggers.http_trigger import HttpTrigger
|
||||
|
||||
self.assertIsInstance(manager._triggers[0], HttpTrigger)
|
||||
|
||||
def test_invalid_mode_raises(self):
|
||||
"""mode='invalid' -> raises ValueError."""
|
||||
config = _make_config(mode="invalid")
|
||||
|
||||
Reference in New Issue
Block a user