mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-19 09:46:33 +00:00
aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO management planes) — for testing ACI automation (Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates without real hardware. Highlights: APIC + NDO REST surface served from one in-memory MIT built from a declarative topology.yaml; port mode + sandbox (real per-device IPs on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror (multi-site templates materialize onto the target sites' APIC stores); real-APIC fvBD default attributes; file-backed state save/restore; an `aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite. History squashed for the public release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
286 lines
12 KiB
Python
286 lines
12 KiB
Python
"""Tests for APIC query subscriptions + websocket push-on-change.
|
|
|
|
Covers:
|
|
1. `?subscription=yes` on the class-query route returns a top-level
|
|
`subscriptionId`; the SAME query WITHOUT that param is byte-identical
|
|
to the pre-subscription response (backward compat — critical).
|
|
2. Websocket `/socket<token>` push for created/modified/deleted events on a
|
|
subscribed class.
|
|
3. A change to an UNsubscribed class does not push anything.
|
|
4. `GET /api/subscriptionRefresh.json?id=<id>` returns 200.
|
|
5. An unknown-token websocket is rejected (closed before any message).
|
|
6. Disconnecting a websocket cleans up its subscriptions (a later change no
|
|
longer has anywhere to push, and does not error).
|
|
|
|
This starlette version's WebSocketTestSession.receive_json() has no built-in
|
|
timeout, so every websocket receive here goes through a thread+Future-bounded
|
|
wrapper (`_receive_json_with_timeout` / `_assert_no_event` below) so a
|
|
missing/never-arriving event fails fast instead of hanging the suite.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from aci_sim.build.orchestrator import build_site
|
|
from aci_sim.rest_aci import subscriptions as subs
|
|
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
|
|
from aci_sim.topology.loader import load_topology
|
|
|
|
TOPO_PATH = "topology.yaml" # run from project root
|
|
|
|
|
|
def _receive_json_with_timeout(ws, timeout: float = 5.0):
|
|
"""Bounded-wait wrapper around WebSocketTestSession.receive_json().
|
|
|
|
This starlette version's TestClient websocket session has no built-in
|
|
receive timeout (`receive()` is a blocking `anyio` portal call with no
|
|
deadline) — an event that never arrives due to a regression would hang
|
|
the whole test suite instead of failing fast. Run the blocking call on a
|
|
worker thread and bound it with `Future.result(timeout=...)` so a missing
|
|
push surfaces as a prompt, clear test failure.
|
|
|
|
Deliberately does NOT use the ThreadPoolExecutor context manager: on a
|
|
timeout the worker thread is still blocked inside `receive()` (there is
|
|
no way to cancel it), and `__exit__`'s default `shutdown(wait=True)`
|
|
would hang waiting for it — defeating the entire point of this helper.
|
|
The pool (and its one leaked, permanently-blocked thread) is simply
|
|
abandoned; harmless for a short-lived test process.
|
|
"""
|
|
pool = ThreadPoolExecutor(max_workers=1)
|
|
future = pool.submit(ws.receive_json)
|
|
try:
|
|
return future.result(timeout=timeout)
|
|
except FutureTimeoutError:
|
|
pytest.fail(f"no websocket event received within {timeout}s")
|
|
|
|
|
|
def _assert_no_event(ws, timeout: float = 2.0) -> None:
|
|
"""Assert NO event arrives within *timeout* (the negative-case counterpart).
|
|
|
|
`pytest.fail` (used by `_receive_json_with_timeout` above) raises
|
|
`_pytest.outcomes.Failed`, which subclasses `BaseException` — NOT
|
|
`Exception` — specifically so a real test failure can never be
|
|
accidentally swallowed by a broad `except Exception`/`pytest.raises
|
|
(Exception)`. That means this helper must check for the timeout directly
|
|
rather than wrapping `_receive_json_with_timeout` in `pytest.raises`.
|
|
"""
|
|
pool = ThreadPoolExecutor(max_workers=1)
|
|
future = pool.submit(ws.receive_json)
|
|
try:
|
|
event = future.result(timeout=timeout)
|
|
except FutureTimeoutError:
|
|
return # correct: no event arrived
|
|
pytest.fail(f"unexpected websocket event arrived: {event!r}")
|
|
|
|
|
|
@pytest.fixture()
|
|
def client():
|
|
"""A fresh app/store/client per test, plus a clean subscriptions registry.
|
|
|
|
Unlike test_rest_aci.py's module-scoped fixture, subscription tests need
|
|
per-test isolation: the subscriptions module holds process-wide module
|
|
state (subscriptionId counter, connections, live subs) that must not leak
|
|
between tests — a stale connection/subscription from a previous test
|
|
could cause a false-positive push or mask a missing one.
|
|
"""
|
|
subs.reset()
|
|
topo = load_topology(TOPO_PATH)
|
|
site = topo.sites[0]
|
|
store = build_site(topo, site)
|
|
state = ApicSiteState(
|
|
name=site.name,
|
|
site=site,
|
|
topo=topo,
|
|
store=store,
|
|
baseline=copy.deepcopy(store),
|
|
)
|
|
app = make_apic_app(state)
|
|
with TestClient(app) as c:
|
|
yield c
|
|
subs.reset()
|
|
|
|
|
|
@pytest.fixture()
|
|
def token(client):
|
|
"""Log in and return the APIC-cookie token (also left set on the client)."""
|
|
resp = client.post(
|
|
"/api/aaaLogin.json",
|
|
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
|
|
)
|
|
assert resp.status_code == 200
|
|
return resp.cookies["APIC-cookie"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subscribe (opt-in query param) + backward compat
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_subscribe_returns_subscription_id(client, token):
|
|
resp = client.get("/api/class/fvTenant.json?subscription=yes")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "subscriptionId" in data
|
|
assert isinstance(data["subscriptionId"], str)
|
|
assert data["subscriptionId"]
|
|
# Normal query fields must still be present and correct.
|
|
assert "imdata" in data
|
|
assert "totalCount" in data
|
|
|
|
|
|
def test_plain_query_byte_identical_without_subscription_param(client, token):
|
|
"""The critical backward-compat guarantee: no ?subscription=yes => no
|
|
subscriptionId field, no behavior change at all vs. pre-feature shape."""
|
|
resp = client.get("/api/class/fvTenant.json")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "subscriptionId" not in data
|
|
assert set(data.keys()) == {"imdata", "totalCount"}
|
|
|
|
|
|
def test_mo_query_subscribe_returns_subscription_id(client, token):
|
|
resp = client.get("/api/mo/uni/tn-mgmt.json?subscription=yes")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "subscriptionId" in data
|
|
|
|
|
|
def test_node_class_fabric_wide_subscribe_returns_subscription_id(client, token):
|
|
resp = client.get("/api/node/class/fvTenant.json?subscription=yes")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "subscriptionId" in data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subscription refresh
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_subscription_refresh_returns_200(client, token):
|
|
resp = client.get("/api/class/fvTenant.json?subscription=yes")
|
|
sid = resp.json()["subscriptionId"]
|
|
refresh_resp = client.get(f"/api/subscriptionRefresh.json?id={sid}")
|
|
assert refresh_resp.status_code == 200
|
|
|
|
|
|
def test_subscription_refresh_unknown_id_still_200(client, token):
|
|
# Real APIC does not error a stale/unknown refresh id.
|
|
resp = client.get("/api/subscriptionRefresh.json?id=999999")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_subscription_refresh_requires_auth(client):
|
|
resp = client.get("/api/subscriptionRefresh.json?id=1")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Websocket push
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_websocket_rejects_unknown_token(client):
|
|
with pytest.raises(Exception):
|
|
with client.websocket_connect("/socket-not-a-real-token") as ws:
|
|
_receive_json_with_timeout(ws, timeout=2)
|
|
|
|
|
|
def test_websocket_push_created_modified_deleted(client, token):
|
|
# Subscribe to fvTenant BEFORE opening the websocket (matches the
|
|
# acceptance flow: subscribe via query, then open the socket).
|
|
sub_resp = client.get("/api/class/fvTenant.json?subscription=yes")
|
|
assert sub_resp.status_code == 200
|
|
subscription_id = sub_resp.json()["subscriptionId"]
|
|
|
|
with client.websocket_connect(f"/socket{token}") as ws:
|
|
# (c) POST a new fvTenant.
|
|
post_resp = client.post(
|
|
"/api/mo/uni/tn-SubTest.json",
|
|
json={"fvTenant": {"attributes": {"name": "SubTest"}}},
|
|
)
|
|
assert post_resp.status_code == 200
|
|
|
|
# (d) assert a created event is pushed with the new tenant's dn.
|
|
event = _receive_json_with_timeout(ws, timeout=5)
|
|
assert subscription_id in event["subscriptionId"]
|
|
assert len(event["imdata"]) == 1
|
|
tenant_body = event["imdata"][0]["fvTenant"]
|
|
assert tenant_body["attributes"]["dn"] == "uni/tn-SubTest"
|
|
assert tenant_body["attributes"]["status"] == "created"
|
|
|
|
# Modify it (POST again on the same DN => pre-existing => "modified").
|
|
mod_resp = client.post(
|
|
"/api/mo/uni/tn-SubTest.json",
|
|
json={"fvTenant": {"attributes": {"name": "SubTest", "descr": "updated"}}},
|
|
)
|
|
assert mod_resp.status_code == 200
|
|
mod_event = _receive_json_with_timeout(ws, timeout=5)
|
|
assert mod_event["imdata"][0]["fvTenant"]["attributes"]["status"] == "modified"
|
|
assert mod_event["imdata"][0]["fvTenant"]["attributes"]["dn"] == "uni/tn-SubTest"
|
|
|
|
# (e) DELETE it => assert a status=deleted event.
|
|
del_resp = client.delete("/api/mo/uni/tn-SubTest.json")
|
|
assert del_resp.status_code == 200
|
|
del_event = _receive_json_with_timeout(ws, timeout=5)
|
|
assert subscription_id in del_event["subscriptionId"]
|
|
del_tenant = del_event["imdata"][0]["fvTenant"]
|
|
assert del_tenant["attributes"]["status"] == "deleted"
|
|
assert del_tenant["attributes"]["dn"] == "uni/tn-SubTest"
|
|
|
|
|
|
def test_unsubscribed_class_does_not_push(client, token):
|
|
# Subscribe only to fvTenant.
|
|
client.get("/api/class/fvTenant.json?subscription=yes")
|
|
|
|
with client.websocket_connect(f"/socket{token}") as ws:
|
|
# Change an entirely different, unsubscribed class.
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-mgmt/mgmtp-default.json",
|
|
json={"mgmtMgmtP": {"attributes": {"descr": "no-op change"}}},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
# No event should arrive for this — bounded wait, not a hang.
|
|
_assert_no_event(ws, timeout=2)
|
|
|
|
|
|
def test_dn_scoped_subscription_matches_subtree_change(client, token):
|
|
# Subscribe via the MO/DN query shape on a parent DN.
|
|
sub_resp = client.get("/api/mo/uni/tn-mgmt.json?subscription=yes")
|
|
subscription_id = sub_resp.json()["subscriptionId"]
|
|
|
|
with client.websocket_connect(f"/socket{token}") as ws:
|
|
# A write to a *child* DN under uni/tn-mgmt should still notify the
|
|
# parent-DN subscriber (subtree match).
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-mgmt/ap-testap.json",
|
|
json={"fvAp": {"attributes": {"name": "testap"}}},
|
|
)
|
|
assert resp.status_code == 200
|
|
event = _receive_json_with_timeout(ws, timeout=5)
|
|
assert subscription_id in event["subscriptionId"]
|
|
assert event["imdata"][0]["fvAp"]["attributes"]["dn"] == "uni/tn-mgmt/ap-testap"
|
|
|
|
|
|
def test_disconnect_cleans_up_subscriptions(client, token):
|
|
sub_resp = client.get("/api/class/fvTenant.json?subscription=yes")
|
|
assert sub_resp.status_code == 200
|
|
|
|
with client.websocket_connect(f"/socket{token}") as ws:
|
|
pass # connect then immediately disconnect (context manager exit)
|
|
|
|
# After disconnect, a change to fvTenant must not raise/error even though
|
|
# the (now-dead) subscription still matches the scope — notify() must
|
|
# silently skip a token with no live connection.
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-AfterDisconnect.json",
|
|
json={"fvTenant": {"attributes": {"name": "AfterDisconnect"}}},
|
|
)
|
|
assert resp.status_code == 200
|