diff --git a/Documentation/technical/testing/dincli-testing-guide.md b/Documentation/technical/testing/dincli-testing-guide.md index de926f4..f717f2e 100644 --- a/Documentation/technical/testing/dincli-testing-guide.md +++ b/Documentation/technical/testing/dincli-testing-guide.md @@ -16,9 +16,11 @@ chain ID 1337 on `http://127.0.0.1:8545`. ## Quick start -The harness is self-contained. All prerequisites (contract compilation, the -local chain node, IPFS daemon) are managed automatically by the conftest. The only manual -requirement is Docker — it must be running before Phase 4 client training begins. +The conftest manages contract compilation and local chain/IPFS startup. +Install the Python dependencies and chain/IPFS toolchains first. The existing +manual path also requires a repo-root `.env` and a packaged +`dincli/config/accounts.json` containing the public development accounts. +Docker must be running before Phase 4 client training begins. ```bash # 1. Ensure Docker daemon is running @@ -50,6 +52,47 @@ That's it. The conftest will: The test suite is structured around a centralized session-scoped `conftest.py` that isolates dependencies and coordinates services. +### CI smoke groundwork + +The first CI preparation change adds opt-in `DIN_TEST_ISOLATED=1` service +ownership and corrects demo bootstrap to use `connect-demo-wallet`. It does +not yet add a Docker runner, a smoke workflow job, or complete seven-contract +deployment assertions. The Python CI job still selects `not integration`. +Full lifecycle wallet switches and later phases are not verified by this change. + +In a disposable checkout/environment, isolated mode requires absolute +`DIN_TEST_TMPDIR` and `DIN_TEST_RESULTS_DIR` paths. Scratch must not already +exist; results must be outside scratch. The harness refuses checkout dotenv +files, uses a local-only subprocess environment, creates a fresh offline Kubo +repository, and rejects occupied ports without terminating their processes. +Only newly started process groups are stopped, including when setup fails +before fixture yield. Startup logs remain in the external results directory. +Both compiler paths remain enabled at this foundation stage; the Foundry-only +selection will accompany the later smoke runner. + +Isolated bootstrap validates and reuses accounts 0/1 in the checkout's public +`dincli/config/accounts.json`, preserving the file. If absent, it generates +those accounts from the public Anvil mnemonic and removes only that generated +file on teardown. Invalid accounts or symlinks fail setup. `connect-demo-wallet` reads +this file; setting `ETH_PRIVATE_KEY_N` alone does not supply demo accounts. +Existing manual runs must provide their own public account file with enough +entries for the chosen phases. Real-wallet commands deliberately reject demo +wallets and must not be substituted during bootstrap. + +The focused tests below exercise startup failures, process ownership, +interruption, account setup and retained evidence without launching Anvil or +IPFS, compiling contracts or contacting a chain: + +```bash +python -m pytest tests/test_integration_demo.py \ + tests/test_integration_services.py tests/test_integration_harness.py -q +``` + +Isolated mode is a harness setting, not a container or a guarantee that an +arbitrary checkout is disposable. Use it only inside an environment you own +for testing. The forthcoming Docker runner will establish that boundary and +provide the shared GitHub/local reproduction command. + ### Managed services (`managed_services` fixture) Before any test runs, the fixture does the following: diff --git a/tests/dincli/conftest.py b/tests/dincli/conftest.py index 5bb7948..23bd6fb 100644 --- a/tests/dincli/conftest.py +++ b/tests/dincli/conftest.py @@ -19,8 +19,9 @@ Run (fail-fast — recommended, because every test depends on the previous one): pytest tests/dincli/ -v -x -m integration --tb=short 2>&1 | tee ~/tempdir/dincli/results/last_run.txt -All test output / logs are written to ~/tempdir/dincli/ (override with -DIN_TEST_TMPDIR; see tests/dincli/constants.py for all env overrides). +DIN_TEST_ISOLATED=1 opts into owned service groups and fresh disposable state. +It requires explicit scratch and external results paths; it does not create a +container. The default manual service behavior remains unchanged. """ import json @@ -31,6 +32,7 @@ import sys import time from pathlib import Path +from contextlib import ExitStack import pytest import requests @@ -49,8 +51,14 @@ DIN_TEMP, NPX_BIN, IPFS_BIN, + ANVIL_BIN, + ISOLATED_MODE, + RESULTS_DIR, ) +from tests.dincli.demo import bootstrap_demo, prepare_demo_accounts +from tests.dincli.services import start_service, rpc_ready, ipfs_ready + # --------------------------------------------------------------------------- # Service helpers @@ -106,6 +114,7 @@ def _compile_contracts(results_dir: Path) -> None: capture_output=True, text=True, timeout=180, + env=_isolated_env(DIN_TEMP) if ISOLATED_MODE else None, ) log_path.write_text(result.stdout + result.stderr, encoding="utf-8") if result.returncode != 0: @@ -129,6 +138,7 @@ def _build_foundry_contracts(results_dir: Path) -> None: capture_output=True, text=True, timeout=180, + env=_isolated_env(DIN_TEMP) if ISOLATED_MODE else None, ) log_path.write_text(result.stdout + result.stderr, encoding="utf-8") if result.returncode != 0: @@ -263,6 +273,17 @@ def din_tmp(): config/ and cache/ are wiped at session start; results/ accumulates runs. """ + if ISOLATED_MODE: + for directory in (DEVNET_ROOT, FOUNDRY_DIR): + if any(path.name != ".env.example" for path in directory.glob(".env*")): + pytest.fail("Isolated mode requires a disposable checkout without dotenv files") + DIN_TEMP.mkdir(parents=True, exist_ok=False) + try: + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + yield DIN_TEMP + finally: + shutil.rmtree(DIN_TEMP) + return DIN_TEMP.mkdir(parents=True, exist_ok=True) for subdir in ("config", "cache"): d = DIN_TEMP / subdir @@ -270,7 +291,7 @@ def din_tmp(): shutil.rmtree(d) d.mkdir(parents=True, exist_ok=True) (DIN_TEMP / "results").mkdir(exist_ok=True) - return DIN_TEMP + yield DIN_TEMP # --------------------------------------------------------------------------- @@ -292,7 +313,7 @@ def managed_services(din_tmp): depend on it transitively via bootstrap). On teardown, any processes we started are terminated. """ - results_dir = din_tmp / "results" + results_dir = RESULTS_DIR chain_proc = None ipfs_proc = None @@ -302,6 +323,37 @@ def managed_services(din_tmp): if PLATFORM_DEPLOY_TOOLCHAIN == "foundry": _build_foundry_contracts(results_dir) + if ISOLATED_MODE: + env = _isolated_env(din_tmp) + with ExitStack() as resources: + if PLATFORM_DEPLOY_TOOLCHAIN == "foundry": + command = [ANVIL_BIN, "--host", "127.0.0.1", "--chain-id", "1337", + "--accounts", "70", "--balance", "10000", "--block-time", "2", + "--code-size-limit", "4294967295", + "--mnemonic", "test test test test test test test test test test test junk"] + cwd = FOUNDRY_DIR + else: + command = [NPX_BIN, "hardhat", "node", "--hostname", "127.0.0.1"] + cwd = HARDHAT_DIR + chain = start_service(command, cwd=cwd, env=env, + log_path=results_dir / "chain_node.log", + ready=lambda: rpc_ready(HARDHAT_RPC), port=8545) + resources.callback(chain.close) + # Never initialize or reuse an existing IPFS repository. + if Path(env["IPFS_PATH"]).exists(): + raise RuntimeError("Isolated IPFS repository already exists") + with (results_dir / "ipfs_init.log").open("w") as log: + for args in (["init"], ["config", "--json", "Bootstrap", "[]"], + ["config", "--json", "Discovery.MDNS.Enabled", "false"]): + subprocess.run([IPFS_BIN, *args], env=env, check=True, + stdout=log, stderr=subprocess.STDOUT, timeout=30) + ipfs = start_service([IPFS_BIN, "daemon", "--offline"], cwd=din_tmp, env=env, + log_path=results_dir / "ipfs_daemon.log", + ready=ipfs_ready, port=5001) + resources.callback(ipfs.close) + yield + return + # 2. Fresh chain node (kill and restart for clean EVM state) if PLATFORM_DEPLOY_TOOLCHAIN == "foundry": chain_proc = _start_fresh_anvil_node(results_dir) @@ -335,6 +387,23 @@ def managed_services(din_tmp): # --------------------------------------------------------------------------- +def _isolated_env(din_tmp): + """Local-only subprocess settings; do not inherit provider credentials.""" + return { + "PATH": os.environ.get("PATH", os.defpath), + "LANG": "C.UTF-8", "HOME": str(din_tmp / "home"), + "XDG_CONFIG_HOME": str(din_tmp / "config"), + "XDG_CACHE_HOME": str(din_tmp / "cache"), + "XDG_DATA_HOME": str(din_tmp / "data"), + "IPFS_PATH": str(din_tmp / "ipfs"), + "PYTHONPATH": str(DEVNET_ROOT), "LOCAL_RPC_URL": HARDHAT_RPC, + "IPFS_PROVIDER": "env", "IPFS_PUBLIC_GATEWAY": "0", + "IPFS_API_URL_ADD": "http://127.0.0.1:5001/api/v0/add", + "IPFS_API_URL_RETRIEVE": "http://127.0.0.1:5001/api/v0", + "NO_COLOR": "1", "TERM": "dumb", + } + + @pytest.fixture(scope="session") def din_env(din_tmp): """ @@ -346,6 +415,8 @@ def din_env(din_tmp): local dincli/ package without a pip install into the venv. - LOCAL_RPC_URL points at the Hardhat node. """ + if ISOLATED_MODE: + return _isolated_env(din_tmp) env = os.environ.copy() env["XDG_CONFIG_HOME"] = str(din_tmp / "config") env["XDG_CACHE_HOME"] = str(din_tmp / "cache") @@ -366,6 +437,8 @@ def workdir(): dincli/ package is found via PYTHONPATH from here. """ + if ISOLATED_MODE: + return DIN_TEMP shutil.copy(str(DEVNET_ROOT / ".env"), str(DIN_TEMP / ".env")) return DIN_TEMP @@ -478,19 +551,21 @@ def _run( @pytest.fixture(scope="session", autouse=True) def bootstrap(managed_services, din_info_backup, run): """ - Configure demo mode and local network, and register the named role + Configure demo mode and local network, and connect the named demo role wallets (account 0 = dinrep / DIN-Representative, account 1 = modelowner). - Tests switch between them with `system connect-wallet `; dynamic - per-account roles in test_04 self-register via - `register-wallet --account N --name acctN --yes --connect`. + Use `system connect-demo-wallet ` to switch demo accounts. Later + lifecycle phases still require their own wallet-command migration. Depends on managed_services so the Hardhat node is guaranteed to be running before the first command. """ - run(["system", "init"]) - run(["system", "configure-demo"]) - run(["system", "configure-network", "--network", "local"]) - run(["system", "register-wallet", "--account", "0", "--name", "dinrep", "--yes"]) - run(["system", "register-wallet", "--account", "1", "--name", "modelowner", "--yes"]) + accounts_path = DEVNET_ROOT / "dincli" / "config" / "accounts.json" + generated_accounts = ISOLATED_MODE and prepare_demo_accounts(accounts_path) + try: + bootstrap_demo(run) + yield + finally: + if generated_accounts: + accounts_path.unlink() # --------------------------------------------------------------------------- diff --git a/tests/dincli/constants.py b/tests/dincli/constants.py index c750619..a8d3a94 100644 --- a/tests/dincli/constants.py +++ b/tests/dincli/constants.py @@ -15,6 +15,10 @@ IPFS_BIN ipfs binary (default: `ipfs` on PATH, else /usr/local/bin/ipfs) DIN_TEST_TMPDIR scratch dir for config/cache isolation and logs (default: ~/tempdir/dincli) + DIN_TEST_ISOLATED set to 1 only in a disposable execution environment; + ignores checkout dotenv and host Python/nvm fallbacks + DIN_TEST_RESULTS_DIR external log directory, required in isolated mode + ANVIL_BIN explicit Anvil binary for owned isolated startup PLATFORM_DEPLOY_TOOLCHAIN "foundry" or "hardhat" — which platform deploy script test_deploy_platform_via_script runs (default: "foundry", matching `dincli system import-deployments`'s @@ -29,6 +33,7 @@ HARDHAT_RPC = "http://127.0.0.1:8545" DEVNET_ROOT = Path(__file__).resolve().parent.parent.parent +ISOLATED_MODE = os.environ.get("DIN_TEST_ISOLATED") == "1" def _load_dotenv_values(path: Path) -> dict: @@ -41,7 +46,7 @@ def _load_dotenv_values(path: Path) -> dict: return {k: v for k, v in dotenv_values(dotenv_path=path).items() if v is not None} -_DOTENV = _load_dotenv_values(DEVNET_ROOT / ".env") +_DOTENV = {} if ISOLATED_MODE else _load_dotenv_values(DEVNET_ROOT / ".env") def _env(name: str) -> "str | None": @@ -58,6 +63,8 @@ def _resolve_python(env_var: str, default_venv: str) -> str: override = _env(env_var) if override: return override + if ISOLATED_MODE: + return sys.executable candidate = Path.home() / "my_venvs" / default_venv / "bin" / "python" return str(candidate) if candidate.exists() else sys.executable @@ -66,7 +73,10 @@ def _resolve_npx() -> str: override = _env("NPX_BIN") if override: return override - nvm_candidates = sorted(Path.home().glob(".nvm/versions/node/*/bin/npx")) + nvm_candidates = ( + [] if ISOLATED_MODE + else sorted(Path.home().glob(".nvm/versions/node/*/bin/npx")) + ) if nvm_candidates: return str(nvm_candidates[-1]) return shutil.which("npx") or "npx" @@ -92,9 +102,19 @@ def _venv_site_packages(python_bin: str) -> str: NPX_BIN = _resolve_npx() FORGE_BIN = _env("FORGE_BIN") or shutil.which("forge") or "forge" +ANVIL_BIN = _env("ANVIL_BIN") or shutil.which("anvil") or "anvil" IPFS_BIN = _env("IPFS_BIN") or shutil.which("ipfs") or "/usr/local/bin/ipfs" DIN_TEMP = Path(_env("DIN_TEST_TMPDIR") or str(Path.home() / "tempdir" / "dincli")) +RESULTS_DIR = Path(_env("DIN_TEST_RESULTS_DIR") or str(DIN_TEMP / "results")) +if ISOLATED_MODE: + if not os.environ.get("DIN_TEST_TMPDIR") or not os.environ.get("DIN_TEST_RESULTS_DIR"): + raise ValueError("Isolated mode requires DIN_TEST_TMPDIR and DIN_TEST_RESULTS_DIR") + if not DIN_TEMP.is_absolute() or not RESULTS_DIR.is_absolute(): + raise ValueError("Isolated scratch and results paths must be absolute") + if (DIN_TEMP.resolve() == RESULTS_DIR.resolve() + or DIN_TEMP.resolve() in RESULTS_DIR.resolve().parents): + raise ValueError("Isolated results must be outside scratch so cleanup preserves logs") # Platform deployment (PR 13: transparent proxies; PR 35: foundry parity). diff --git a/tests/dincli/demo.py b/tests/dincli/demo.py new file mode 100644 index 0000000..4166dcb --- /dev/null +++ b/tests/dincli/demo.py @@ -0,0 +1,79 @@ +"""Explicit preparation of disposable wallets for the local integration suite.""" + +import json +from pathlib import Path + +from eth_account import Account + + +# Public Anvil/Hardhat development seed; these accounts are for local tests only. +DEMO_MNEMONIC = "test test test test test test test test test test test junk" +DEMO_ADDRESSES = ( + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", +) + + +def generate_demo_accounts(accounts_path: Path) -> Path: + """Write accounts 0/1 at the caller's explicit disposable package path. + + An existing destination is always refused, including a symlink. Never call + this with a user's checkout/config path; the caller owns cleanup of the + isolated package containing this file. + """ + accounts_path = Path(accounts_path) + if accounts_path.exists() or accounts_path.is_symlink(): + raise FileExistsError(f"Refusing to overwrite demo accounts: {accounts_path}") + + Account.enable_unaudited_hdwallet_features() + accounts = [] + for index, expected_address in enumerate(DEMO_ADDRESSES): + account = Account.from_mnemonic( + DEMO_MNEMONIC, account_path=f"m/44'/60'/0'/0/{index}" + ) + if account.address != expected_address: + raise ValueError(f"Unexpected public demo address for account {index}") + accounts.append({ + "address": account.address, + "private_key": "0x" + account.key.hex(), + }) + + accounts_path.parent.mkdir(parents=True, exist_ok=True) + with accounts_path.open("x", encoding="utf-8") as file: + json.dump({"hardhat": accounts}, file, indent=2) + file.write("\n") + return accounts_path + + +def prepare_demo_accounts(accounts_path: Path) -> bool: + """Reuse validated public accounts, or generate them and return ownership. + + Only a file created here may be removed by the caller. A checkout already + includes public demo accounts; preserve that file byte-for-byte. + """ + accounts_path = Path(accounts_path) + if accounts_path.is_symlink(): + raise ValueError(f"Refusing symlink demo accounts: {accounts_path}") + if not accounts_path.exists(): + generate_demo_accounts(accounts_path) + return True + try: + accounts = json.loads(accounts_path.read_text(encoding="utf-8"))["hardhat"] + for index, expected_address in enumerate(DEMO_ADDRESSES): + account = accounts[index] + if account["address"].lower() != expected_address.lower(): + raise ValueError("Unexpected demo address") + if Account.from_key(account["private_key"]).address != expected_address: + raise ValueError("Unexpected demo private key") + except (ValueError, TypeError, KeyError, IndexError) as error: + raise ValueError(f"Invalid public demo accounts: {accounts_path}") from error + return False + + +def bootstrap_demo(run) -> None: + """Configure local demo mode and connect the two named test role wallets.""" + run(["system", "init"]) + run(["system", "configure-demo", "--mode", "yes"]) + run(["system", "configure-network", "--network", "local"]) + run(["system", "connect-demo-wallet", "dinrep", "--account", "0", "--yes"]) + run(["system", "connect-demo-wallet", "modelowner", "--account", "1", "--yes"]) diff --git a/tests/dincli/services.py b/tests/dincli/services.py new file mode 100644 index 0000000..c09b413 --- /dev/null +++ b/tests/dincli/services.py @@ -0,0 +1,154 @@ +"""Own local integration services without disturbing pre-existing processes.""" + +import os +import re +import signal +import socket +import subprocess +import time +from pathlib import Path +from typing import Callable, IO + +import requests + + +class OwnedService: + """A child session and its log; cleanup is safe to call more than once.""" + + def __init__(self, process: subprocess.Popen, log: IO, log_path: Path): + self.process = process + self.log_path = log_path + self._log = log + self._closed = False + + def close(self) -> None: + if self._closed: + return + self._closed = True + try: + # start_new_session makes the child PID its process-group ID. + try: + os.killpg(self.process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + finally: + # Also reap descendants when the parent exits before them. + try: + os.killpg(self.process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.process.wait(timeout=5) + finally: + self._log.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + +def _check_port(port: int) -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + try: + probe.bind(("127.0.0.1", port)) + except OSError as exc: + raise RuntimeError(f"Loopback port {port} is unavailable") from exc + + +def start_service( + command: list, + cwd: Path, + env: dict, + log_path: Path, + ready: Callable[[], bool], + timeout: float = 30, + port: int | None = None, +) -> OwnedService: + """Start a service, returning ownership only after a live child is ready.""" + if port is not None: + _check_port(port) + log = None + service = None + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + log = log_path.open("w") + process = subprocess.Popen( + command, + cwd=cwd, + env=env, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + service = OwnedService(process, log, log_path) + deadline = time.monotonic() + timeout + while True: + if process.poll() is not None: + raise RuntimeError("Service exited before readiness") + if time.monotonic() >= deadline: + raise RuntimeError(f"Service readiness timed out after {timeout}s") + is_ready = ready() + if process.poll() is not None: + raise RuntimeError("Service exited during readiness probe") + if time.monotonic() >= deadline: + raise RuntimeError(f"Service readiness timed out after {timeout}s") + if is_ready: + return service + time.sleep(min(0.1, max(0, deadline - time.monotonic()))) + except BaseException as exc: + try: + if service is not None: + service.close() + elif log is not None: + log.close() + except Exception as cleanup_exc: + raise RuntimeError( + f"Service startup failed: {exc}; cleanup failed: {cleanup_exc}; log: {log_path}" + ) from exc + if not isinstance(exc, Exception): + raise + raise RuntimeError(f"Service startup failed: {exc}; log: {log_path}") from exc + + +def rpc_ready(url: str, expected_chain_id: int = 1337) -> bool: + try: + response = requests.post( + url, + json={"jsonrpc": "2.0", "method": "eth_chainId", "params": [], "id": 1}, + timeout=3, + ) + if response.status_code != 200: + return False + body = response.json() + return ( + isinstance(body, dict) + and body.get("jsonrpc") == "2.0" + and type(body.get("id")) is int + and body.get("id") == 1 + and "error" not in body + and isinstance(body.get("result"), str) + and re.fullmatch(r"0x[0-9a-fA-F]+", body["result"]) is not None + and int(body["result"], 16) == expected_chain_id + ) + except (requests.RequestException, ValueError, TypeError): + return False + + +def ipfs_ready(url: str = "http://127.0.0.1:5001/api/v0/version") -> bool: + try: + response = requests.post(url, timeout=3) + if response.status_code != 200: + return False + body = response.json() + return ( + isinstance(body, dict) + and isinstance(body.get("Version"), str) + and bool(body["Version"].strip()) + ) + except (requests.RequestException, ValueError, TypeError): + return False diff --git a/tests/dincli/test_01_platform.py b/tests/dincli/test_01_platform.py index 0ff2c02..5df8afa 100644 --- a/tests/dincli/test_01_platform.py +++ b/tests/dincli/test_01_platform.py @@ -86,8 +86,8 @@ def ensure_artifacts(): def test_connect_wallet_dinrep(run, state): - """Switch to the dinrep wallet (account 0, DIN-Representative).""" - result = run(["system", "connect-wallet", "dinrep"]) + """Switch to the dinrep demo wallet (account 0, DIN-Representative).""" + result = run(["system", "connect-demo-wallet", "dinrep"]) assert result.returncode == 0 state["representative_connected"] = True diff --git a/tests/test_integration_demo.py b/tests/test_integration_demo.py new file mode 100644 index 0000000..db8a77a --- /dev/null +++ b/tests/test_integration_demo.py @@ -0,0 +1,125 @@ +"""Local unit coverage for integration setup, without starting its services.""" + +import json + +import pytest +from eth_account import Account + +from tests.dincli.demo import bootstrap_demo, generate_demo_accounts, prepare_demo_accounts + + + +def test_generate_demo_accounts_uses_public_anvil_accounts(tmp_path): + accounts_path = tmp_path / "dincli" / "config" / "accounts.json" + + assert generate_demo_accounts(accounts_path) == accounts_path + + data = json.loads(accounts_path.read_text(encoding="utf-8")) + assert set(data) == {"hardhat"} + assert len(data["hardhat"]) == 2 + assert [account["address"] for account in data["hardhat"]] == [ + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + ] + assert data["hardhat"][0]["private_key"] == ( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + ) + for account in data["hardhat"]: + assert set(account) == {"address", "private_key"} + assert account["private_key"].startswith("0x") + assert len(account["private_key"]) == 66 + assert Account.from_key(account["private_key"]).address == account["address"] + assert list(tmp_path.rglob("accounts.json")) == [accounts_path] + + +def test_generate_demo_accounts_preserves_existing_file(tmp_path): + accounts_path = tmp_path / "accounts.json" + original = b'{"custom": "user data"}\n' + accounts_path.write_bytes(original) + + with pytest.raises(FileExistsError, match="Refusing to overwrite"): + generate_demo_accounts(accounts_path) + + assert accounts_path.read_bytes() == original + + +def test_generate_demo_accounts_refuses_dangling_symlink(tmp_path): + target = tmp_path / "missing.json" + accounts_path = tmp_path / "accounts.json" + accounts_path.symlink_to(target) + + with pytest.raises(FileExistsError, match="Refusing to overwrite"): + generate_demo_accounts(accounts_path) + + assert accounts_path.is_symlink() + assert not target.exists() + + +def test_bootstrap_demo_uses_noninteractive_commands_in_order(): + commands = [] + + bootstrap_demo(commands.append) + + assert commands == [ + ["system", "init"], + ["system", "configure-demo", "--mode", "yes"], + ["system", "configure-network", "--network", "local"], + ["system", "connect-demo-wallet", "dinrep", "--account", "0", "--yes"], + ["system", "connect-demo-wallet", "modelowner", "--account", "1", "--yes"], + ] + + +def test_bootstrap_demo_stops_after_runner_failure(): + commands = [] + + def failing_run(args): + commands.append(args) + raise RuntimeError("command failed") + + with pytest.raises(RuntimeError, match="command failed"): + bootstrap_demo(failing_run) + + assert commands == [["system", "init"]] + + +def test_prepare_demo_accounts_preserves_checked_in_public_accounts(): + from pathlib import Path + + accounts_path = Path(__file__).resolve().parents[1] / "dincli/config/accounts.json" + original = accounts_path.read_bytes() + assert prepare_demo_accounts(accounts_path) is False + assert accounts_path.read_bytes() == original + + +def test_prepare_demo_accounts_reports_ownership_of_generated_file(tmp_path): + accounts_path = tmp_path / "accounts.json" + assert prepare_demo_accounts(accounts_path) is True + assert accounts_path.exists() + + +@pytest.mark.parametrize("invalid", ["malformed-json", "missing-account", "wrong-address", "wrong-key"]) +def test_prepare_demo_accounts_rejects_invalid_data_without_changing_it(tmp_path, invalid): + accounts_path = tmp_path / "accounts.json" + generate_demo_accounts(accounts_path) + data = json.loads(accounts_path.read_text()) + if invalid == "missing-account": + data["hardhat"].pop() + elif invalid == "wrong-address": + data["hardhat"][0]["address"] = data["hardhat"][1]["address"] + elif invalid == "wrong-key": + data["hardhat"][0]["private_key"] = data["hardhat"][1]["private_key"] + accounts_path.write_text("invalid" if invalid == "malformed-json" else json.dumps(data)) + original = accounts_path.read_bytes() + with pytest.raises(ValueError, match="Invalid public demo accounts"): + prepare_demo_accounts(accounts_path) + assert accounts_path.read_bytes() == original + + +def test_prepare_demo_accounts_refuses_symlink(tmp_path): + target = tmp_path / "public-accounts.json" + generate_demo_accounts(target) + accounts_path = tmp_path / "accounts.json" + accounts_path.symlink_to(target) + with pytest.raises(ValueError, match="Refusing symlink"): + prepare_demo_accounts(accounts_path) + assert accounts_path.is_symlink() diff --git a/tests/test_integration_harness.py b/tests/test_integration_harness.py new file mode 100644 index 0000000..00a603d --- /dev/null +++ b/tests/test_integration_harness.py @@ -0,0 +1,216 @@ +"""Exercise isolated integration fixture ownership without starting services.""" + +import importlib +import json +import signal +import subprocess +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + + +@pytest.fixture +def harness(monkeypatch, tmp_path): + # Import with isolated settings so constants cannot read host dotenv/config. + scratch = tmp_path / "scratch" + results = tmp_path / "evidence" + monkeypatch.setenv("DIN_TEST_ISOLATED", "1") + monkeypatch.setenv("DIN_TEST_TMPDIR", str(scratch)) + monkeypatch.setenv("DIN_TEST_RESULTS_DIR", str(results)) + module = importlib.import_module("tests.dincli.conftest") + checkout = tmp_path / "checkout" + foundry = checkout / "foundry" + foundry.mkdir(parents=True) + for name, value in { + "ISOLATED_MODE": True, + "DEVNET_ROOT": checkout, + "FOUNDRY_DIR": foundry, + "HARDHAT_DIR": checkout / "hardhat", + "DIN_TEMP": scratch, + "RESULTS_DIR": results, + "PLATFORM_DEPLOY_TOOLCHAIN": "foundry", + }.items(): + monkeypatch.setattr(module, name, value) + + def forbidden(*args, **kwargs): + raise AssertionError("No real service, compilation, or network calls allowed") + + for name in ("_compile_contracts", "_build_foundry_contracts", + "_start_fresh_anvil_node", "_start_fresh_hardhat_node", + "_ensure_ipfs_running", "start_service"): + monkeypatch.setattr(module, name, forbidden) + monkeypatch.setattr(module.subprocess, "run", forbidden) + monkeypatch.setattr(module.requests, "post", forbidden) + return module + + +def fake_services(harness, monkeypatch): + """Return real ownership wrappers around fake processes and temporary logs.""" + from tests.dincli.services import OwnedService + from tests.dincli import services + + harness.RESULTS_DIR.mkdir() + handles = [] + signals = [] + monkeypatch.setattr(services.os, "killpg", lambda pid, sig: signals.append((pid, sig))) + monkeypatch.setattr(harness, "_compile_contracts", lambda path: None) + monkeypatch.setattr(harness, "_build_foundry_contracts", lambda path: None) + + def start(command, *, log_path, **kwargs): + log = log_path.open("w", encoding="utf-8") + log.write("retained service evidence\n") + log.flush() + process = SimpleNamespace(pid=10000 + len(handles), wait=Mock()) + handle = OwnedService(process, log, log_path) + handles.append(handle) + return handle + + monkeypatch.setattr(harness, "start_service", start) + return handles, signals + + +def test_ipfs_init_failure_closes_owned_chain_and_retains_logs(harness, monkeypatch): + handles, signals = fake_services(harness, monkeypatch) + + def fail_init(command, **kwargs): + kwargs["stdout"].write("IPFS init failed\n") + raise subprocess.CalledProcessError(1, command) + + monkeypatch.setattr(harness.subprocess, "run", fail_init) + fixture = harness.managed_services.__wrapped__(harness.DIN_TEMP) + + with pytest.raises(subprocess.CalledProcessError): + next(fixture) + + assert len(handles) == 1 + chain = handles[0] + assert chain._log.closed + assert chain.process.wait.call_count == 2 + assert signals == [(chain.process.pid, signal.SIGTERM), (chain.process.pid, signal.SIGKILL)] + assert chain.log_path.read_text() == "retained service evidence\n" + assert (harness.RESULTS_DIR / "ipfs_init.log").read_text() == "IPFS init failed\n" + + +def test_successful_service_teardown_closes_both_owned_handles(harness, monkeypatch): + handles, signals = fake_services(harness, monkeypatch) + init_commands = [] + monkeypatch.setattr(harness.subprocess, "run", lambda args, **kwargs: init_commands.append(args)) + fixture = harness.managed_services.__wrapped__(harness.DIN_TEMP) + + next(fixture) + assert len(handles) == 2 + assert all(not service._log.closed for service in handles) + assert len(init_commands) == 3 + fixture.close() + + assert all(service._log.closed for service in handles) + assert all(service.process.wait.call_count == 2 for service in handles) + assert [pid for pid, sig in signals if sig == signal.SIGTERM] == [ + handles[1].process.pid, handles[0].process.pid, + ] + assert all(service.log_path.read_text() == "retained service evidence\n" for service in handles) + + +def test_isolated_env_filters_credentials_and_uses_local_config(harness, monkeypatch): + monkeypatch.setenv("PINATA_JWT", "injected-production-credential") + monkeypatch.setenv("LOCAL_RPC_URL", "https://production.invalid") + monkeypatch.setenv("IPFS_API_URL_ADD", "https://production.invalid/add") + + env = harness.din_env.__wrapped__(harness.DIN_TEMP) + + assert "PINATA_JWT" not in env + assert "injected-production-credential" not in env.values() + for name, relative in { + "HOME": "home", "XDG_CONFIG_HOME": "config", "XDG_CACHE_HOME": "cache", + "XDG_DATA_HOME": "data", "IPFS_PATH": "ipfs", + }.items(): + assert env[name] == str(harness.DIN_TEMP / relative) + assert env["PYTHONPATH"] == str(harness.DEVNET_ROOT) + assert env["LOCAL_RPC_URL"] == "http://127.0.0.1:8545" + assert env["IPFS_API_URL_ADD"] == "http://127.0.0.1:5001/api/v0/add" + assert env["IPFS_API_URL_RETRIEVE"] == "http://127.0.0.1:5001/api/v0" + assert env["IPFS_PROVIDER"] == "env" + + +def test_isolated_scratch_refuses_existing_data_without_deleting_it(harness): + harness.DIN_TEMP.mkdir() + sentinel = harness.DIN_TEMP / "user-data.txt" + sentinel.write_text("preserve this") + fixture = harness.din_tmp.__wrapped__() + + with pytest.raises(FileExistsError): + next(fixture) + + assert sentinel.read_text() == "preserve this" + assert not harness.RESULTS_DIR.exists() + + +def test_isolated_scratch_cleanup_retains_external_evidence(harness): + fixture = harness.din_tmp.__wrapped__() + assert next(fixture) == harness.DIN_TEMP + (harness.DIN_TEMP / "temporary-wallet.json").write_text("disposable") + evidence = harness.RESULTS_DIR / "result.log" + evidence.write_text("retain this") + + fixture.close() + + assert not harness.DIN_TEMP.exists() + assert evidence.read_text() == "retain this" + + +def test_results_creation_failure_cleans_new_scratch(harness): + harness.RESULTS_DIR.write_text("existing file") + fixture = harness.din_tmp.__wrapped__() + with pytest.raises(FileExistsError): + next(fixture) + assert not harness.DIN_TEMP.exists() + assert harness.RESULTS_DIR.read_text() == "existing file" + + +@pytest.mark.parametrize("fail_bootstrap", [False, True]) +def test_isolated_bootstrap_provisions_then_cleans_demo_accounts(harness, fail_bootstrap): + accounts_path = harness.DEVNET_ROOT / "dincli" / "config" / "accounts.json" + commands = [] + + def run(args): + data = json.loads(accounts_path.read_text()) + assert len(data["hardhat"]) == 2 + assert data["hardhat"][0]["address"] == "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + commands.append(args) + if fail_bootstrap: + raise RuntimeError("bootstrap command failed") + + fixture = harness.bootstrap.__wrapped__(None, None, run) + if fail_bootstrap: + with pytest.raises(RuntimeError, match="bootstrap command failed"): + next(fixture) + assert commands == [["system", "init"]] + else: + next(fixture) + assert accounts_path.exists() + assert len(commands) == 5 + fixture.close() + assert not accounts_path.exists() + + +@pytest.mark.parametrize("fail_bootstrap", [False, True]) +def test_isolated_bootstrap_preserves_existing_public_accounts(harness, fail_bootstrap): + from tests.dincli.demo import generate_demo_accounts + + accounts_path = harness.DEVNET_ROOT / "dincli/config/accounts.json" + generate_demo_accounts(accounts_path) + original = accounts_path.read_bytes() + + def run(args): + if fail_bootstrap: + raise RuntimeError("bootstrap command failed") + + fixture = harness.bootstrap.__wrapped__(None, None, run) + if fail_bootstrap: + with pytest.raises(RuntimeError, match="bootstrap command failed"): + next(fixture) + else: + next(fixture) + fixture.close() + assert accounts_path.read_bytes() == original diff --git a/tests/test_integration_services.py b/tests/test_integration_services.py new file mode 100644 index 0000000..2dcbb70 --- /dev/null +++ b/tests/test_integration_services.py @@ -0,0 +1,214 @@ +"""Service ownership tests; these never start chain/IPFS nodes or contact them.""" + +import signal +import socket +import subprocess +import os +import sys +from unittest.mock import Mock + +import pytest +import requests + +from tests.dincli import services + + +@pytest.fixture +def harness(monkeypatch, tmp_path): + process = Mock(pid=24680) + process.poll.return_value = None + process.wait.return_value = 0 + popen = Mock(return_value=process) + killpg = Mock() + monkeypatch.setattr(services.subprocess, "Popen", popen) + monkeypatch.setattr(services.os, "killpg", killpg) + clock = [0.0] + monkeypatch.setattr(services.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(services.time, "sleep", lambda delay: clock.__setitem__(0, clock[0] + delay)) + + def start(ready=lambda: True, timeout=1, **kwargs): + return services.start_service( + ["fake-service", "--flag"], tmp_path, {"PATH": "/bin"}, + tmp_path / "service.log", ready, timeout=timeout, **kwargs, + ) + + return process, popen, killpg, clock, start, tmp_path / "service.log" + + +def assert_closed(process, popen, killpg): + assert popen.call_args.kwargs["stdout"].closed + assert killpg.call_args_list == [ + ((process.pid, signal.SIGTERM),), + ((process.pid, signal.SIGKILL),), + ] + assert all(call.kwargs["timeout"] == 5 for call in process.wait.call_args_list) + + +def test_ready_service_has_its_own_session_and_idempotent_cleanup(harness): + process, popen, killpg, _, start, log_path = harness + service = start() + assert service.process is process + assert service.log_path == log_path + assert popen.call_args.kwargs["start_new_session"] is True + assert popen.call_args.kwargs["stderr"] == subprocess.STDOUT + assert not popen.call_args.kwargs["stdout"].closed + with service: + pass + service.close() + assert_closed(process, popen, killpg) + assert log_path.exists() + + +def test_timeout_cleans_up_and_retains_diagnostic_log(harness): + process, popen, killpg, clock, start, log_path = harness + + def unready(): + popen.call_args.kwargs["stdout"].write("startup diagnostics\n") + return False + + with pytest.raises(RuntimeError, match="timed out"): + start(unready, timeout=0.25) + assert clock[0] == pytest.approx(0.25) + assert_closed(process, popen, killpg) + assert "startup diagnostics" in log_path.read_text() + + +@pytest.mark.parametrize("poll_results", [[9], [None, 9]]) +def test_child_exit_is_failure_even_when_probe_returns_true(harness, poll_results): + process, popen, killpg, _, start, _ = harness + process.poll.side_effect = poll_results + probe = Mock(return_value=True) + with pytest.raises(RuntimeError, match="exited"): + start(probe) + assert probe.call_count == len(poll_results) - 1 + assert_closed(process, popen, killpg) + + +def test_occupied_port_is_rejected_without_spawning(harness): + _, popen, killpg, _, start, log_path = harness + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen() + with pytest.raises(RuntimeError, match="port .* unavailable"): + start(port=listener.getsockname()[1]) + popen.assert_not_called() + killpg.assert_not_called() + assert not log_path.exists() + + +def test_probe_exception_cleans_up(harness): + process, popen, killpg, _, start, log_path = harness + with pytest.raises(RuntimeError, match="broken probe"): + start(Mock(side_effect=ValueError("broken probe"))) + assert_closed(process, popen, killpg) + assert log_path.exists() + + +@pytest.mark.parametrize("interruption", [KeyboardInterrupt, SystemExit]) +def test_startup_interruption_cleans_up_before_ownership_transfer(harness, interruption): + process, popen, killpg, _, start, log_path = harness + with pytest.raises(interruption): + start(Mock(side_effect=interruption())) + assert_closed(process, popen, killpg) + assert log_path.exists() + + +def test_start_failure_closes_log_without_signalling(harness): + _, popen, killpg, _, start, log_path = harness + popen.side_effect = OSError("missing executable") + with pytest.raises(RuntimeError, match="missing executable"): + start() + assert popen.call_args.kwargs["stdout"].closed + killpg.assert_not_called() + assert log_path.exists() + + +def test_cleanup_escalates_when_parent_does_not_exit(harness): + process, popen, killpg, _, start, _ = harness + service = start() + process.wait.side_effect = [subprocess.TimeoutExpired("fake-service", 5), 0] + service.close() + assert_closed(process, popen, killpg) + assert process.wait.call_count == 2 + + +def test_already_exited_group_does_not_prevent_cleanup(harness): + process, popen, killpg, _, start, _ = harness + service = start() + killpg.side_effect = ProcessLookupError + service.close() + assert_closed(process, popen, killpg) + + +def test_probe_cannot_return_success_after_deadline(harness): + process, popen, killpg, clock, start, _ = harness + + def slow_probe(): + clock[0] = 2 + return True + + with pytest.raises(RuntimeError, match="timed out"): + start(slow_probe) + assert_closed(process, popen, killpg) + + +def test_real_child_is_reaped_and_log_preserved(tmp_path): + log_path = tmp_path / "real-service.log" + service = services.start_service( + [sys.executable, "-u", "-c", "import time; print('ready'); time.sleep(60)"], + tmp_path, os.environ.copy(), log_path, + lambda: log_path.read_text().strip() == "ready", timeout=5, + ) + try: + assert os.getpgid(service.process.pid) == service.process.pid + finally: + service.close() + assert service.process.poll() is not None + assert log_path.read_text().strip() == "ready" + + +@pytest.mark.parametrize("body, expected", [ + ({"jsonrpc": "2.0", "id": 1, "result": "0x539"}, True), + ({"jsonrpc": "2.0", "id": 1, "result": "0x1"}, False), + ({"jsonrpc": "2.0", "id": 1, "result": "0x539", "error": {}}, False), + ({"jsonrpc": "2.0", "id": 2, "result": "0x539"}, False), + ({"id": 1, "result": "0x539"}, False), + ({"jsonrpc": "2.0", "id": 1, "result": 1337}, False), + ({"jsonrpc": "2.0", "id": 1, "result": "0xnope"}, False), + ({"jsonrpc": "2.0", "id": 1, "result": "0x539 "}, False), + ({"jsonrpc": "2.0", "id": True, "result": "0x539"}, False), + ([], False), +]) +def test_rpc_requires_matching_jsonrpc_chain(monkeypatch, body, expected): + response = Mock(status_code=200) + response.json.return_value = body + post = Mock(return_value=response) + monkeypatch.setattr(services.requests, "post", post) + assert services.rpc_ready("http://local.invalid") is expected + assert post.call_args.kwargs["timeout"] == 3 + assert post.call_args.kwargs["json"]["method"] == "eth_chainId" + + +@pytest.mark.parametrize("body, expected", [ + ({"Version": "0.32.1"}, True), ({"Version": ""}, False), + ({"Version": " "}, False), ({"Version": 1}, False), ({}, False), ([], False), +]) +def test_ipfs_requires_nonempty_version(monkeypatch, body, expected): + response = Mock(status_code=200) + response.json.return_value = body + post = Mock(return_value=response) + monkeypatch.setattr(services.requests, "post", post) + assert services.ipfs_ready() is expected + assert post.call_args.kwargs["timeout"] == 3 + + +@pytest.mark.parametrize("probe", [services.rpc_ready, services.ipfs_ready]) +@pytest.mark.parametrize("failure", ["http", "json", "network"]) +def test_readiness_handles_failed_http_json_and_network(monkeypatch, probe, failure): + response = Mock(status_code=503 if failure == "http" else 200) + response.json.side_effect = ValueError("invalid JSON") + post = Mock(return_value=response) + if failure == "network": + post.side_effect = requests.ConnectionError("unreachable") + monkeypatch.setattr(services.requests, "post", post) + assert probe("http://local.invalid") is False