Tests / Test passed: 581
Host Agent: - One-time local operator account bootstrap (PBKDF2-HMAC-SHA256, atomic 0600-permission write) gating the daemon's first unattended start via a new `setup` CLI subcommand. - Default control-plane URL now https://amcp.home.jerryyan.top (env var override unchanged). - Enrollment no longer requires a pre-issued token; falls back to zero-token self-service enrollment when none is configured. Cloud control plane: - CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED (default false) opt-in flag. - SelfServiceEnrollmentAuthProvider + ChainedEnrollmentAuthProvider: configured tokens still take priority; self-service only applies when no token matches, preserving edge-host-enrollment's token-bound path. - Fixed a latent bug in sql_repository.py::enroll_host: the token-conflict lookup used `== enrollment_token_digest`, which SQLAlchemy compiles to `IS NULL` when the value is None, so every self-service enrollment after the first would have falsely collided with an existing NULL-digest host. Skipped that lookup entirely when the digest is None. Docs/deploy: .env.example, compose.yaml, compose.deploy.yaml, CLOUD_DEPLOYMENT.md, MACOS_IPHONE_SETUP.md updated for the new flag, URL default, and required `device-host-agent setup` step. Verification: 494 non-integration tests pass; openspec validate --strict passes. PostgreSQL-backed contract tests and full manual end-to-end verification were not run (no Postgres/Docker or reachable cloud-api in this environment); noted as unchecked in tasks.md 7.2/7.4.
123 lines
3.7 KiB
Python
123 lines
3.7 KiB
Python
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from host_agent import cli
|
|
from host_agent.local_account import LocalAccountStore
|
|
|
|
|
|
class _RecordingApplication:
|
|
def __init__(self) -> None:
|
|
self.ran = False
|
|
|
|
def run(self) -> None:
|
|
self.ran = True
|
|
|
|
|
|
def _base_env(tmp_path) -> dict[str, str]:
|
|
return {
|
|
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example",
|
|
"HOST_AGENT_LOCAL_ACCOUNT_PATH": str(tmp_path / "account.json"),
|
|
"HOST_AGENT_IDENTITY_PATH": str(tmp_path / "identity.json"),
|
|
}
|
|
|
|
|
|
def _set_env(monkeypatch, tmp_path) -> None:
|
|
for key, value in _base_env(tmp_path).items():
|
|
monkeypatch.setenv(key, value)
|
|
|
|
|
|
def _patch_create_application(monkeypatch) -> dict:
|
|
captured: dict = {}
|
|
|
|
def fake_create_application(*, config=None, **kwargs):
|
|
captured["config"] = config
|
|
app = _RecordingApplication()
|
|
captured["app"] = app
|
|
return app
|
|
|
|
monkeypatch.setattr(cli, "create_application", fake_create_application)
|
|
return captured
|
|
|
|
|
|
def test_existing_account_fast_path_skips_prompting(monkeypatch, tmp_path) -> None:
|
|
_set_env(monkeypatch, tmp_path)
|
|
LocalAccountStore(tmp_path / "account.json").create(
|
|
"operator", "correct horse battery staple"
|
|
)
|
|
|
|
def fail_input(prompt: str = "") -> str:
|
|
raise AssertionError("must not prompt when a local account already exists")
|
|
|
|
monkeypatch.setattr("builtins.input", fail_input)
|
|
captured = _patch_create_application(monkeypatch)
|
|
|
|
cli.main([])
|
|
|
|
assert captured["app"].ran is True
|
|
assert captured["config"].display_name == "operator"
|
|
|
|
|
|
def test_interactive_first_run_prompts_and_creates_account(monkeypatch, tmp_path) -> None:
|
|
_set_env(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
|
inputs = iter(["operator"])
|
|
monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
|
|
passwords = iter(["hunter2", "hunter2"])
|
|
monkeypatch.setattr("getpass.getpass", lambda prompt="": next(passwords))
|
|
captured = _patch_create_application(monkeypatch)
|
|
|
|
cli.main([])
|
|
|
|
assert captured["app"].ran is True
|
|
account = LocalAccountStore(tmp_path / "account.json").load()
|
|
assert account is not None
|
|
assert account.username == "operator"
|
|
|
|
|
|
def test_non_interactive_without_account_exits_with_clear_error(
|
|
monkeypatch, tmp_path, capsys
|
|
) -> None:
|
|
_set_env(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
|
|
captured = _patch_create_application(monkeypatch)
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
cli.main([])
|
|
|
|
assert exc_info.value.code == 1
|
|
assert "setup" in capsys.readouterr().err
|
|
assert "app" not in captured
|
|
assert LocalAccountStore(tmp_path / "account.json").load() is None
|
|
|
|
|
|
def test_setup_subcommand_creates_account(monkeypatch, tmp_path) -> None:
|
|
_set_env(monkeypatch, tmp_path)
|
|
inputs = iter(["operator"])
|
|
monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
|
|
passwords = iter(["hunter2", "hunter2"])
|
|
monkeypatch.setattr("getpass.getpass", lambda prompt="": next(passwords))
|
|
captured = _patch_create_application(monkeypatch)
|
|
|
|
cli.main(["setup"])
|
|
|
|
assert "app" not in captured
|
|
account = LocalAccountStore(tmp_path / "account.json").load()
|
|
assert account is not None
|
|
assert account.username == "operator"
|
|
|
|
|
|
def test_setup_subcommand_refuses_overwrite_without_confirmation(
|
|
monkeypatch, tmp_path
|
|
) -> None:
|
|
_set_env(monkeypatch, tmp_path)
|
|
store = LocalAccountStore(tmp_path / "account.json")
|
|
original = store.create("operator", "original-password")
|
|
monkeypatch.setattr("builtins.input", lambda prompt="": "n")
|
|
|
|
cli.main(["setup"])
|
|
|
|
assert store.load() == original
|