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,666 @@
|
||||
"""Phase 6 NDO tests — tests/test_ndo.py
|
||||
|
||||
All tests use FastAPI's synchronous TestClient (no real network).
|
||||
The module-scoped client/state is shared across the whole module so that the
|
||||
POST-schema write test can be followed by a GET read-back in the same session.
|
||||
|
||||
Key assertions:
|
||||
- every §7 endpoint responds with the right shape
|
||||
- VRF/BD/EPG/contract names in the schema exactly match the topology
|
||||
- DHCP relay UUID resolves via the tenantPolicy template
|
||||
- POST /schemas stores the schema; subsequent GET /schemas shows it
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from aci_sim.ndo.app import make_ndo_app
|
||||
from aci_sim.ndo.model import (
|
||||
DHCP_RELAY_UUID,
|
||||
TENANT_POLICY_TEMPLATE_ID,
|
||||
build_ndo_model,
|
||||
)
|
||||
from aci_sim.topology.loader import load_topology
|
||||
|
||||
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-scoped fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def topo():
|
||||
return load_topology(TOPOLOGY_YAML)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client(topo):
|
||||
state = build_ndo_model(topo)
|
||||
app = make_ndo_app(state)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def token(client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"userName": "admin", "userPasswd": "cisco123", "domain": "local"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "token" in data, f"Login response missing token: {data}"
|
||||
return data["token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth + platform version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_login(client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"userName": "admin", "userPasswd": "cisco123", "domain": "local"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "token" in data
|
||||
assert data["token"], "token must be non-empty"
|
||||
|
||||
|
||||
def test_platform_version(client, token):
|
||||
resp = client.get(
|
||||
"/api/v1/platform/version",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["version"] == "4.2.2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sites
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_sites_matches_topology(client, topo, token):
|
||||
"""PR-10: NDO site names must be the fabric name (e.g. "LAB1-IT-ACI"),
|
||||
not the short topology-internal site name ("LAB1") — verified against a
|
||||
real-gear cisco.mso.mso_tenant run where only the fabric-name form was
|
||||
accepted (see docs/CONTRACT.md §7).
|
||||
"""
|
||||
resp = client.get(
|
||||
"/mso/api/v1/sites",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
sites = resp.json()["sites"]
|
||||
|
||||
assert len(sites) == len(topo.sites)
|
||||
site_names = {s["name"] for s in sites}
|
||||
for topo_site in topo.sites:
|
||||
assert (topo_site.fabric_name or topo_site.name) in site_names
|
||||
|
||||
for s in sites:
|
||||
assert "id" in s
|
||||
assert s["platform"] == "APIC"
|
||||
assert isinstance(s["urls"], list) and len(s["urls"]) > 0
|
||||
# URL must embed the site's apic_host
|
||||
topo_site = next(
|
||||
ts for ts in topo.sites if (ts.fabric_name or ts.name) == s["name"]
|
||||
)
|
||||
assert topo_site.apic_host in s["urls"][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tenants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_tenants_matches_topology(client, topo, token):
|
||||
resp = client.get(
|
||||
"/mso/api/v1/tenants",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
tenants = resp.json()["tenants"]
|
||||
|
||||
assert len(tenants) == len(topo.tenants)
|
||||
ndo_names = {t["name"] for t in tenants}
|
||||
for tt in topo.tenants:
|
||||
assert tt.name in ndo_names
|
||||
|
||||
for t in tenants:
|
||||
assert "id" in t
|
||||
assert "siteAssociations" in t
|
||||
assert isinstance(t["siteAssociations"], list)
|
||||
|
||||
|
||||
def test_tenant_site_associations(client, topo, token):
|
||||
"""Stretched tenant must be associated with the correct site IDs."""
|
||||
sites_resp = client.get("/mso/api/v1/sites",
|
||||
headers={"Authorization": f"Bearer {token}"})
|
||||
# PR-10: /mso/api/v1/sites now reports fabric names, not the topology's
|
||||
# short site names — join by fabric_name (falling back to the short
|
||||
# name) to map short-name (as used in tenant.sites) → site id.
|
||||
fabric_to_short = {
|
||||
(ts.fabric_name or ts.name): ts.name for ts in topo.sites
|
||||
}
|
||||
site_id_map = {
|
||||
fabric_to_short.get(s["name"], s["name"]): s["id"]
|
||||
for s in sites_resp.json()["sites"]
|
||||
}
|
||||
|
||||
tenants_resp = client.get("/mso/api/v1/tenants",
|
||||
headers={"Authorization": f"Bearer {token}"})
|
||||
ndo_tenants = {t["name"]: t for t in tenants_resp.json()["tenants"]}
|
||||
|
||||
for tt in topo.tenants:
|
||||
ndo_t = ndo_tenants[tt.name]
|
||||
ndo_site_ids = {sa["siteId"] for sa in ndo_t["siteAssociations"]}
|
||||
expected_ids = {site_id_map[s] for s in tt.sites if s in site_id_map}
|
||||
assert ndo_site_ids == expected_ids, (
|
||||
f"Tenant {tt.name}: site-id mismatch ndo={ndo_site_ids} "
|
||||
f"expected={expected_ids}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_schemas_list(client, topo, token):
|
||||
resp = client.get(
|
||||
"/mso/api/v1/schemas",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
schemas = resp.json()["schemas"]
|
||||
|
||||
# At least one schema per tenant
|
||||
assert len(schemas) >= len(topo.tenants)
|
||||
|
||||
for s in schemas:
|
||||
assert "id" in s
|
||||
assert "displayName" in s
|
||||
assert "name" in s
|
||||
assert isinstance(s["templates"], list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema detail — cross-plane consistency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_corp_schema_detail(client, token):
|
||||
"""Helper: fetch the MS-TN1 schema detail (stretched tenant)."""
|
||||
schemas = client.get("/mso/api/v1/schemas",
|
||||
headers={"Authorization": f"Bearer {token}"}).json()["schemas"]
|
||||
corp_summary = next(s for s in schemas if "MS-TN1" in s["name"])
|
||||
resp = client.get(
|
||||
f"/mso/api/v1/schemas/{corp_summary['id']}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return resp.json(), corp_summary["id"]
|
||||
|
||||
|
||||
def test_schema_detail_vrf_names_match_topology(client, topo, token):
|
||||
detail, _ = _get_corp_schema_detail(client, token)
|
||||
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
|
||||
|
||||
assert len(detail["templates"]) >= 1
|
||||
tmpl = detail["templates"][0]
|
||||
|
||||
topo_vrf_names = {v.name for v in corp_tenant.vrfs}
|
||||
ndo_vrf_names = {v["name"] for v in tmpl["vrfs"]}
|
||||
assert topo_vrf_names == ndo_vrf_names, (
|
||||
f"VRF name mismatch: topo={topo_vrf_names} ndo={ndo_vrf_names}"
|
||||
)
|
||||
|
||||
|
||||
def test_schema_detail_bd_names_match_topology(client, topo, token):
|
||||
detail, _ = _get_corp_schema_detail(client, token)
|
||||
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
|
||||
tmpl = detail["templates"][0]
|
||||
|
||||
topo_bd_names = {b.name for b in corp_tenant.bds}
|
||||
ndo_bd_names = {b["name"] for b in tmpl["bds"]}
|
||||
assert topo_bd_names == ndo_bd_names, (
|
||||
f"BD name mismatch: topo={topo_bd_names} ndo={ndo_bd_names}"
|
||||
)
|
||||
|
||||
|
||||
def test_schema_detail_bd_vrf_refs(client, topo, token):
|
||||
"""Each BD's vrfRef must end with the correct VRF name."""
|
||||
detail, _ = _get_corp_schema_detail(client, token)
|
||||
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
|
||||
tmpl = detail["templates"][0]
|
||||
|
||||
topo_bd_vrf = {b.name: b.vrf for b in corp_tenant.bds}
|
||||
for bd in tmpl["bds"]:
|
||||
expected_vrf = topo_bd_vrf[bd["name"]]
|
||||
assert bd["vrfRef"].endswith(f"/vrfs/{expected_vrf}"), (
|
||||
f"BD {bd['name']}: vrfRef {bd['vrfRef']!r} "
|
||||
f"should end /vrfs/{expected_vrf}"
|
||||
)
|
||||
assert bd["vrfRef"].startswith("/vrfs/")
|
||||
|
||||
|
||||
def test_schema_detail_epg_names_match_topology(client, topo, token):
|
||||
detail, _ = _get_corp_schema_detail(client, token)
|
||||
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
|
||||
tmpl = detail["templates"][0]
|
||||
|
||||
topo_epg_names: set[str] = {
|
||||
epg.name for ap in corp_tenant.aps for epg in ap.epgs
|
||||
}
|
||||
ndo_epg_names: set[str] = {
|
||||
epg["name"]
|
||||
for anp in tmpl["anps"]
|
||||
for epg in anp["epgs"]
|
||||
}
|
||||
assert topo_epg_names == ndo_epg_names, (
|
||||
f"EPG name mismatch: topo={topo_epg_names} ndo={ndo_epg_names}"
|
||||
)
|
||||
|
||||
|
||||
def test_schema_detail_contract_names_match_topology(client, topo, token):
|
||||
detail, _ = _get_corp_schema_detail(client, token)
|
||||
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
|
||||
tmpl = detail["templates"][0]
|
||||
|
||||
topo_contract_names = {c.name for c in corp_tenant.contracts}
|
||||
ndo_contract_names = {c["name"] for c in tmpl["contracts"]}
|
||||
assert topo_contract_names == ndo_contract_names
|
||||
|
||||
|
||||
def test_schema_detail_site_associations_match_topology(client, topo, token):
|
||||
"""Stretched schema sites must exactly match the tenant's site IDs."""
|
||||
detail, _ = _get_corp_schema_detail(client, token)
|
||||
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
|
||||
|
||||
sites_resp = client.get("/mso/api/v1/sites",
|
||||
headers={"Authorization": f"Bearer {token}"})
|
||||
# PR-10: join by fabric_name since /mso/api/v1/sites now reports fabric
|
||||
# names — see test_tenant_site_associations above for the same pattern.
|
||||
fabric_to_short = {
|
||||
(ts.fabric_name or ts.name): ts.name for ts in topo.sites
|
||||
}
|
||||
site_id_map = {
|
||||
fabric_to_short.get(s["name"], s["name"]): s["id"]
|
||||
for s in sites_resp.json()["sites"]
|
||||
}
|
||||
|
||||
expected_site_ids = {site_id_map[s] for s in corp_tenant.sites}
|
||||
schema_site_ids = {sa["siteId"] for sa in detail.get("sites", [])}
|
||||
assert expected_site_ids == schema_site_ids, (
|
||||
f"Schema site-id mismatch: expected={expected_site_ids} "
|
||||
f"got={schema_site_ids}"
|
||||
)
|
||||
|
||||
|
||||
def test_schema_detail_epg_bd_refs(client, topo, token):
|
||||
"""Each EPG's bdRef must start /bds/ and match a real BD name."""
|
||||
detail, _ = _get_corp_schema_detail(client, token)
|
||||
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
|
||||
tmpl = detail["templates"][0]
|
||||
topo_bd_names = {b.name for b in corp_tenant.bds}
|
||||
|
||||
for anp in tmpl["anps"]:
|
||||
for epg in anp["epgs"]:
|
||||
bd_ref = epg["bdRef"]
|
||||
assert bd_ref.startswith("/bds/"), (
|
||||
f"EPG {epg['name']} bdRef {bd_ref!r} must start /bds/"
|
||||
)
|
||||
bd_name = bd_ref.split("/")[-1]
|
||||
assert bd_name in topo_bd_names, (
|
||||
f"EPG {epg['name']} bdRef references unknown BD {bd_name!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_schema_detail_contract_filter_refs(client, topo, token):
|
||||
"""Each contract's filterRelationships must reference real filter names."""
|
||||
detail, _ = _get_corp_schema_detail(client, token)
|
||||
tmpl = detail["templates"][0]
|
||||
filter_names = {f["name"] for f in tmpl["filters"]}
|
||||
|
||||
for c in tmpl["contracts"]:
|
||||
for fr in c.get("filterRelationships", []):
|
||||
f_ref = fr["filterRef"]
|
||||
assert f_ref.startswith("/filters/"), (
|
||||
f"Contract {c['name']} filterRef {f_ref!r} must start /filters/"
|
||||
)
|
||||
f_name = f_ref.split("/")[-1]
|
||||
assert f_name in filter_names, (
|
||||
f"Contract {c['name']} references unknown filter {f_name!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_schema_detail_external_epgs(client, topo, token):
|
||||
"""External EPGs must have l3outRef, vrfRef, and subnets."""
|
||||
detail, _ = _get_corp_schema_detail(client, token)
|
||||
tmpl = detail["templates"][0]
|
||||
|
||||
# Corp has two l3outs each with one ext_epg → expect 2 externalEpgs
|
||||
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
|
||||
expected_count = sum(
|
||||
len(l3out.ext_epgs) for l3out in corp_tenant.l3outs
|
||||
)
|
||||
assert len(tmpl["externalEpgs"]) == expected_count
|
||||
|
||||
for xepg in tmpl["externalEpgs"]:
|
||||
assert xepg["vrfRef"].startswith("/vrfs/")
|
||||
assert xepg["l3outRef"].startswith("/l3outs/")
|
||||
assert isinstance(xepg["subnets"], list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fabric connectivity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fabric_connectivity(client, topo, token):
|
||||
resp = client.get(
|
||||
"/mso/api/v1/sites/fabric-connectivity",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
# Accept either a list or {"sites": [...]}
|
||||
fc_sites = (
|
||||
data
|
||||
if isinstance(data, list)
|
||||
else data.get("sites", data.get("fabricConnectivity", []))
|
||||
)
|
||||
assert len(fc_sites) == len(topo.sites), (
|
||||
f"Expected {len(topo.sites)} sites in fabric-connectivity, got {len(fc_sites)}"
|
||||
)
|
||||
for fc in fc_sites:
|
||||
# At least one status field must be present
|
||||
has_status = fc.get("status") or fc.get("connectivityStatus")
|
||||
assert has_status, f"fabric-connectivity entry missing status: {fc}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Policy states
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_policy_states_per_schema(client, token):
|
||||
schemas = client.get("/mso/api/v1/schemas",
|
||||
headers={"Authorization": f"Bearer {token}"}).json()["schemas"]
|
||||
|
||||
for schema_summary in schemas:
|
||||
schema_id = schema_summary["id"]
|
||||
resp = client.get(
|
||||
f"/mso/api/v1/schemas/{schema_id}/policy-states",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
states = (
|
||||
data
|
||||
if isinstance(data, list)
|
||||
else data.get("policyStates", data.get("states", []))
|
||||
)
|
||||
assert len(states) > 0, (
|
||||
f"Schema {schema_id} returned empty policy-states"
|
||||
)
|
||||
for state in states:
|
||||
assert "status" in state, f"policy-state entry missing 'status': {state}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template summaries + tenantPolicy DHCP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_template_summaries(client, token):
|
||||
resp = client.get(
|
||||
"/mso/api/v1/templates/summaries",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
# Normalise: handle both list and {"templates": [...]}
|
||||
summaries = data if isinstance(data, list) else data.get("templates", [])
|
||||
assert len(summaries) > 0
|
||||
|
||||
types = [s.get("templateType") for s in summaries]
|
||||
assert "tenantPolicy" in types, (
|
||||
f"No tenantPolicy entry in template summaries: {types}"
|
||||
)
|
||||
for s in summaries:
|
||||
assert "templateId" in s
|
||||
assert "templateType" in s
|
||||
|
||||
|
||||
def test_tenant_policy_template_dhcp_relay(client, token):
|
||||
resp = client.get(
|
||||
f"/mso/api/v1/templates/{TENANT_POLICY_TEMPLATE_ID}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
inner = (
|
||||
resp.json()
|
||||
.get("tenantPolicyTemplate", {})
|
||||
.get("template", {})
|
||||
)
|
||||
relay = inner.get("dhcpRelayPolicies", [])
|
||||
assert len(relay) > 0, "tenantPolicy template must have dhcpRelayPolicies"
|
||||
for p in relay:
|
||||
assert "uuid" in p
|
||||
assert "name" in p
|
||||
assert relay[0]["uuid"] == DHCP_RELAY_UUID
|
||||
|
||||
|
||||
def test_bd_dhcp_labels_resolve_via_tenant_policy(client, token):
|
||||
"""Every BD dhcpLabel ref must resolve in the tenantPolicy DHCP map."""
|
||||
# Build uuid→name map exactly as ndo_connector.get_dhcp_policy_map() does
|
||||
summaries_resp = client.get(
|
||||
"/mso/api/v1/templates/summaries",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
summaries = summaries_resp.json()
|
||||
if isinstance(summaries, dict):
|
||||
summaries = summaries.get("templates", [])
|
||||
|
||||
dhcp_map: dict[str, str] = {}
|
||||
for s in summaries:
|
||||
if s.get("templateType") != "tenantPolicy":
|
||||
continue
|
||||
tmpl_resp = client.get(
|
||||
f"/mso/api/v1/templates/{s['templateId']}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
inner = (
|
||||
tmpl_resp.json()
|
||||
.get("tenantPolicyTemplate", {})
|
||||
.get("template", {})
|
||||
)
|
||||
for p in inner.get("dhcpRelayPolicies", []):
|
||||
if p.get("uuid") and p.get("name"):
|
||||
dhcp_map[p["uuid"]] = p["name"]
|
||||
for p in inner.get("dhcpOptionPolicies", []):
|
||||
if p.get("uuid") and p.get("name"):
|
||||
dhcp_map[p["uuid"]] = p["name"]
|
||||
|
||||
assert dhcp_map, "DHCP policy map must not be empty"
|
||||
|
||||
# Verify every BD dhcpLabel ref is resolvable
|
||||
schemas = client.get("/mso/api/v1/schemas",
|
||||
headers={"Authorization": f"Bearer {token}"}).json()["schemas"]
|
||||
found_any_label = False
|
||||
for s in schemas:
|
||||
detail = client.get(
|
||||
f"/mso/api/v1/schemas/{s['id']}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
).json()
|
||||
for tmpl in detail.get("templates", []):
|
||||
for bd in tmpl.get("bds", []):
|
||||
for lbl in bd.get("dhcpLabels", []):
|
||||
ref = lbl.get("ref", "")
|
||||
assert ref in dhcp_map, (
|
||||
f"BD {bd['name']} dhcpLabel ref {ref!r} "
|
||||
f"not in DHCP policy map {list(dhcp_map)}"
|
||||
)
|
||||
found_any_label = True
|
||||
|
||||
assert found_any_label, "No BD with dhcpLabels found — test is vacuous"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit records (both endpoints)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_audit_records_nonempty_primary(client, token):
|
||||
resp = client.get(
|
||||
"/api/v1/audit-records?count=50",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
records = (
|
||||
data
|
||||
if isinstance(data, list)
|
||||
else data.get("auditRecords", data.get("records", []))
|
||||
)
|
||||
assert len(records) > 0, "Primary audit-records endpoint returned empty list"
|
||||
|
||||
|
||||
def test_audit_records_fallback_endpoint(client, token):
|
||||
resp = client.get(
|
||||
"/mso/api/v1/audit-records?count=20",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
records = (
|
||||
data
|
||||
if isinstance(data, list)
|
||||
else data.get("auditRecords", data.get("records", []))
|
||||
)
|
||||
assert len(records) > 0
|
||||
|
||||
|
||||
def test_audit_record_fields(client, token):
|
||||
resp = client.get("/api/v1/audit-records",
|
||||
headers={"Authorization": f"Bearer {token}"})
|
||||
records = resp.json().get("auditRecords", [])
|
||||
for rec in records:
|
||||
# ndo_audit_log reads these fields; all must be present
|
||||
assert "timestamp" in rec or "date" in rec, f"No timestamp in {rec}"
|
||||
assert "user" in rec or "userName" in rec, f"No user in {rec}"
|
||||
assert "action" in rec or "type" in rec, f"No action in {rec}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST schemas → write + read-back
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_schema_and_readback(client, token):
|
||||
"""POST a new schema and verify it appears in the schema list."""
|
||||
new_schema = {
|
||||
"displayName": "test-post-schema",
|
||||
"name": "test-post-schema",
|
||||
"templates": [
|
||||
{
|
||||
"name": "test-tmpl",
|
||||
"templateType": "application",
|
||||
"vrfs": [],
|
||||
"bds": [],
|
||||
"anps": [],
|
||||
"contracts": [],
|
||||
"filters": [],
|
||||
"externalEpgs": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Create
|
||||
resp = client.post(
|
||||
"/mso/api/v1/schemas",
|
||||
json=new_schema,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
created = resp.json()
|
||||
assert "id" in created, f"POST /schemas response missing id: {created}"
|
||||
schema_id = created["id"]
|
||||
assert created.get("status") == "active"
|
||||
|
||||
# Read back from list
|
||||
list_resp = client.get("/mso/api/v1/schemas",
|
||||
headers={"Authorization": f"Bearer {token}"})
|
||||
ids_in_list = {s["id"] for s in list_resp.json()["schemas"]}
|
||||
assert schema_id in ids_in_list, (
|
||||
f"New schema {schema_id!r} not in GET /schemas after POST"
|
||||
)
|
||||
|
||||
# Read back full detail
|
||||
detail_resp = client.get(
|
||||
f"/mso/api/v1/schemas/{schema_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert detail_resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST deploy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_deploy(client, token):
|
||||
resp = client.post(
|
||||
"/mso/api/v1/deploy",
|
||||
json={"schemaId": "schema-123", "templateName": "tmpl"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "id" in data
|
||||
assert data["status"] == "in-progress"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth leniency — no bearer token still works
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_auth_lenient_no_token(client):
|
||||
"""Endpoints must respond even without a Bearer token (lenient auth)."""
|
||||
assert client.get("/mso/api/v1/sites").status_code == 200
|
||||
assert client.get("/mso/api/v1/tenants").status_code == 200
|
||||
assert client.get("/mso/api/v1/schemas").status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unknown_schema_returns_404(client, token):
|
||||
resp = client.get(
|
||||
"/mso/api/v1/schemas/does-not-exist-xyzxyz",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_unknown_template_returns_404(client, token):
|
||||
resp = client.get(
|
||||
"/mso/api/v1/templates/no-such-template-id",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user