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,277 @@
|
||||
"""Regression tests for PR-10: cisco.aci/cisco.mso E2E-run gap fixes.
|
||||
|
||||
Triaged from a live aci-ansible E2E run whose failures were checked against
|
||||
real-gear logs (see docs/CONTRACT.md §7/§11 and CHANGELOG 0.2.1). Covers:
|
||||
|
||||
1. GET/POST/DELETE via /api/node/mo/{dn} behave identically to the
|
||||
canonical /api/mo/{dn} — real APIC serves /api/node/mo/{dn}.json as a
|
||||
full alias (API Inspector / aci_rest form); the sim only registered
|
||||
/api/mo/*, so cisco.aci calls hitting the alias got FastAPI's default
|
||||
404 {"detail":"Not Found"} (11 E2E failures).
|
||||
2. An unmatched /api/* path returns an APIC-shaped 400 error envelope
|
||||
(imdata/error/code), never FastAPI's {"detail":...} shape.
|
||||
3. NDO /mso/api/v1/sites site names equal the topology's fabric names
|
||||
(e.g. "LAB1-IT-ACI"), not the short internal site name ("LAB1") —
|
||||
verified against a real-gear cisco.mso.mso_tenant run that only
|
||||
accepted the fabric-name form.
|
||||
4. GET /mso/api/v1/schemas/list-identity returns every seeded schema with
|
||||
id + displayName, and is NOT shadowed by the parameterized
|
||||
/mso/api/v1/schemas/{schema_id} route.
|
||||
5. GET /api/v1/tenants (cisco.mso.ndo_template's tenant prereq lookup)
|
||||
returns the NDO tenants list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from aci_sim.build.orchestrator import build_site
|
||||
from aci_sim.ndo.app import make_ndo_app
|
||||
from aci_sim.ndo.model import build_ndo_model
|
||||
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
|
||||
from aci_sim.topology.loader import load_topology
|
||||
|
||||
TOPO_PATH = "topology.yaml"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# APIC fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _new_apic_client() -> TestClient:
|
||||
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),
|
||||
)
|
||||
return TestClient(make_apic_app(state))
|
||||
|
||||
|
||||
def _logged_in_apic_client() -> TestClient:
|
||||
c = _new_apic_client()
|
||||
resp = c.post(
|
||||
"/api/aaaLogin.json",
|
||||
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NDO fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def topo():
|
||||
return load_topology(TOPO_PATH)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ndo_client(topo):
|
||||
state = build_ndo_model(topo)
|
||||
app = make_ndo_app(state)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ndo_token(ndo_client):
|
||||
resp = ndo_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"userName": "admin", "userPasswd": "cisco123", "domain": "local"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FIX A — /api/node/mo alias
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_node_mo_alias_get_post_delete_round_trip():
|
||||
"""POST/GET/DELETE via /api/node/mo/{dn} must behave identically to the
|
||||
canonical /api/mo/{dn} route — create via the node-alias, read back via
|
||||
the canonical path, then delete via the alias and confirm removal.
|
||||
"""
|
||||
c = _logged_in_apic_client()
|
||||
|
||||
# Create via the node-alias.
|
||||
post_resp = c.post(
|
||||
"/api/node/mo/uni/tn-PR10AliasTest.json",
|
||||
json={
|
||||
"fvTenant": {
|
||||
"attributes": {"name": "PR10AliasTest", "dn": "uni/tn-PR10AliasTest"}
|
||||
}
|
||||
},
|
||||
)
|
||||
assert post_resp.status_code == 200, post_resp.text
|
||||
assert post_resp.json()["imdata"][0]["fvTenant"]["attributes"]["status"] == "created"
|
||||
|
||||
# Read back via the canonical path.
|
||||
get_resp = c.get("/api/mo/uni/tn-PR10AliasTest.json")
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["totalCount"] == "1"
|
||||
assert (
|
||||
get_resp.json()["imdata"][0]["fvTenant"]["attributes"]["name"]
|
||||
== "PR10AliasTest"
|
||||
)
|
||||
|
||||
# Also readable via the alias itself.
|
||||
get_alias_resp = c.get("/api/node/mo/uni/tn-PR10AliasTest.json")
|
||||
assert get_alias_resp.status_code == 200
|
||||
assert get_alias_resp.json()["totalCount"] == "1"
|
||||
|
||||
# Delete via the alias.
|
||||
del_resp = c.delete("/api/node/mo/uni/tn-PR10AliasTest.json")
|
||||
assert del_resp.status_code == 200
|
||||
assert del_resp.json()["totalCount"] == "0"
|
||||
|
||||
# Confirm removal via the canonical path.
|
||||
confirm_resp = c.get("/api/mo/uni/tn-PR10AliasTest.json")
|
||||
assert confirm_resp.status_code == 200
|
||||
assert confirm_resp.json()["totalCount"] == "0"
|
||||
|
||||
|
||||
def test_node_mo_alias_requires_auth():
|
||||
"""The alias must be gated by the same session check as /api/mo/*."""
|
||||
c = _new_apic_client()
|
||||
resp = c.get("/api/node/mo/uni/tn-Whatever.json")
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["imdata"][0]["error"]["attributes"]["code"] == "403"
|
||||
|
||||
|
||||
def test_node_mo_alias_does_not_shadow_node_class():
|
||||
"""/api/node/mo/* and /api/node/class/* are distinct literal prefixes —
|
||||
the fabric-wide node/class form must still resolve correctly.
|
||||
"""
|
||||
c = _logged_in_apic_client()
|
||||
resp = c.get("/api/node/class/fabricNode.json")
|
||||
assert resp.status_code == 200
|
||||
assert int(resp.json()["totalCount"]) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FIX A — unmatched /api/* fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unmatched_api_path_returns_apic_shaped_400():
|
||||
c = _logged_in_apic_client()
|
||||
resp = c.get("/api/bogus/nonexistent/path")
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert "detail" not in body
|
||||
assert body["imdata"][0]["error"]["attributes"]["code"] == "400"
|
||||
assert "Invalid request path" in body["imdata"][0]["error"]["attributes"]["text"]
|
||||
|
||||
|
||||
def test_unmatched_non_api_path_unaffected():
|
||||
"""Paths outside /api/* (e.g. control-plane) keep the default 404 shape —
|
||||
the fallback is scoped to the APIC REST contract only.
|
||||
"""
|
||||
c = _logged_in_apic_client()
|
||||
resp = c.get("/_sim/does-not-exist")
|
||||
assert resp.status_code == 404
|
||||
assert "detail" in resp.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FIX B — NDO site names == fabric names
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ndo_sites_use_fabric_names(ndo_client, ndo_token, topo):
|
||||
resp = ndo_client.get(
|
||||
"/mso/api/v1/sites",
|
||||
headers={"Authorization": f"Bearer {ndo_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
sites = resp.json()["sites"]
|
||||
reported_names = {s["name"] for s in sites}
|
||||
|
||||
expected_fabric_names = {(s.fabric_name or s.name) for s in topo.sites}
|
||||
assert reported_names == expected_fabric_names, (
|
||||
f"NDO site names {reported_names} must equal topology fabric names "
|
||||
f"{expected_fabric_names}, per real-gear cisco.mso.mso_tenant "
|
||||
f"acceptance (see docs/CONTRACT.md §7)."
|
||||
)
|
||||
# And explicitly NOT the short internal names (guards against a
|
||||
# regression that silently reverts to site.name).
|
||||
short_names = {s.name for s in topo.sites}
|
||||
assert reported_names.isdisjoint(short_names - expected_fabric_names)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FIX C — schema list-identity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_schema_list_identity_returns_all_seeded_schemas(ndo_client, ndo_token):
|
||||
resp = ndo_client.get(
|
||||
"/mso/api/v1/schemas/list-identity",
|
||||
headers={"Authorization": f"Bearer {ndo_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "schemas" in data
|
||||
schemas = data["schemas"]
|
||||
assert len(schemas) >= 3, "expected at least the 3 seeded schemas"
|
||||
|
||||
display_names = {s["displayName"] for s in schemas}
|
||||
for expected in ("MS-TN1-schema", "SF-TN1-LAB1-schema", "SF-TN1-LAB2-schema"):
|
||||
assert expected in display_names, f"{expected} missing from list-identity"
|
||||
|
||||
for s in schemas:
|
||||
assert "id" in s
|
||||
assert "displayName" in s
|
||||
|
||||
|
||||
def test_schema_list_identity_not_shadowed_by_id_route(ndo_client, ndo_token):
|
||||
"""Route-ordering regression guard: list-identity must be declared before
|
||||
the parameterized /schemas/{schema_id} route, or FastAPI matches
|
||||
"list-identity" as a schema_id and 404s with
|
||||
{"detail": "Schema 'list-identity' not found"}.
|
||||
"""
|
||||
resp = ndo_client.get(
|
||||
"/mso/api/v1/schemas/list-identity",
|
||||
headers={"Authorization": f"Bearer {ndo_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "detail" not in resp.json()
|
||||
assert "schemas" in resp.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FIX C — /api/v1/tenants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bare_api_v1_tenants_returns_tenant_list(ndo_client, ndo_token, topo):
|
||||
resp = ndo_client.get(
|
||||
"/api/v1/tenants",
|
||||
headers={"Authorization": f"Bearer {ndo_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "tenants" in data
|
||||
tenants = data["tenants"]
|
||||
assert len(tenants) == len(topo.tenants)
|
||||
|
||||
tenant_names = {t["name"] for t in tenants}
|
||||
for tt in topo.tenants:
|
||||
assert tt.name in tenant_names
|
||||
|
||||
for t in tenants:
|
||||
assert "id" in t
|
||||
assert "siteAssociations" in t
|
||||
Reference in New Issue
Block a user