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:
2026-07-05 20:06:36 +10:00
co-authored by Claude Fable 5
commit 7e9a175ce6
117 changed files with 31769 additions and 0 deletions
+372
View File
@@ -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