mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +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>
278 lines
9.6 KiB
Python
278 lines
9.6 KiB
Python
"""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
|