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>
373 lines
18 KiB
Python
373 lines
18 KiB
Python
"""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
|