Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 7 (#2199). osism/api.py (1161 LOC) implements the OSISM API server (FastAPI). Because of its size the module is split into two sub-issues: this issue covers the route handlers (HTTP + WebSocket) exercised through fastapi.testclient.TestClient; the companion issue "Unit tests for osism/api.py — helper functions, Pydantic models, secret masking" (Tier 7, split 1/2) covers _mask_inventory_secrets(), find_device_by_identifier(), process_netbox_webhook() and the models directly.
Dev dependency required: fastapi itself is a runtime dependency (requirements.txt:13; CI installs the package via pipenv run pip install ., see playbooks/test-unit.yml), but TestClient is httpx-based since Starlette 0.21 and httpx is not declared anywhere (not in Pipfile, Pipfile.lock or requirements.txt; import httpx fails in the current pipenv environment). Add httpx to [dev-packages] in the Pipfile (pinned like the other dev packages, e.g. httpx = "==0.28.1") and relock as part of this issue.
Scope
Add tests/unit/test_api_endpoints.py covering all HTTP endpoints and the WebSocket endpoint of osism/api.py via TestClient(app). There is currently no test coverage for osism/api.py. Direct unit tests for the helper functions and models are out of scope (companion issue) — but response-level effects such as secret masking in hostvars responses belong here. This issue is large (20 routes); it may be split further during implementation, as was done for Tier 3 issues.
Test targets
Health endpoints — root() api.py:293, v1() api.py:299, events_info() api.py:305
No patching needed.
GET / → 200, {"result": "ok"}
GET /v1 → 200, {"result": "ok"}
GET /v1/events → 200, body contains websocket_endpoint == "/v1/events/openstack"
Telemetry sinks — write_sink_meters() api.py:378, write_sink_events() api.py:396
No patching needed (handlers only parse JSON and log).
POST /v1/meters/sink with a JSON list → 200, {"result": "ok"}
POST /v1/meters/sink with a JSON object → 200
POST /v1/meters/sink with a malformed JSON body (content=b"{not json") → 500, detail "Failed to process meters data"
- Same three cases for
POST /v1/events/sink (detail "Failed to process events data")
get_baremetal_nodes_list() — api.py:416
Patch osism.api.openstack.get_baremetal_nodes.
- Returns two node dicts → 200,
count == 2, fields mapped into BaremetalNode entries
- Returns
[] → 200, count == 0
- Raises → 500, detail starts with
"Failed to retrieve baremetal nodes:" and includes the exception text
NetBox info — get_baremetal_node_netbox_info() api.py:443, get_baremetal_nodes_netbox_info() api.py:464
Patch osism.api.openstack.get_baremetal_node_netbox_info / osism.api.openstack.get_baremetal_nodes_netbox_info.
GET /v1/baremetal/nodes/{node_name}/netbox happy path → 200, device_role/primary_ip4/primary_ip6/netbox_url mapped; task function called with node_name
- Single-node lookup raises → 500, detail includes the node name
POST /v1/baremetal/nodes/netbox with {"node_names": ["n1", "n2"]} → 200, nodes keyed by name; task function called with the list
POST body missing node_names → 422 (FastAPI validation, handler never called)
- Bulk lookup raises → 500, detail
"Failed to retrieve NetBox info: ..."
get_baremetal_node_ports() api.py:488, get_baremetal_node_parameters() api.py:507
Patch osism.api.openstack.get_baremetal_node_ports / osism.api.openstack.get_baremetal_node_parameters.
- Ports happy path → 200,
count matches, port fields mapped; raises → 500 with node UUID in detail
- Parameters happy path (
kernel_append_params, netplan_parameters, frr_parameters) → 200; raises → 500 with node UUID in detail
notifications_baremetal() — api.py:526
Patch osism.api.baremetal_events.get_handler (method on the module-level BaremetalEvents instance) via mocker.patch.object.
- Valid notification body → 204 with empty body;
get_handler called with event_type, returned handler called with payload
- Handler raises → 500, detail
"Failed to process baremetal notification"
message_id not a UUID → 422
- Optional: without patching, an unknown
event_type (e.g. "foo.bar.baz.qux") with payload={"ironic_object.data": {"name": "n1"}} exercises the real default handler → 204
sonic_ztp_complete() — api.py:549
Patch osism.utils.nb (see hints) and osism.api.find_device_by_identifier (already unit-tested in the companion issue).
utils.nb is None → 503, detail "NetBox is not enabled"
- Device found → 200,
{"result": "ok", "device": <name>}; device.custom_fields["provision_state"] set to "active" and device.save() called
- No device found → 404, detail contains the identifier (the inner
HTTPException is re-raised unchanged through the generic handler)
device.save() raises → 500, detail "Failed to complete ZTP process"
webhook() — api.py:704
Patch osism.utils.nb and osism.api.process_netbox_webhook (its branch logic is unit-tested in the companion issue).
utils.nb is None → 503, detail "NetBox webhook processing is not enabled"
- Valid body → 200,
{"result": "ok"}; process_netbox_webhook called with the parsed WebhookNetboxData
process_netbox_webhook raises → 500, detail "Failed to process NetBox webhook"
- Body missing a required field (e.g.
request_id) → 422
- Note:
content_length: int = Header(...) is required, but httpx/TestClient sets Content-Length automatically for JSON bodies; x_hook_signature is optional and currently unvalidated
websocket_openstack_events() — api.py:632
Patch osism.api.websocket_manager with a stub whose async connect accepts the socket (see hints) — this avoids starting the real broadcaster background task.
client.websocket_connect("/v1/events/openstack") → connect awaited with the WebSocket
- Send
{"action": "set_filters", "event_filters": [...], "node_filters": [...], "service_filters": [...]} → update_filters awaited with exactly these values; acknowledgment frame received: {"type": "filter_update", "status": "success", ...} echoing the filters
set_filters with only some keys → missing ones passed as None and echoed as null in the ack
- Send invalid JSON text → no ack, no
update_filters call, connection stays usable (a subsequent valid set_filters still gets an ack)
- Send valid JSON with
action != "set_filters" → no ack, no update_filters call
update_filters raising → inner except Exception logs and the loop continues (connection still open)
- Closing the client context →
disconnect awaited (the finally block)
get_inventory_hosts() — api.py:736
Patch osism.api.get_inventory_path, osism.api.os.path.exists, osism.api.subprocess.run, osism.api.get_hosts_from_inventory (the inventory helpers themselves are covered by #2194).
- Inventory file missing (
os.path.exists → False) → 503, detail contains the path
subprocess.run result with returncode != 0 → 500, "Failed to load Ansible inventory"
- Happy path → 200,
hosts + count; assert the command is ["ansible-inventory", "-i", <path>, "--list"]
?limit=compute* → command extended with ["--limit", "compute*"]
subprocess.TimeoutExpired → 504
- Non-JSON stdout → 500,
"Failed to parse Ansible inventory"
get_hosts_from_inventory raises → 500, generic "Failed to retrieve hosts: ..."
Hostvars — get_host_hostvars() api.py:803, get_host_hostvar() api.py:866
Patch osism.api.get_inventory_path and osism.api.subprocess.run.
- Happy path → 200; variables sorted by name,
count correct; assert get_inventory_path called with prefer_minified=False and the command uses --host <host>
- Secret masking applied in the response: stdout containing
{"ansible_password": "x", "vaulted": "$ANSIBLE_VAULT;1.1;AES256\n..."} → both values are "***"
returncode != 0 with "Could not match" in stderr → 404, "Host '<host>' not found in inventory"
returncode != 0 with "Unable to parse" in stderr → 404
returncode != 0 with other stderr → 500
subprocess.TimeoutExpired → 504
- Non-JSON stdout → 500
- Single-variable endpoint: variable present → 200 with
host/name/value; secret variable (e.g. database_password) → value "***" (masking happens after the membership check)
- Variable not in hostvars → 404,
"Variable '<variable>' not found for host '<host>'"
- Host not found / timeout / bad JSON → 404 / 504 / 500 as above
Facts — get_host_facts() api.py:932, get_host_fact() api.py:971
Patch utils.redis — the lazy attribute on osism.utils — via monkeypatch.setattr(osism.utils, "redis", fake_redis, raising=False).
redis.get returns None → 404, "No facts found in cache for host '<host>'"; assert the (separator-less) cache key f"ansible_facts{host}" is used
- Returns JSON bytes → 200; facts sorted by name,
count correct, from_cache is True
- Returns invalid JSON → 500,
"Failed to parse facts for <host>"
redis.get raises → 500, generic "Failed to retrieve facts: ..."
- Single-fact endpoint: fact present → 200 with
value and from_cache is True; fact missing → 404 "Fact '<fact>' not found..."; no cache entry → 404; invalid JSON → 500
search_inventory() — api.py:1010
Patch osism.api.get_inventory_path, osism.api.os.path.exists, osism.api.subprocess.run, osism.api.get_hosts_from_inventory, and builtins.open (facts are read from /cache/facts/<host>).
- Missing
name_pattern query parameter → 422
- Invalid
name_pattern regex ("[") → 400, "Invalid name_pattern regex: ..."
- Invalid
host_pattern regex → 400
source=bogus → 400, "source must be 'hostvars', 'facts', or omitted for both"
- Inventory file missing → 503
- Inventory
--list call with returncode != 0 → 500
- Hostvars search happy path:
--list then one --host call per host (subprocess.run side_effect list); only names matching name_pattern (case-insensitive) returned with source == "hostvars"; secret values masked ("***") before matching
host_pattern filters the host list; hosts_searched reflects the filtered count
source=facts: no per-host ansible-inventory --host calls; facts file read via open, matching fact names returned with source == "facts"; facts file absent → host contributes nothing
limit honored: more matches than limit → exactly limit results, host loop breaks early
- Per-host
subprocess.TimeoutExpired or bad hostvars JSON → warning, host skipped, search continues
- Facts file with invalid JSON /
IOError on open → warning, search continues
- Response
query dict echoes name_pattern, host_pattern, source, limit
- Two
get_inventory_path calls: prefer_minified=False for hostvars, default (minified) for the host list
Mocking hints
- Add
httpx to [dev-packages] in the Pipfile first — from fastapi.testclient import TestClient raises without it (Starlette ≥ 0.21 TestClient is httpx-based).
- Module-scoped fixture:
from fastapi.testclient import TestClient
from osism.api import app
@pytest.fixture(scope="module")
def client():
return TestClient(app)
- Importing
osism.api instantiates EventBridge() (osism/services/event_bridge.py:304), which attempts redis.Redis(...).ping() against host redis; the exception is caught and logged, so no Redis is needed — just expect the log line. The import also runs dictConfig.
osism.api does from osism.tasks import reconciler, openstack — attribute lookups happen on the module objects, so mocker.patch("osism.api.openstack.get_baremetal_nodes") patches the same object as osism.tasks.openstack.get_baremetal_nodes. Tests call the patched task functions synchronously via the endpoint; no Celery broker is involved.
utils.nb and utils.redis are lazy attributes materialized by osism.utils.__getattr__; use monkeypatch.setattr(osism.utils, "nb", fake, raising=False) (a plain mocker.patch("osism.utils.nb") would trigger a real connection attempt during the initial getattr).
- Fake
subprocess.run results: subprocess.CompletedProcess(args=[...], returncode=0, stdout=json.dumps(payload), stderr="") (the code passes text=True, so stdout is a str). Timeouts: side_effect=subprocess.TimeoutExpired(cmd="ansible-inventory", timeout=30).
- WebSocket stub — the real
websocket_manager.connect calls websocket.accept(); a bare AsyncMock would never accept and client.websocket_connect would fail. Use:
fake_manager = mocker.MagicMock()
async def fake_connect(ws):
await ws.accept()
fake_manager.connect = mocker.AsyncMock(side_effect=fake_connect)
fake_manager.update_filters = mocker.AsyncMock()
fake_manager.disconnect = mocker.AsyncMock()
mocker.patch("osism.api.websocket_manager", fake_manager)
Then with client.websocket_connect("/v1/events/openstack") as ws: ws.send_text(...); ws.receive_json().
- For the facts branch of
search_inventory, patch osism.api.os.path.exists with a side_effect keyed on the path (inventory path → True, /cache/facts/<host> → as needed) and builtins.open with mock_open(read_data=json.dumps(facts)).
- A minimal valid webhook body (JSON) for
POST /v1/webhook/netbox:
{
"username": "admin",
"data": {"url": "/api/dcim/devices/1/", "name": "sw1",
"tags": [{"name": "Managed by OSISM"}],
"custom_fields": {"device_type": "server"}},
"snapshots": {},
"event": "updated",
"timestamp": "2026-01-01T00:00:00Z",
"model": "device",
"request_id": "f8a6e0a4-3c1b-4f53-9c5f-2f4f3b2a1e00"
}
Definition of Done
Dependencies
Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 7 (#2199).
osism/api.py(1161 LOC) implements the OSISM API server (FastAPI). Because of its size the module is split into two sub-issues: this issue covers the route handlers (HTTP + WebSocket) exercised throughfastapi.testclient.TestClient; the companion issue "Unit tests forosism/api.py— helper functions, Pydantic models, secret masking" (Tier 7, split 1/2) covers_mask_inventory_secrets(),find_device_by_identifier(),process_netbox_webhook()and the models directly.Dev dependency required:
fastapiitself is a runtime dependency (requirements.txt:13; CI installs the package viapipenv run pip install ., seeplaybooks/test-unit.yml), butTestClientis httpx-based since Starlette 0.21 andhttpxis not declared anywhere (not inPipfile,Pipfile.lockorrequirements.txt;import httpxfails in the current pipenv environment). Addhttpxto[dev-packages]in thePipfile(pinned like the other dev packages, e.g.httpx = "==0.28.1") and relock as part of this issue.Scope
Add
tests/unit/test_api_endpoints.pycovering all HTTP endpoints and the WebSocket endpoint ofosism/api.pyviaTestClient(app). There is currently no test coverage forosism/api.py. Direct unit tests for the helper functions and models are out of scope (companion issue) — but response-level effects such as secret masking in hostvars responses belong here. This issue is large (20 routes); it may be split further during implementation, as was done for Tier 3 issues.Test targets
Health endpoints —
root()api.py:293,v1()api.py:299,events_info()api.py:305No patching needed.
GET /→ 200,{"result": "ok"}GET /v1→ 200,{"result": "ok"}GET /v1/events→ 200, body containswebsocket_endpoint == "/v1/events/openstack"Telemetry sinks —
write_sink_meters()api.py:378,write_sink_events()api.py:396No patching needed (handlers only parse JSON and log).
POST /v1/meters/sinkwith a JSON list → 200,{"result": "ok"}POST /v1/meters/sinkwith a JSON object → 200POST /v1/meters/sinkwith a malformed JSON body (content=b"{not json") → 500, detail"Failed to process meters data"POST /v1/events/sink(detail"Failed to process events data")get_baremetal_nodes_list()—api.py:416Patch
osism.api.openstack.get_baremetal_nodes.count == 2, fields mapped intoBaremetalNodeentries[]→ 200,count == 0"Failed to retrieve baremetal nodes:"and includes the exception textNetBox info —
get_baremetal_node_netbox_info()api.py:443,get_baremetal_nodes_netbox_info()api.py:464Patch
osism.api.openstack.get_baremetal_node_netbox_info/osism.api.openstack.get_baremetal_nodes_netbox_info.GET /v1/baremetal/nodes/{node_name}/netboxhappy path → 200,device_role/primary_ip4/primary_ip6/netbox_urlmapped; task function called withnode_namePOST /v1/baremetal/nodes/netboxwith{"node_names": ["n1", "n2"]}→ 200,nodeskeyed by name; task function called with the listPOSTbody missingnode_names→ 422 (FastAPI validation, handler never called)"Failed to retrieve NetBox info: ..."get_baremetal_node_ports()api.py:488,get_baremetal_node_parameters()api.py:507Patch
osism.api.openstack.get_baremetal_node_ports/osism.api.openstack.get_baremetal_node_parameters.countmatches, port fields mapped; raises → 500 with node UUID in detailkernel_append_params,netplan_parameters,frr_parameters) → 200; raises → 500 with node UUID in detailnotifications_baremetal()—api.py:526Patch
osism.api.baremetal_events.get_handler(method on the module-levelBaremetalEventsinstance) viamocker.patch.object.get_handlercalled withevent_type, returned handler called withpayload"Failed to process baremetal notification"message_idnot a UUID → 422event_type(e.g."foo.bar.baz.qux") withpayload={"ironic_object.data": {"name": "n1"}}exercises the real default handler → 204sonic_ztp_complete()—api.py:549Patch
osism.utils.nb(see hints) andosism.api.find_device_by_identifier(already unit-tested in the companion issue).utils.nbisNone→ 503, detail"NetBox is not enabled"{"result": "ok", "device": <name>};device.custom_fields["provision_state"]set to"active"anddevice.save()calledHTTPExceptionis re-raised unchanged through the generic handler)device.save()raises → 500, detail"Failed to complete ZTP process"webhook()—api.py:704Patch
osism.utils.nbandosism.api.process_netbox_webhook(its branch logic is unit-tested in the companion issue).utils.nbisNone→ 503, detail"NetBox webhook processing is not enabled"{"result": "ok"};process_netbox_webhookcalled with the parsedWebhookNetboxDataprocess_netbox_webhookraises → 500, detail"Failed to process NetBox webhook"request_id) → 422content_length: int = Header(...)is required, but httpx/TestClient setsContent-Lengthautomatically for JSON bodies;x_hook_signatureis optional and currently unvalidatedwebsocket_openstack_events()—api.py:632Patch
osism.api.websocket_managerwith a stub whose asyncconnectaccepts the socket (see hints) — this avoids starting the real broadcaster background task.client.websocket_connect("/v1/events/openstack")→connectawaited with the WebSocket{"action": "set_filters", "event_filters": [...], "node_filters": [...], "service_filters": [...]}→update_filtersawaited with exactly these values; acknowledgment frame received:{"type": "filter_update", "status": "success", ...}echoing the filtersset_filterswith only some keys → missing ones passed asNoneand echoed asnullin the ackupdate_filterscall, connection stays usable (a subsequent validset_filtersstill gets an ack)action != "set_filters"→ no ack, noupdate_filterscallupdate_filtersraising → innerexcept Exceptionlogs and the loop continues (connection still open)disconnectawaited (thefinallyblock)get_inventory_hosts()—api.py:736Patch
osism.api.get_inventory_path,osism.api.os.path.exists,osism.api.subprocess.run,osism.api.get_hosts_from_inventory(the inventory helpers themselves are covered by #2194).os.path.exists→False) → 503, detail contains the pathsubprocess.runresult withreturncode != 0→ 500,"Failed to load Ansible inventory"hosts+count; assert the command is["ansible-inventory", "-i", <path>, "--list"]?limit=compute*→ command extended with["--limit", "compute*"]subprocess.TimeoutExpired→ 504"Failed to parse Ansible inventory"get_hosts_from_inventoryraises → 500, generic"Failed to retrieve hosts: ..."Hostvars —
get_host_hostvars()api.py:803,get_host_hostvar()api.py:866Patch
osism.api.get_inventory_pathandosism.api.subprocess.run.countcorrect; assertget_inventory_pathcalled withprefer_minified=Falseand the command uses--host <host>{"ansible_password": "x", "vaulted": "$ANSIBLE_VAULT;1.1;AES256\n..."}→ both values are"***"returncode != 0with"Could not match"in stderr → 404,"Host '<host>' not found in inventory"returncode != 0with"Unable to parse"in stderr → 404returncode != 0with other stderr → 500subprocess.TimeoutExpired→ 504host/name/value; secret variable (e.g.database_password) → value"***"(masking happens after the membership check)"Variable '<variable>' not found for host '<host>'"Facts —
get_host_facts()api.py:932,get_host_fact()api.py:971Patch
utils.redis— the lazy attribute onosism.utils— viamonkeypatch.setattr(osism.utils, "redis", fake_redis, raising=False).redis.getreturnsNone→ 404,"No facts found in cache for host '<host>'"; assert the (separator-less) cache keyf"ansible_facts{host}"is usedcountcorrect,from_cache is True"Failed to parse facts for <host>"redis.getraises → 500, generic"Failed to retrieve facts: ..."valueandfrom_cache is True; fact missing → 404"Fact '<fact>' not found..."; no cache entry → 404; invalid JSON → 500search_inventory()—api.py:1010Patch
osism.api.get_inventory_path,osism.api.os.path.exists,osism.api.subprocess.run,osism.api.get_hosts_from_inventory, andbuiltins.open(facts are read from/cache/facts/<host>).name_patternquery parameter → 422name_patternregex ("[") → 400,"Invalid name_pattern regex: ..."host_patternregex → 400source=bogus→ 400,"source must be 'hostvars', 'facts', or omitted for both"--listcall withreturncode != 0→ 500--listthen one--hostcall per host (subprocess.runside_effectlist); only names matchingname_pattern(case-insensitive) returned withsource == "hostvars"; secret values masked ("***") before matchinghost_patternfilters the host list;hosts_searchedreflects the filtered countsource=facts: no per-hostansible-inventory --hostcalls; facts file read viaopen, matching fact names returned withsource == "facts"; facts file absent → host contributes nothinglimithonored: more matches thanlimit→ exactlylimitresults, host loop breaks earlysubprocess.TimeoutExpiredor bad hostvars JSON → warning, host skipped, search continuesIOErroron open → warning, search continuesquerydict echoesname_pattern,host_pattern,source,limitget_inventory_pathcalls:prefer_minified=Falsefor hostvars, default (minified) for the host listMocking hints
httpxto[dev-packages]in thePipfilefirst —from fastapi.testclient import TestClientraises without it (Starlette ≥ 0.21 TestClient is httpx-based).osism.apiinstantiatesEventBridge()(osism/services/event_bridge.py:304), which attemptsredis.Redis(...).ping()against hostredis; the exception is caught and logged, so no Redis is needed — just expect the log line. The import also runsdictConfig.osism.apidoesfrom osism.tasks import reconciler, openstack— attribute lookups happen on the module objects, somocker.patch("osism.api.openstack.get_baremetal_nodes")patches the same object asosism.tasks.openstack.get_baremetal_nodes. Tests call the patched task functions synchronously via the endpoint; no Celery broker is involved.utils.nbandutils.redisare lazy attributes materialized byosism.utils.__getattr__; usemonkeypatch.setattr(osism.utils, "nb", fake, raising=False)(a plainmocker.patch("osism.utils.nb")would trigger a real connection attempt during the initialgetattr).subprocess.runresults:subprocess.CompletedProcess(args=[...], returncode=0, stdout=json.dumps(payload), stderr="")(the code passestext=True, sostdoutis astr). Timeouts:side_effect=subprocess.TimeoutExpired(cmd="ansible-inventory", timeout=30).websocket_manager.connectcallswebsocket.accept(); a bareAsyncMockwould never accept andclient.websocket_connectwould fail. Use:with client.websocket_connect("/v1/events/openstack") as ws: ws.send_text(...); ws.receive_json().search_inventory, patchosism.api.os.path.existswith aside_effectkeyed on the path (inventory path →True,/cache/facts/<host>→ as needed) andbuiltins.openwithmock_open(read_data=json.dumps(facts)).POST /v1/webhook/netbox:{ "username": "admin", "data": {"url": "/api/dcim/devices/1/", "name": "sw1", "tags": [{"name": "Managed by OSISM"}], "custom_fields": {"device_type": "server"}}, "snapshots": {}, "event": "updated", "timestamp": "2026-01-01T00:00:00Z", "model": "device", "request_id": "f8a6e0a4-3c1b-4f53-9c5f-2f4f3b2a1e00" }Definition of Done
httpxadded to[dev-packages]inPipfile(andPipfile.lockupdated)tests/unit/test_api_endpoints.pycreatedpytest --cov=osism.apishows ≥ 90 % (together with the companion helper issue)pipenv run pytest tests/unit/test_api_endpoints.pypasses locallyflake8,mypy,python-blackremain greenpython-osism-unit-testspassesDependencies
osism/api.py— helper functions, Pydantic models, secret masking (Tier 7, split 1/2).