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,372 @@
|
||||
"""Tests for the Admin account wizard step + `aci-sim run` credential enforcement.
|
||||
|
||||
Covers:
|
||||
1. `run_wizard`/`cmd_init` write a topology `auth:` section (custom
|
||||
username/password, and the `ndo_same_account=no` split-account path),
|
||||
round-tripping through `Topology.model_validate`/`load_topology`.
|
||||
2. Defaults (no answers / non-tty `--defaults`) still produce a valid
|
||||
topology with `auth: {username: admin, password: cisco}`.
|
||||
3. `resolve_admin_credentials` — the pure credential-resolution function
|
||||
`cmd_run` uses — is unit-tested directly for all three precedence cases:
|
||||
topology-only, env-override-wins, neither-present.
|
||||
4. `build_run_env_argv` injects SIM_USERNAME/SIM_PASSWORD only when both
|
||||
admin_username/admin_password are given, and never touches the
|
||||
filesystem (pure function, per its own docstring contract).
|
||||
5. End-to-end enforcement: a FastAPI APIC app backed by `auth.py`'s real
|
||||
`aaaLogin` route 401s on the wrong password and 200s on the right one,
|
||||
for a NON-default (custom) SIM_USERNAME/SIM_PASSWORD pair — proving the
|
||||
enforcement is real, not just resolved-and-ignored. Since auth.py binds
|
||||
SIM_USERNAME/SIM_PASSWORD as module-level constants at import time, this
|
||||
test monkeypatches the module's attributes directly (the aaaLogin route
|
||||
closure reads them as module globals at request time, not at def time —
|
||||
verified by this file's own tests) rather than fighting import-time
|
||||
binding with `importlib.reload`.
|
||||
6. Backward compat: an existing topology.yaml with NO `auth:` section still
|
||||
loads (`topo.auth is None`) and `resolve_admin_credentials`/
|
||||
`build_run_env_argv` inject nothing — `rest_aci/auth.py` keeps its own
|
||||
env-or-admin/cisco default, unchanged.
|
||||
|
||||
Does NOT weaken any pre-existing auth test — this file only adds new
|
||||
coverage; `tests/test_pr9_ansible.py`'s cert-auth/aaaLogin tests are
|
||||
untouched.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from aci_sim.build.orchestrator import build_site
|
||||
from aci_sim.cli import build_run_env_argv, resolve_admin_credentials, run_wizard
|
||||
from aci_sim.rest_aci import auth as auth_module
|
||||
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
|
||||
from aci_sim.topology.loader import load_topology
|
||||
from aci_sim.topology.schema import Auth, Topology
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1 + 2. Wizard writes an `auth:` section (defaults + custom + split NDO).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWizardAuthSection:
|
||||
def test_defaults_produce_admin_cisco_auth(self) -> None:
|
||||
out = io.StringIO()
|
||||
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=True)
|
||||
assert topo["auth"] == {"username": "admin", "password": "cisco"}
|
||||
validated = Topology.model_validate(topo)
|
||||
assert validated.auth == Auth(username="admin", password="cisco")
|
||||
|
||||
def test_custom_admin_credentials_via_answers(self) -> None:
|
||||
answers = {
|
||||
"admin_username": "netops",
|
||||
"admin_password": "s3cr3t!",
|
||||
"deployment_type": "1",
|
||||
}
|
||||
out = io.StringIO()
|
||||
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
|
||||
assert topo["auth"] == {"username": "netops", "password": "s3cr3t!"}
|
||||
|
||||
validated = Topology.model_validate(topo)
|
||||
assert validated.auth.username == "netops"
|
||||
assert validated.auth.password == "s3cr3t!"
|
||||
assert validated.auth.ndo_username is None
|
||||
assert validated.auth.ndo_password is None
|
||||
|
||||
def test_custom_admin_credentials_round_trip_through_yaml(self, tmp_path: Path) -> None:
|
||||
answers = {"admin_username": "netops", "admin_password": "s3cr3t!", "deployment_type": "1"}
|
||||
out = io.StringIO()
|
||||
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
|
||||
|
||||
out_file = tmp_path / "auth.topology.yaml"
|
||||
out_file.write_text(yaml.dump(topo, sort_keys=False), encoding="utf-8")
|
||||
loaded = load_topology(out_file)
|
||||
assert loaded.auth.username == "netops"
|
||||
assert loaded.auth.password == "s3cr3t!"
|
||||
|
||||
def test_ndo_same_account_no_stores_split_ndo_credentials(self) -> None:
|
||||
answers = {
|
||||
"deployment_type": "1",
|
||||
"admin_username": "apicadmin",
|
||||
"admin_password": "apicpass",
|
||||
"ndo_same_account": "no",
|
||||
"ndo_username": "ndoadmin",
|
||||
"ndo_password": "ndopass",
|
||||
}
|
||||
out = io.StringIO()
|
||||
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
|
||||
assert topo["auth"] == {
|
||||
"username": "apicadmin",
|
||||
"password": "apicpass",
|
||||
"ndo_username": "ndoadmin",
|
||||
"ndo_password": "ndopass",
|
||||
}
|
||||
validated = Topology.model_validate(topo)
|
||||
assert validated.auth.ndo_username == "ndoadmin"
|
||||
assert validated.auth.ndo_password == "ndopass"
|
||||
|
||||
def test_ndo_same_account_no_defaults_ndo_fields_to_admin_account(self) -> None:
|
||||
"""ndo_same_account=no but ndo_username/ndo_password not separately
|
||||
answered -> the prompt defaults (admin_username/admin_password) are used."""
|
||||
answers = {"deployment_type": "1", "ndo_same_account": "no"}
|
||||
out = io.StringIO()
|
||||
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
|
||||
assert topo["auth"]["ndo_username"] == "admin"
|
||||
assert topo["auth"]["ndo_password"] == "cisco"
|
||||
|
||||
def test_scripted_stdin_admin_account_step(self) -> None:
|
||||
stdin_script = (
|
||||
"svcaccount\n" # admin username
|
||||
"hunter2\n" # admin password
|
||||
"no\n" # ndo_same_account -> no
|
||||
"ndosvc\n" # ndo username
|
||||
"ndopass\n" # ndo password
|
||||
"1\n" # deployment type -> single fabric
|
||||
)
|
||||
out = io.StringIO()
|
||||
topo, _gw = run_wizard(stream_in=io.StringIO(stdin_script), stream_out=out, accept_defaults=False)
|
||||
assert topo["auth"] == {
|
||||
"username": "svcaccount",
|
||||
"password": "hunter2",
|
||||
"ndo_username": "ndosvc",
|
||||
"ndo_password": "ndopass",
|
||||
}
|
||||
|
||||
def test_summary_shows_username_but_masks_password(self) -> None:
|
||||
answers = {"deployment_type": "1", "admin_username": "netops", "admin_password": "supersecret"}
|
||||
out = io.StringIO()
|
||||
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
|
||||
|
||||
from aci_sim.cli import _print_summary
|
||||
|
||||
summary_out = io.StringIO()
|
||||
_print_summary(topo, _gw, summary_out)
|
||||
text = summary_out.getvalue()
|
||||
assert "netops" in text
|
||||
assert "supersecret" not in text
|
||||
assert "Admin account" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. resolve_admin_credentials — the pure precedence function cmd_run uses.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveAdminCredentialsPrecedence:
|
||||
def test_topology_auth_present_no_env_override(self) -> None:
|
||||
auth = Auth(username="netops", password="s3cr3t!")
|
||||
username, password, source = resolve_admin_credentials({}, auth)
|
||||
assert (username, password, source) == ("netops", "s3cr3t!", "topology.yaml")
|
||||
|
||||
def test_env_override_wins_even_with_topology_auth_present(self) -> None:
|
||||
auth = Auth(username="netops", password="s3cr3t!")
|
||||
base_env = {"SIM_USERNAME": "envuser", "SIM_PASSWORD": "envpass"}
|
||||
username, password, source = resolve_admin_credentials(base_env, auth)
|
||||
assert (username, password, source) == (None, None, "env override")
|
||||
|
||||
def test_neither_present_injects_nothing(self) -> None:
|
||||
username, password, source = resolve_admin_credentials({}, None)
|
||||
assert (username, password, source) == (None, None, "built-in default")
|
||||
|
||||
def test_env_override_wins_even_without_topology_auth(self) -> None:
|
||||
base_env = {"SIM_USERNAME": "envuser"}
|
||||
username, password, source = resolve_admin_credentials(base_env, None)
|
||||
assert (username, password, source) == (None, None, "env override")
|
||||
|
||||
def test_empty_string_sim_username_in_env_does_not_count_as_override(self) -> None:
|
||||
"""An empty-string SIM_USERNAME (e.g. `SIM_USERNAME= aci-sim run`) is
|
||||
falsy — falls through to topology.yaml/default, matching os.environ's
|
||||
own treatment of unset-vs-empty as not meaningfully "set"."""
|
||||
auth = Auth(username="netops", password="s3cr3t!")
|
||||
username, password, source = resolve_admin_credentials({"SIM_USERNAME": ""}, auth)
|
||||
assert (username, password, source) == ("netops", "s3cr3t!", "topology.yaml")
|
||||
|
||||
def test_sim_password_only_in_env_is_an_atomic_override(self) -> None:
|
||||
"""Regression (reviewer Finding 1): a caller who sets ONLY SIM_PASSWORD
|
||||
(rotating just the password, keeping the default username) must NOT have
|
||||
it silently discarded in favor of the topology password. Setting EITHER
|
||||
credential var counts as an env override, so topology.auth is ignored
|
||||
entirely — the override is atomic, the caller owns both vars."""
|
||||
auth = Auth(username="topo_user", password="topo_pass")
|
||||
username, password, source = resolve_admin_credentials({"SIM_PASSWORD": "caller_pw"}, auth)
|
||||
assert (username, password, source) == (None, None, "env override")
|
||||
|
||||
def test_sim_password_only_no_topology_is_env_override(self) -> None:
|
||||
username, password, source = resolve_admin_credentials({"SIM_PASSWORD": "caller_pw"}, None)
|
||||
assert (username, password, source) == (None, None, "env override")
|
||||
|
||||
def test_caller_sim_password_survives_into_exec_env(self) -> None:
|
||||
"""End of the Finding-1 chain: with the atomic override, the caller's
|
||||
SIM_PASSWORD passes through to the exec'd env untouched (never clobbered
|
||||
by the topology password); the unset SIM_USERNAME is left for auth.py to
|
||||
fill with its own admin default."""
|
||||
base = {"SIM_PASSWORD": "caller_pw"}
|
||||
user, pwd, _src = resolve_admin_credentials(base, Auth(username="topo_user", password="topo_pass"))
|
||||
env, _argv = build_run_env_argv("topology.yaml", base_env=base, admin_username=user, admin_password=pwd)
|
||||
assert env["SIM_PASSWORD"] == "caller_pw" # survived — not clobbered
|
||||
assert "SIM_USERNAME" not in env # left unset -> auth.py fills with admin default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. build_run_env_argv — env injection + filesystem purity.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildRunEnvArgvCredentialInjection:
|
||||
def test_injects_both_vars_when_both_given(self) -> None:
|
||||
env, argv = build_run_env_argv(
|
||||
"topology.yaml", base_env={"PATH": "/usr/bin"}, admin_username="netops", admin_password="s3cr3t!"
|
||||
)
|
||||
assert env["SIM_USERNAME"] == "netops"
|
||||
assert env["SIM_PASSWORD"] == "s3cr3t!"
|
||||
assert env["TOPOLOGY_PATH"] == "topology.yaml"
|
||||
|
||||
def test_injects_nothing_when_neither_given(self) -> None:
|
||||
env, argv = build_run_env_argv("topology.yaml", base_env={"PATH": "/usr/bin"})
|
||||
assert "SIM_USERNAME" not in env
|
||||
assert "SIM_PASSWORD" not in env
|
||||
|
||||
def test_base_env_untouched_by_reference(self) -> None:
|
||||
"""build_run_env_argv deepcopies base_env — the caller's dict must
|
||||
not be mutated (pure-function contract in its docstring)."""
|
||||
base_env = {"PATH": "/usr/bin"}
|
||||
build_run_env_argv("topology.yaml", base_env=base_env, admin_username="netops", admin_password="s3cr3t!")
|
||||
assert "SIM_USERNAME" not in base_env
|
||||
assert "TOPOLOGY_PATH" not in base_env
|
||||
|
||||
def test_does_not_touch_filesystem(self, tmp_path: Path) -> None:
|
||||
"""Passing a nonexistent topology path must not raise / must not stat
|
||||
the file — build_run_env_argv is filesystem-pure per its docstring."""
|
||||
nonexistent = tmp_path / "does-not-exist.yaml"
|
||||
env, argv = build_run_env_argv(str(nonexistent))
|
||||
assert env["TOPOLOGY_PATH"] == str(nonexistent)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. End-to-end: aaaLogin actually enforces a custom password.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _new_client(topo) -> TestClient:
|
||||
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))
|
||||
|
||||
|
||||
class TestEndToEndEnforcement:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_auth_module_globals(self):
|
||||
"""auth.py binds SIM_USERNAME/SIM_PASSWORD as module-level constants
|
||||
at import time; this fixture monkeypatches the module's OWN attributes
|
||||
(which aaaLogin's closure reads as module globals at request time, not
|
||||
at def time) and restores the originals after each test, so these
|
||||
tests never leak a custom credential into any other test in the
|
||||
suite."""
|
||||
orig_username = auth_module.SIM_USERNAME
|
||||
orig_password = auth_module.SIM_PASSWORD
|
||||
yield
|
||||
auth_module.SIM_USERNAME = orig_username
|
||||
auth_module.SIM_PASSWORD = orig_password
|
||||
|
||||
def test_aaalogin_succeeds_with_custom_password(self) -> None:
|
||||
auth_module.SIM_USERNAME = "customadmin"
|
||||
auth_module.SIM_PASSWORD = "custompass123"
|
||||
topo = load_topology(TOPOLOGY_YAML)
|
||||
client = _new_client(topo)
|
||||
|
||||
resp = client.post(
|
||||
"/api/aaaLogin.json",
|
||||
json={"aaaUser": {"attributes": {"name": "customadmin", "pwd": "custompass123"}}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_aaalogin_401s_with_wrong_password(self) -> None:
|
||||
auth_module.SIM_USERNAME = "customadmin"
|
||||
auth_module.SIM_PASSWORD = "custompass123"
|
||||
topo = load_topology(TOPOLOGY_YAML)
|
||||
client = _new_client(topo)
|
||||
|
||||
resp = client.post(
|
||||
"/api/aaaLogin.json",
|
||||
json={"aaaUser": {"attributes": {"name": "customadmin", "pwd": "totally-wrong"}}},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_aaalogin_401s_with_stale_default_password_after_rotation(self) -> None:
|
||||
"""The previous default (admin/cisco) must stop working once a
|
||||
topology-driven credential rotation has taken effect — proves this
|
||||
isn't just "any password works", it's a real replacement."""
|
||||
auth_module.SIM_USERNAME = "customadmin"
|
||||
auth_module.SIM_PASSWORD = "custompass123"
|
||||
topo = load_topology(TOPOLOGY_YAML)
|
||||
client = _new_client(topo)
|
||||
|
||||
resp = client.post(
|
||||
"/api/aaaLogin.json",
|
||||
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_ndo_remains_lenient_regardless_of_apic_credential_rotation(self) -> None:
|
||||
"""NDO must keep accepting any credential even while the APIC plane
|
||||
enforces a rotated (non-default) password — proves this feature does
|
||||
NOT change NDO's deliberate leniency."""
|
||||
from aci_sim.ndo.app import make_ndo_app
|
||||
from aci_sim.ndo.model import build_ndo_model
|
||||
|
||||
auth_module.SIM_USERNAME = "customadmin"
|
||||
auth_module.SIM_PASSWORD = "custompass123"
|
||||
|
||||
topo = load_topology(TOPOLOGY_YAML)
|
||||
ndo_state = build_ndo_model(topo)
|
||||
ndo_client = TestClient(make_ndo_app(ndo_state))
|
||||
resp = ndo_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"userName": "anyone", "userPasswd": "anything-at-all"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Backward compat: no `auth:` section -> no injection, no regression.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBackwardCompatNoAuthSection:
|
||||
def test_repo_topology_has_no_auth_section(self) -> None:
|
||||
topo = load_topology(TOPOLOGY_YAML)
|
||||
assert topo.auth is None
|
||||
|
||||
def test_resolve_admin_credentials_injects_nothing_for_repo_topology(self) -> None:
|
||||
topo = load_topology(TOPOLOGY_YAML)
|
||||
username, password, source = resolve_admin_credentials({}, topo.auth)
|
||||
assert (username, password, source) == (None, None, "built-in default")
|
||||
|
||||
def test_build_run_env_argv_injects_nothing_for_repo_topology(self) -> None:
|
||||
topo = load_topology(TOPOLOGY_YAML)
|
||||
username, password, _source = resolve_admin_credentials(dict(os.environ), topo.auth)
|
||||
env, _argv = build_run_env_argv(
|
||||
str(TOPOLOGY_YAML), base_env={"PATH": "/usr/bin"}, admin_username=username, admin_password=password
|
||||
)
|
||||
assert "SIM_USERNAME" not in env
|
||||
assert "SIM_PASSWORD" not in env
|
||||
|
||||
def test_topology_dict_without_auth_key_still_validates(self) -> None:
|
||||
"""A hand-authored topology dict that never mentions `auth` at all
|
||||
(pre-dating this feature) must still validate — Topology.auth is
|
||||
Optional[Auth] = None."""
|
||||
from aci_sim.cli import generate_topology
|
||||
|
||||
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=1)
|
||||
assert "auth" not in topo_dict
|
||||
validated = Topology.model_validate(topo_dict)
|
||||
assert validated.auth is None
|
||||
Reference in New Issue
Block a user