mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
Initial public release — aci-sim v0.16.0
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>
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user