diff --git a/napalm_redfish/transport.py b/napalm_redfish/transport.py new file mode 100644 index 0000000..b6f45db --- /dev/null +++ b/napalm_redfish/transport.py @@ -0,0 +1,327 @@ +"""HTTPS transport to a Redfish service, on top of the DMTF ``redfish`` library. + +The library does the HTTP work; this module adds what a BMC driver needs on top: + +- Session login through the SessionService (``X-Auth-Token``), with the session deleted on + close, because BMCs allow only a few concurrent sessions. Basic auth is the fallback for + services that cannot create sessions, never a second try after a rejected password. +- Certificate verification that is on unless turned off per device. The library disables + verification whenever it is not given a CA file, so ``verify=True`` passes the CA bundle + of ``requests`` explicitly. +- Collections followed by ``@odata.id`` (including ``nextLink`` pages and expanded members) + instead of assumed member IDs. +- Links followed only on the BMC itself: an ``@odata.id`` pointing at another host (a PDU + outlet, for example) is refused, so the session token never leaves the BMC. +""" + +from __future__ import annotations + +import ipaddress +import logging +import os +from typing import Any, Mapping +from urllib.parse import urlsplit + +import requests +from redfish.rest.v1 import ( + AuthMethod, + InvalidCredentialsError, + RedfishPasswordChangeRequiredError, + RetriesExhaustedError, + ServerDownOrUnreachableError, + SessionCreationError, + redfish_client, +) + +LOG = logging.getLogger(__name__) + +SERVICE_ROOT = "/redfish/v1/" +DEFAULT_SESSIONS = "/redfish/v1/SessionService/Sessions" +AUTH_METHODS = ("auto", "session", "basic") +MAX_PAGES = 1000 + +Reference = str | Mapping[str, Any] + + +class RedfishError(Exception): + """A Redfish request failed.""" + + def __init__(self, message: str, *, status: int | None = None, path: str | None = None): + super().__init__(message) + self.status = status + self.path = path + + +class RedfishAuthError(RedfishError): + """Login failed, or the account may not read the resource (HTTP 401/403).""" + + +class RedfishHTTPError(RedfishError): + """The service answered with an HTTP error status.""" + + +class RedfishNotFound(RedfishHTTPError): + """The resource does not exist (HTTP 404).""" + + +def _error_message(body: Any) -> str: + """The human-readable part of a Redfish error body, if any.""" + if not isinstance(body, Mapping): + return "" + error = body.get("error") + if not isinstance(error, Mapping): + return "" + messages = [ + info.get("Message") + for info in error.get("@Message.ExtendedInfo") or [] + if isinstance(info, Mapping) and info.get("Message") + ] + if not messages and error.get("message"): + messages = [error["message"]] + return "; ".join(str(m) for m in messages) + + +class RedfishTransport: + """One authenticated connection to a Redfish service. + + :param hostname: BMC host name or IP address (IPv6 without brackets is fine). + :param port: HTTPS port, default 443. + :param verify: ``True`` verifies the certificate against the CA bundle of ``requests``, + a path verifies against that CA file, ``False`` turns verification off. + :param auth: ``"auto"`` (session, basic auth if the service cannot create sessions), + ``"session"`` or ``"basic"``. + :param timeout: seconds per request. + :param retries: extra attempts after a connection error (the library waits 1 s between). + """ + + def __init__( + self, + hostname: str, + username: str, + password: str, + *, + port: int | None = None, + verify: bool | str = True, + auth: str = "auto", + timeout: float = 30, + retries: int = 1, + ): + if auth not in AUTH_METHODS: + raise ValueError(f"auth must be one of {', '.join(AUTH_METHODS)}, not {auth!r}") + if isinstance(verify, str) and not os.path.isfile(verify): + raise ValueError(f"CA file not found: {verify}") + self.hostname = hostname + self.username = username + self.password = password + self.port = port + self.verify = verify + self.auth = auth + self.timeout = timeout + self.retries = retries + self.root: dict[str, Any] = {} + self.auth_method: str | None = None + self._client = None + + @property + def base_url(self) -> str: + host = self.hostname + try: + if ipaddress.ip_address(host).version == 6: + host = f"[{host}]" + except ValueError: + pass + return f"https://{host}" + (f":{self.port}" if self.port else "") + + @property + def _cafile(self) -> str | None: + if self.verify is True: + return requests.certs.where() + if self.verify is False: + return None + return self.verify + + # --- connection -------------------------------------------------------------------- + + def open(self) -> None: + """Read the service root (no credentials needed) and log in. No-op when open.""" + if self._client is not None: + return + try: + client = redfish_client( + base_url=self.base_url, + username=self.username, + password=self.password, + default_prefix=SERVICE_ROOT, + cafile=self._cafile, + timeout=self.timeout, + max_retry=self.retries, + ) + except (RetriesExhaustedError, ServerDownOrUnreachableError) as exc: + raise RedfishError(f"{self.base_url}: service root not reachable: {exc}") from exc + except Exception as exc: + raise RedfishError(f"{self.base_url}: cannot read the service root: {exc}") from exc + self._client = client + self.root = dict(getattr(client, "root", None) or {}) + try: + self.auth_method = self._login(client) + except BaseException: + self._client = None + client._session.close() + raise + + def _login(self, client) -> str: + if self.auth == "basic": + self._call(client.login, auth=AuthMethod.BASIC) + return "basic" + try: + self._call(client.login, auth=AuthMethod.SESSION) + except _SessionUnsupported as exc: + if self.auth == "session": + raise RedfishAuthError(f"{self.base_url}: session login failed: {exc}") from exc + LOG.info("%s: no Redfish session (%s), falling back to basic auth", self.base_url, exc) + else: + if client.get_session_key(): + return "session" + if self.auth == "session": + raise RedfishAuthError(f"{self.base_url}: session login returned no X-Auth-Token") + LOG.info("%s: session login returned no token, falling back to basic auth", + self.base_url) + self._call(client.login, auth=AuthMethod.BASIC) + return "basic" + + def _call(self, login, **kwargs) -> None: + try: + login(**kwargs) + except InvalidCredentialsError as exc: + raise RedfishAuthError(f"{self.base_url}: invalid credentials", status=401) from exc + except RedfishPasswordChangeRequiredError as exc: + raise RedfishAuthError(f"{self.base_url}: the account must change its password " + "before it can be used") from exc + except SessionCreationError as exc: + raise _SessionUnsupported(str(exc).strip()) from exc + except (RetriesExhaustedError, ServerDownOrUnreachableError) as exc: + raise RedfishError(f"{self.base_url}: login failed: {exc}") from exc + + def close(self) -> None: + """Delete the session (if any) and close the HTTP connection. Never raises.""" + client, self._client = self._client, None + self.auth_method = None + if client is None: + return + try: + client.logout() + except Exception as exc: # a leaked session times out; a failing close must not mask + LOG.warning("%s: could not delete the Redfish session: %s", self.base_url, exc) + client._session.close() + + def __enter__(self) -> "RedfishTransport": + self.open() + return self + + def __exit__(self, *exc_info) -> None: + self.close() + + # --- requests ---------------------------------------------------------------------- + + def path(self, ref: Reference) -> str: + """Request path for a path, an absolute URL on this BMC or an ``{"@odata.id": ...}``.""" + if isinstance(ref, Mapping): + ref = ref.get("@odata.id") + if not isinstance(ref, str) or not ref: + raise RedfishError(f"not a Redfish reference: {ref!r}") + parts = urlsplit(ref) + if parts.scheme or parts.netloc: + ours = urlsplit(self.base_url) + if parts.scheme != "https" or parts.netloc.lower() != ours.netloc.lower(): + raise RedfishError(f"refusing to follow a link to another host: {ref}") + if not parts.path.startswith("/"): + raise RedfishError(f"not an absolute Redfish path: {ref}") + return parts.path + (f"?{parts.query}" if parts.query else "") + + def _request(self, method: str, ref: Reference, body: Any = None) -> dict[str, Any]: + if self._client is None: + raise RedfishError("not connected: call open() first") + path = self.path(ref) + try: + if method == "GET": + resp = self._client.get(path) + else: + resp = self._client.post(path, body=body) + except RetriesExhaustedError as exc: + raise RedfishError(f"{method} {path}: no response", path=path) from exc + status = resp.status + try: + data = resp.dict + except Exception as exc: + if status < 400: + raise RedfishError(f"{method} {path}: response is not JSON", + status=status, path=path) from exc + data = {} + if status < 400: + if not isinstance(data, dict): + raise RedfishError(f"{method} {path}: response is not a JSON object", + status=status, path=path) + return data + detail = _error_message(data) + message = f"{method} {path}: HTTP {status}" + (f": {detail}" if detail else "") + if status in (401, 403): + raise RedfishAuthError(message, status=status, path=path) + if status == 404: + raise RedfishNotFound(message, status=status, path=path) + raise RedfishHTTPError(message, status=status, path=path) + + def get(self, ref: Reference) -> dict[str, Any]: + return self._request("GET", ref) + + def get_optional(self, ref: Reference | None) -> dict[str, Any] | None: + """GET, but ``None`` for a missing reference or a 404.""" + if ref is None: + return None + try: + return self.get(ref) + except RedfishNotFound: + return None + + def post(self, ref: Reference, body: Mapping[str, Any]) -> dict[str, Any]: + return self._request("POST", ref, dict(body)) + + def follow(self, resource: Mapping[str, Any], key: str) -> dict[str, Any] | None: + """The resource linked as ``resource[key]``; ``None`` if not linked or not found.""" + link = resource.get(key) + if not isinstance(link, Mapping) or "@odata.id" not in link: + return None + return self.get_optional(link) + + def members(self, collection: Reference, *, limit: int | None = None) -> list[dict[str, Any]]: + """All members of a collection, each fetched by its ``@odata.id``. + + Members the service already expanded inline are used as they are. ``nextLink`` pages + are followed. ``limit`` stops after that many members. + """ + result: list[dict[str, Any]] = [] + page_ref: Reference | None = collection + seen: set[str] = set() + while page_ref is not None and len(seen) < MAX_PAGES: + page_path = self.path(page_ref) + if page_path in seen: + LOG.warning("%s: nextLink loop at %s", self.base_url, page_path) + break + seen.add(page_path) + page = self.get(page_path) + for member in page.get("Members") or []: + if limit is not None and len(result) >= limit: + return result + if not isinstance(member, Mapping): + continue + if "Id" in member or "@odata.type" in member: + result.append(dict(member)) + elif "@odata.id" in member: + result.append(self.get(member)) + if limit is not None and len(result) >= limit: + break + page_ref = page.get("Members@odata.nextLink") or page.get("@odata.nextLink") + return result + + +class _SessionUnsupported(Exception): + """Session creation failed for a reason other than bad credentials.""" diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..ed8732e --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,428 @@ +"""Transport tests against mocked HTTP serving the DMTF public-rackmount1 mockup.""" + +import base64 +import json +import logging +import os + +import pytest +import requests +from conftest import BASE_URL, SESSION_PATH, SESSIONS, TOKEN + +from napalm_redfish.transport import ( + RedfishAuthError, + RedfishError, + RedfishHTTPError, + RedfishNotFound, + RedfishTransport, +) + +BASIC = "Basic " + base64.b64encode(b"netork:s3cret").decode() + + +def make(**kwargs) -> RedfishTransport: + kwargs.setdefault("verify", False) + kwargs.setdefault("retries", 0) + return RedfishTransport("bmc.example.net", "netork", "s3cret", **kwargs) + + +# --- base URL ------------------------------------------------------------------------------ + + +@pytest.mark.parametrize( + "hostname, port, expected", + [ + ("bmc.example.net", None, "https://bmc.example.net"), + ("bmc.example.net", 8443, "https://bmc.example.net:8443"), + ("192.0.2.10", None, "https://192.0.2.10"), + ("2001:db8::10", None, "https://[2001:db8::10]"), + ("2001:db8::10", 8443, "https://[2001:db8::10]:8443"), + ], +) +def test_base_url(hostname, port, expected): + assert RedfishTransport(hostname, "u", "p", port=port).base_url == expected + + +def test_rejects_unknown_auth_method(): + with pytest.raises(ValueError): + RedfishTransport("bmc.example.net", "u", "p", auth="digest") + + +# --- session login ------------------------------------------------------------------------- + + +def test_open_reads_service_root_before_login(server): + t = make() + t.open() + first = server.rsps.calls[0].request + assert first.method == "GET" and first.url.rstrip("/") == BASE_URL + "/redfish/v1" + assert "X-Auth-Token" not in first.headers and "Authorization" not in first.headers + assert t.root["RedfishVersion"] == "1.15.0" + t.close() + + +def test_session_login_posts_credentials_and_uses_token(server): + t = make() + t.open() + assert t.auth_method == "session" + (login,) = server.calls("POST", SESSIONS) + assert json.loads(login.request.body) == {"UserName": "netork", "Password": "s3cret"} + + t.get("/redfish/v1/Systems") + get = server.calls("GET", "/redfish/v1/Systems")[0].request + assert get.headers["X-Auth-Token"] == TOKEN + assert "Authorization" not in get.headers + t.close() + + +def test_close_deletes_the_session(server): + t = make() + t.open() + t.close() + (delete,) = server.calls("DELETE", SESSION_PATH) + assert delete.request.headers["X-Auth-Token"] == TOKEN + assert t.auth_method is None + + +def test_open_twice_logs_in_once(server): + t = make() + t.open() + t.open() + t.close() + assert len(server.calls("POST", SESSIONS)) == 1 + assert len(server.calls("DELETE", SESSION_PATH)) == 1 + + +def test_close_twice_deletes_once(server): + t = make() + t.open() + t.close() + t.close() + assert len(server.calls("DELETE")) == 1 + + +def test_context_manager_deletes_session_on_error(server): + with pytest.raises(RuntimeError): + with make() as t: + t.get("/redfish/v1/Systems") + raise RuntimeError("boom") + assert len(server.calls("DELETE", SESSION_PATH)) == 1 + + +def test_close_survives_failed_logout(server, caplog): + server.override("DELETE", SESSION_PATH, 500, {"error": {"message": "nope"}}) + t = make() + t.open() + with caplog.at_level(logging.WARNING): + t.close() + assert t.auth_method is None + assert "session" in caplog.text.lower() + + +@pytest.mark.filterwarnings("ignore:Service incorrectly did not provide the 'Location'") +def test_session_uri_from_body_when_location_missing(server): + server.override("POST", SESSIONS, 201, {"@odata.id": SESSION_PATH}, {"X-Auth-Token": TOKEN}) + t = make() + t.open() + t.close() + assert len(server.calls("DELETE", SESSION_PATH)) == 1 + + +def test_wrong_password_raises_without_basic_retry(server): + server.override("POST", SESSIONS, 401, {"error": {"message": "bad credentials"}}) + t = make() + with pytest.raises(RedfishAuthError): + t.open() + # One failed login only: a second attempt with basic auth would count twice against + # the BMC's lockout threshold. + assert len(server.calls("POST")) == 1 + assert not [c for c in server.rsps.calls if "Authorization" in c.request.headers] + + +@pytest.mark.parametrize("status", [404, 405, 501]) +def test_falls_back_to_basic_when_sessions_unsupported(server, status): + server.override("POST", SESSIONS, status, {"error": {"message": "not supported"}}) + t = make() + t.open() + assert t.auth_method == "basic" + t.get("/redfish/v1/Systems") + get = server.calls("GET", "/redfish/v1/Systems")[0].request + assert get.headers["Authorization"] == BASIC + assert "X-Auth-Token" not in get.headers + t.close() + assert not server.calls("DELETE") + + +@pytest.mark.filterwarnings("ignore:Service incorrectly did not provide the 'Location'") +def test_falls_back_to_basic_when_no_token_returned(server): + server.override("POST", SESSIONS, 201, {"@odata.id": SESSION_PATH}) + t = make() + t.open() + assert t.auth_method == "basic" + t.close() + + +def test_no_fallback_when_session_auth_is_forced(server): + server.override("POST", SESSIONS, 405, {}) + t = make(auth="session") + with pytest.raises(RedfishAuthError): + t.open() + + +def test_basic_auth_never_creates_a_session(server): + t = make(auth="basic") + t.open() + assert t.auth_method == "basic" + t.get("/redfish/v1/Systems") + assert not server.calls("POST") + assert server.calls("GET", "/redfish/v1/Systems")[0].request.headers["Authorization"] == BASIC + t.close() + + +def test_basic_auth_wrong_password(server): + server.override("GET", SESSIONS, 401, {}) + with pytest.raises(RedfishAuthError): + make(auth="basic").open() + + +def test_login_url_from_service_root(server): + custom = dict(_root(), Links={"Sessions": {"@odata.id": "/redfish/v1/Custom/Sessions"}}) + server.override("GET", "/redfish/v1/", 200, custom) + server.override("GET", "/redfish/v1", 200, custom) + server.override("POST", "/redfish/v1/Custom/Sessions", 201, {}, + {"X-Auth-Token": TOKEN, "Location": SESSION_PATH}) + t = make() + t.open() + assert server.calls("POST", "/redfish/v1/Custom/Sessions") + t.close() + + +def _root(): + from conftest import load + + return load("dmtf-public-rackmount1", "/redfish/v1") + + +def test_unreachable_service_raises_redfish_error(http): + t = make() + with pytest.raises(RedfishError): + t.open() # nothing registered on the mock: connection refused + + +def test_requests_before_open_fail(server): + with pytest.raises(RedfishError): + make().get("/redfish/v1/Systems") + + +# --- certificate verification -------------------------------------------------------------- + + +def _verify_of(server): + return server.rsps.calls[0].request.req_kwargs["verify"] + + +def test_verify_true_uses_ca_bundle(server): + t = make(verify=True) + t.open() + verify = _verify_of(server) + # The redfish library turns verification off unless it gets a CA file, so True must + # become a real bundle, not None/False. + assert isinstance(verify, str) and os.path.isfile(verify) + assert verify == requests.certs.where() + t.close() + + +def test_verify_false_disables_verification(server): + t = make(verify=False) + t.open() + assert _verify_of(server) is False + t.close() + + +def test_verify_path_is_passed_through(server, tmp_path): + ca = tmp_path / "bmc-ca.pem" + ca.write_text("dummy") + t = make(verify=str(ca)) + t.open() + assert _verify_of(server) == str(ca) + t.close() + + +def test_verify_path_must_exist(): + with pytest.raises(ValueError): + RedfishTransport("bmc.example.net", "u", "p", verify="/nonexistent/ca.pem") + + +def test_verify_is_the_default(): + assert RedfishTransport("bmc.example.net", "u", "p").verify is True + + +# --- GET and errors ------------------------------------------------------------------------ + + +@pytest.fixture +def transport(server): + t = make() + t.open() + yield t + t.close() + + +def test_get_returns_json(transport): + assert transport.get("/redfish/v1/Systems/437XR1138R2")["Model"] == "3500" + + +def test_get_accepts_odata_reference(transport): + ref = {"@odata.id": "/redfish/v1/Systems/437XR1138R2"} + assert transport.get(ref)["Id"] == "437XR1138R2" + + +def test_get_strips_fragment(transport, server): + data = transport.get("/redfish/v1/Chassis/1U/Thermal#/Fans/0") + assert data["Id"] == "Thermal" + assert server.calls("GET", "/redfish/v1/Chassis/1U/Thermal")[0].request.url.endswith("/Thermal") + + +def test_get_accepts_same_host_absolute_url(transport): + assert transport.get(BASE_URL + "/redfish/v1/Systems")["Members@odata.count"] == 1 + + +def test_get_refuses_links_to_other_hosts(transport, server): + before = len(server.rsps.calls) + # PowerSupply.Links.Outlet in the mockup points at a PDU on another host. Following it + # would send this BMC's session token there. + with pytest.raises(RedfishError): + transport.get("https://redfishpdu.contoso.com/redfish/v1/PowerEquipment/RackPDUs/1") + assert len(server.rsps.calls) == before + + +def test_get_refuses_relative_paths(transport): + with pytest.raises(RedfishError): + transport.get("Systems/1") + + +def test_get_404_raises_not_found(transport): + with pytest.raises(RedfishNotFound) as exc: + transport.get("/redfish/v1/Systems/1") + assert exc.value.status == 404 + assert exc.value.path == "/redfish/v1/Systems/1" + + +def test_get_optional_returns_none_on_404(transport): + assert transport.get_optional("/redfish/v1/Chassis/1U/Missing") is None + assert transport.get_optional(None) is None + + +def test_get_error_carries_redfish_message(transport, server): + server.override("GET", "/redfish/v1/Managers", 500, { + "error": {"code": "Base.1.0.InternalError", "message": "General error", + "@Message.ExtendedInfo": [{"Message": "The BMC is busy."}]}}) + with pytest.raises(RedfishHTTPError) as exc: + transport.get("/redfish/v1/Managers") + assert exc.value.status == 500 + assert "The BMC is busy." in str(exc.value) + + +@pytest.mark.parametrize("status", [401, 403]) +def test_get_auth_errors(transport, server, status): + server.override("GET", "/redfish/v1/AccountService", status, {}) + with pytest.raises(RedfishAuthError): + transport.get("/redfish/v1/AccountService") + + +def test_get_invalid_json(transport, server): + server.override("GET", "/redfish/v1/Managers", 200, "login") + with pytest.raises(RedfishError): + transport.get("/redfish/v1/Managers") + + +# --- collections --------------------------------------------------------------------------- + + +def test_members_follows_odata_ids(transport, server): + systems = transport.members("/redfish/v1/Systems") + # The mockup's system ID is its serial number, not "1". + assert [s["Id"] for s in systems] == ["437XR1138R2"] + assert server.calls("GET", "/redfish/v1/Systems/437XR1138R2") + + +def test_members_ignores_member_count(transport): + # Members@odata.count says 2, but three members are listed: trust the list. + fw = transport.members("/redfish/v1/UpdateService/FirmwareInventory") + assert [f["Id"] for f in fw] == ["BMC", "SS", "BIOS"] + + +def test_members_from_link_on_resource(transport): + system = transport.get("/redfish/v1/Systems/437XR1138R2") + cpus = transport.members(system["Processors"]) + assert [c["Id"] for c in cpus] == ["CPU1", "CPU2", "FPGA1"] + + +def test_members_uses_expanded_members_without_extra_requests(transport, server): + entries = transport.members("/redfish/v1/Managers/BMC/LogServices/Log/Entries") + assert entries[0]["Message"] == "System May be Melting" + assert not server.calls("GET", "/redfish/v1/Managers/BMC/LogServices/Log/Entries/1") + + +def test_members_follows_next_link(transport, server): + path = "/redfish/v1/Systems/437XR1138R2/LogServices/Log1/Entries" + server.override("GET", path + "?$skiptoken=2", 200, { + "Members": [{"@odata.id": path + "/3", "Id": "3", "Message": "third"}], + "@odata.id": path, + }) + entries = transport.members(path) + assert [e["Id"] for e in entries] == ["1", "2", "3"] + + +def test_members_next_link_loop_terminates(transport, server): + path = "/redfish/v1/Managers" + server.override("GET", path, 200, { + "Members": [{"@odata.id": "/redfish/v1/Managers/BMC"}], + "Members@odata.nextLink": path, + }) + assert [m["Id"] for m in transport.members(path)] == ["BMC"] + + +def test_members_limit(transport, server): + path = "/redfish/v1/Systems/437XR1138R2/LogServices/Log1/Entries" + entries = transport.members(path, limit=1) + assert [e["Id"] for e in entries] == ["1"] + assert len(server.calls("GET", path)) == 1 + + +def test_members_of_missing_collection(transport): + with pytest.raises(RedfishNotFound): + transport.members("/redfish/v1/Systems/437XR1138R2/Storage") + + +def test_follow(transport): + manager = transport.get("/redfish/v1/Managers/BMC") + assert transport.follow(manager, "NetworkProtocol")["Id"] == "NetworkProtocol" + assert transport.follow(manager, "NoSuchLink") is None + + +def test_follow_missing_target_is_none(transport, server): + system = transport.get("/redfish/v1/Systems/437XR1138R2") + # Storage is not linked from this system; Bios is linked but not in the fixtures. + assert transport.follow(system, "Storage") is None + assert transport.follow(system, "Bios") is None + + +# --- POST ---------------------------------------------------------------------------------- + + +def test_post_sends_json_and_returns_body(transport, server): + target = "/redfish/v1/Systems/437XR1138R2/Actions/ComputerSystem.Reset" + server.override("POST", target, 204, {}) + assert transport.post(target, {"ResetType": "GracefulRestart"}) == {} + (call,) = server.calls("POST", target) + assert json.loads(call.request.body) == {"ResetType": "GracefulRestart"} + assert call.request.headers["X-Auth-Token"] == TOKEN + + +def test_post_error(transport, server): + target = "/redfish/v1/Managers/BMC/Actions/Manager.Reset" + server.override("POST", target, 400, {"error": {"message": "ActionParameterValueNotInList"}}) + with pytest.raises(RedfishHTTPError) as exc: + transport.post(target, {"ResetType": "PowerCycle"}) + assert exc.value.status == 400