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,273 @@
|
||||
"""Regression tests for PR-4 auth fidelity.
|
||||
|
||||
Covers: unauthenticated/garbage/expired-cookie query rejection, credential
|
||||
checking on aaaLogin, session expiry + aaaRefresh renewal, aaaLogout
|
||||
invalidation, and the 422-leak fix for malformed aaaLogin bodies. See
|
||||
docs/CONTRACT.md §1 (login) and §5 (error shapes) for the contract this
|
||||
enforces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import aci_sim.rest_aci.auth as auth_module
|
||||
from aci_sim.build.orchestrator import build_site
|
||||
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
|
||||
from aci_sim.topology.loader import load_topology
|
||||
|
||||
TOPO_PATH = "topology.yaml"
|
||||
|
||||
|
||||
def _new_client() -> TestClient:
|
||||
"""Fresh, unauthenticated TestClient backed by its own store/state."""
|
||||
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 _login(client: TestClient, *, user: str = "admin", pwd: str = "cisco"):
|
||||
return client.post(
|
||||
"/api/aaaLogin.json",
|
||||
json={"aaaUser": {"attributes": {"name": user, "pwd": pwd}}},
|
||||
)
|
||||
|
||||
|
||||
def _assert_apic_error_envelope(resp, code: str) -> None:
|
||||
data = resp.json()
|
||||
assert "detail" not in data, f"FastAPI detail leak: {data}"
|
||||
assert "imdata" in data
|
||||
assert data["imdata"][0]["error"]["attributes"]["code"] == code
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No-cookie / garbage-cookie / expired-cookie query rejection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_cookie_query_returns_403():
|
||||
c = _new_client()
|
||||
resp = c.get("/api/class/fabricNode.json")
|
||||
assert resp.status_code == 403
|
||||
_assert_apic_error_envelope(resp, "403")
|
||||
|
||||
|
||||
def test_garbage_cookie_query_returns_403():
|
||||
c = _new_client()
|
||||
c.cookies.set("APIC-cookie", "not-a-real-token")
|
||||
resp = c.get("/api/class/fabricNode.json")
|
||||
assert resp.status_code == 403
|
||||
_assert_apic_error_envelope(resp, "403")
|
||||
|
||||
|
||||
def test_expired_token_query_returns_403():
|
||||
c = _new_client()
|
||||
login = _login(c)
|
||||
assert login.status_code == 200
|
||||
token = c.cookies.get("APIC-cookie")
|
||||
assert token in auth_module._sessions
|
||||
|
||||
# Force expiry without sleeping — patch the session's expires_at directly.
|
||||
auth_module._sessions[token].expires_at = auth_module._now() - 1
|
||||
|
||||
resp = c.get("/api/class/fabricNode.json")
|
||||
assert resp.status_code == 403
|
||||
_assert_apic_error_envelope(resp, "403")
|
||||
|
||||
|
||||
def test_no_cookie_mo_query_returns_403():
|
||||
c = _new_client()
|
||||
resp = c.get("/api/mo/uni/tn-MS-TN1.json")
|
||||
assert resp.status_code == 403
|
||||
_assert_apic_error_envelope(resp, "403")
|
||||
|
||||
|
||||
def test_no_cookie_node_scoped_query_returns_403():
|
||||
c = _new_client()
|
||||
resp = c.get("/api/node/class/topology/pod-1/node-101/faultInst.json")
|
||||
assert resp.status_code == 403
|
||||
_assert_apic_error_envelope(resp, "403")
|
||||
|
||||
|
||||
def test_no_cookie_write_returns_403():
|
||||
c = _new_client()
|
||||
resp = c.post(
|
||||
"/api/mo/uni/tn-Blocked.json",
|
||||
json={"fvTenant": {"attributes": {"name": "Blocked", "dn": "uni/tn-Blocked"}}},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
_assert_apic_error_envelope(resp, "403")
|
||||
# And the write must not have landed.
|
||||
c2 = _new_client()
|
||||
_login(c2)
|
||||
names = [
|
||||
i["fvTenant"]["attributes"]["name"]
|
||||
for i in c2.get("/api/class/fvTenant.json").json()["imdata"]
|
||||
]
|
||||
assert "Blocked" not in names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential checking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wrong_password_returns_401_envelope():
|
||||
c = _new_client()
|
||||
resp = _login(c, pwd="wrong-password")
|
||||
assert resp.status_code == 401
|
||||
_assert_apic_error_envelope(resp, "401")
|
||||
assert "APIC-cookie" not in resp.cookies
|
||||
|
||||
|
||||
def test_wrong_username_returns_401_envelope():
|
||||
c = _new_client()
|
||||
resp = _login(c, user="notadmin")
|
||||
assert resp.status_code == 401
|
||||
_assert_apic_error_envelope(resp, "401")
|
||||
|
||||
|
||||
def test_correct_credentials_return_200_with_token():
|
||||
c = _new_client()
|
||||
resp = _login(c)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
token = data["imdata"][0]["aaaLogin"]["attributes"]["token"]
|
||||
assert token
|
||||
assert "APIC-cookie" in resp.cookies
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Malformed aaaLogin body -> 400 imdata envelope (never FastAPI {"detail":...})
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_malformed_login_body_non_dict_top_level():
|
||||
c = _new_client()
|
||||
resp = c.post("/api/aaaLogin.json", content=b'"just a string"',
|
||||
headers={"Content-Type": "application/json"})
|
||||
assert resp.status_code == 400
|
||||
data = resp.json()
|
||||
assert "imdata" in data
|
||||
assert "detail" not in data
|
||||
|
||||
|
||||
def test_malformed_login_body_missing_aaauser():
|
||||
c = _new_client()
|
||||
resp = c.post("/api/aaaLogin.json", json={"wrongKey": {}})
|
||||
assert resp.status_code == 400
|
||||
data = resp.json()
|
||||
assert "imdata" in data
|
||||
assert "detail" not in data
|
||||
|
||||
|
||||
def test_malformed_login_body_non_json():
|
||||
c = _new_client()
|
||||
resp = c.post(
|
||||
"/api/aaaLogin.json",
|
||||
content=b"not json at all {{{",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
data = resp.json()
|
||||
assert "imdata" in data
|
||||
assert "detail" not in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# aaaRefresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_refresh_without_login_returns_403():
|
||||
c = _new_client()
|
||||
resp = c.get("/api/aaaRefresh.json")
|
||||
assert resp.status_code == 403
|
||||
_assert_apic_error_envelope(resp, "403")
|
||||
|
||||
|
||||
def test_refresh_with_garbage_cookie_returns_403():
|
||||
c = _new_client()
|
||||
c.cookies.set("APIC-cookie", "garbage")
|
||||
resp = c.get("/api/aaaRefresh.json")
|
||||
assert resp.status_code == 403
|
||||
_assert_apic_error_envelope(resp, "403")
|
||||
|
||||
|
||||
def test_refresh_extends_expiry():
|
||||
c = _new_client()
|
||||
_login(c)
|
||||
token = c.cookies.get("APIC-cookie")
|
||||
sess = auth_module._sessions[token]
|
||||
|
||||
# Move expiry to the near future (still valid) and confirm refresh pushes
|
||||
# it back out to a full lifetime.
|
||||
sess.expires_at = auth_module._now() + 1
|
||||
old_expiry = sess.expires_at
|
||||
|
||||
resp = c.get("/api/aaaRefresh.json")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "aaaLogin" in data["imdata"][0]
|
||||
|
||||
new_token = c.cookies.get("APIC-cookie")
|
||||
assert new_token in auth_module._sessions
|
||||
assert auth_module._sessions[new_token].expires_at > old_expiry
|
||||
|
||||
# And a subsequent query with the refreshed cookie still works.
|
||||
q = c.get("/api/class/fabricNode.json")
|
||||
assert q.status_code == 200
|
||||
|
||||
|
||||
def test_refresh_on_expired_token_returns_403():
|
||||
c = _new_client()
|
||||
_login(c)
|
||||
token = c.cookies.get("APIC-cookie")
|
||||
auth_module._sessions[token].expires_at = auth_module._now() - 1
|
||||
|
||||
resp = c.get("/api/aaaRefresh.json")
|
||||
assert resp.status_code == 403
|
||||
_assert_apic_error_envelope(resp, "403")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# aaaLogout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_logout_then_query_returns_403():
|
||||
c = _new_client()
|
||||
_login(c)
|
||||
token = c.cookies.get("APIC-cookie")
|
||||
assert token in auth_module._sessions
|
||||
|
||||
resp = c.post("/api/aaaLogout.json")
|
||||
assert resp.status_code == 200
|
||||
assert token not in auth_module._sessions
|
||||
|
||||
q = c.get("/api/class/fabricNode.json")
|
||||
assert q.status_code == 403
|
||||
_assert_apic_error_envelope(q, "403")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /_sim control plane must remain unauthenticated
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sim_reset_works_without_auth():
|
||||
c = _new_client()
|
||||
resp = c.post("/_sim/reset")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
Reference in New Issue
Block a user