diff --git a/Pipfile b/Pipfile index 0071457e..61e4e11f 100644 --- a/Pipfile +++ b/Pipfile @@ -42,6 +42,7 @@ validators = "==0.35.0" watchdog = "==6.0.0" [dev-packages] +httpx = "==0.28.1" pytest = "==9.1.1" pytest-cov = "==7.1.0" pytest-mock = "==3.15.1" diff --git a/Pipfile.lock b/Pipfile.lock index 00001887..d54bbd1e 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ada609b237c03818f10187884ce1a1ea565ab76a8956574b21667e1b438c0b00" + "sha256": "fd55152f63a4b522de49e38580d503a0c16de19cd15ee1f59e00e2013a615af7" }, "pipfile-spec": 6, "requires": {}, @@ -2131,6 +2131,22 @@ } }, "develop": { + "anyio": { + "hashes": [ + "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", + "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9" + ], + "markers": "python_version >= '3.10'", + "version": "==4.14.0" + }, + "certifi": { + "hashes": [ + "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", + "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" + ], + "markers": "python_version >= '3.7'", + "version": "==2026.6.17" + }, "coverage": { "extras": [ "toml" @@ -2246,6 +2262,39 @@ "markers": "python_version >= '3.10'", "version": "==7.14.1" }, + "h11": { + "hashes": [ + "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", + "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" + ], + "markers": "python_version >= '3.8'", + "version": "==0.16.0" + }, + "httpcore": { + "hashes": [ + "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", + "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" + ], + "markers": "python_version >= '3.8'", + "version": "==1.0.9" + }, + "httpx": { + "hashes": [ + "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", + "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==0.28.1" + }, + "idna": { + "hashes": [ + "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", + "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" + ], + "markers": "python_version >= '3.9'", + "version": "==3.18" + }, "iniconfig": { "hashes": [ "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", @@ -2303,6 +2352,14 @@ "index": "pypi", "markers": "python_version >= '3.9'", "version": "==3.15.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + ], + "markers": "python_version >= '3.9'", + "version": "==4.15.0" } } -} +} \ No newline at end of file diff --git a/tests/integration/test_api_facts.py b/tests/integration/test_api_facts.py new file mode 100644 index 00000000..0a121e2d --- /dev/null +++ b/tests/integration/test_api_facts.py @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Integration tests for the inventory-facts API endpoints. + +``GET /v1/inventory/hosts/{host}/facts`` and ``.../facts/{fact}`` read the +Ansible facts cache straight from Redis (key ``ansible_facts``). Driving +them through ``fastapi.testclient.TestClient`` against the live Redis exercises +the API-to-Redis read path end-to-end. The suite is skipped automatically when +Redis is not reachable (see ``conftest.py``). +""" + +import json +import uuid + +import pytest + +from osism import utils + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def client(): + """A ``TestClient`` bound to the FastAPI app. + + ``osism.api`` is imported lazily here because importing it wires the event + bridge to Redis at module load -- safe only in the integration environment + where Redis is up. + """ + from fastapi.testclient import TestClient + + from osism import api + + with TestClient(api.app) as test_client: + yield test_client + + +@pytest.fixture +def seed_facts(): + """Seed ``ansible_facts`` keys and remove them after the test.""" + keys = [] + + def _seed(host, value): + key = f"ansible_facts{host}" + utils.redis.set(key, value) + keys.append(key) + + yield _seed + + for key in keys: + utils.redis.delete(key) + + +def test_get_all_facts_returns_parsed_facts_and_count(client, seed_facts): + """All facts are returned parsed, with the correct count and cache flag.""" + host = f"itest-{uuid.uuid4()}" + facts = { + "ansible_hostname": "node-1", + "ansible_processor_count": 4, + "ansible_default_ipv4": {"address": "10.0.0.1", "gateway": "10.0.0.254"}, + } + seed_facts(host, json.dumps(facts)) + + response = client.get(f"/v1/inventory/hosts/{host}/facts") + + assert response.status_code == 200 + body = response.json() + assert body["host"] == host + assert body["count"] == len(facts) + assert body["from_cache"] is True + assert {entry["name"]: entry["value"] for entry in body["facts"]} == facts + + +def test_get_single_fact_returns_value(client, seed_facts): + """A single fact is returned with its exact value and cache flag.""" + host = f"itest-{uuid.uuid4()}" + facts = {"ansible_processor_count": 4, "ansible_hostname": "node-1"} + seed_facts(host, json.dumps(facts)) + + response = client.get(f"/v1/inventory/hosts/{host}/facts/ansible_processor_count") + + assert response.status_code == 200 + body = response.json() + assert body["host"] == host + assert body["name"] == "ansible_processor_count" + assert body["value"] == 4 + assert body["from_cache"] is True + + +def test_get_unknown_fact_returns_404(client, seed_facts): + """Requesting a fact absent from the cached set returns 404.""" + host = f"itest-{uuid.uuid4()}" + seed_facts(host, json.dumps({"ansible_hostname": "node-1"})) + + response = client.get(f"/v1/inventory/hosts/{host}/facts/does_not_exist") + + assert response.status_code == 404 + + +def test_get_all_facts_unknown_host_returns_404(client): + """Requesting facts for a host with no cache key returns 404.""" + host = f"itest-{uuid.uuid4()}" + + response = client.get(f"/v1/inventory/hosts/{host}/facts") + + assert response.status_code == 404 + + +def test_get_single_fact_unknown_host_returns_404(client): + """Requesting a single fact for a host with no cache key returns 404.""" + host = f"itest-{uuid.uuid4()}" + + response = client.get(f"/v1/inventory/hosts/{host}/facts/ansible_hostname") + + assert response.status_code == 404 + + +def test_get_all_facts_malformed_json_returns_500(client, seed_facts): + """Non-JSON data in the cache key surfaces as a 500.""" + host = f"itest-{uuid.uuid4()}" + seed_facts(host, "{ not valid json") + + response = client.get(f"/v1/inventory/hosts/{host}/facts") + + assert response.status_code == 500