Files
wehub-resource-sync 3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

389 lines
13 KiB
Python

# SPDX-License-Identifier: MIT
"""Tests for ``winpodx.core.install_state``.
See ``docs/design/AGENT_FIRST_INSTALL_DESIGN.md`` §"Guest install state
(host-side mirror)" for the contract this exercises.
"""
from __future__ import annotations
import json
import stat
from pathlib import Path
from typing import Any
import pytest
from winpodx.core.agent import (
AgentError,
AgentUnavailableError,
ExecResult,
)
from winpodx.core.config import Config
from winpodx.core.install_state import (
PHASE_ORDER,
GuestInstallState,
_parse_markers_json,
_state_cache_path,
fetch_install_state,
)
@pytest.fixture
def cfg() -> Config:
return Config()
class _FakeAgent:
"""Stand-in for AgentClient that scripts ``exec()`` responses.
``responses`` is a list of either ExecResult objects (returned in
order) or Exception instances (raised in order).
"""
def __init__(self, responses: list[Any]) -> None:
self.responses = list(responses)
self.calls: list[str] = []
def exec(self, script: str, *, timeout: int = 60) -> ExecResult:
self.calls.append(script)
if not self.responses:
raise AssertionError("FakeAgent exhausted: extra exec() call")
item = self.responses.pop(0)
if isinstance(item, Exception):
raise item
return item
def _patch_agent(monkeypatch: pytest.MonkeyPatch, fake: _FakeAgent) -> None:
# AgentClient is imported lazily inside install_state's helpers, so
# patch on the agent module itself rather than the import alias.
monkeypatch.setattr(
"winpodx.core.agent.AgentClient",
lambda cfg: fake,
)
# ---------------------------------------------------------------------------
# _parse_markers_json
# ---------------------------------------------------------------------------
class TestParseMarkersJson:
def test_array_of_strings(self) -> None:
raw = '["defender_exclusion.done","agent_ready.done"]'
assert _parse_markers_json(raw) == ["agent_ready.done", "defender_exclusion.done"]
def test_single_string_becomes_singleton(self) -> None:
# PowerShell ConvertTo-Json on a 1-element pipeline emits a bare string.
assert _parse_markers_json('"agent_ready.done"') == ["agent_ready.done"]
def test_empty_array(self) -> None:
assert _parse_markers_json("[]") == []
def test_empty_string(self) -> None:
assert _parse_markers_json("") == []
def test_whitespace_only(self) -> None:
assert _parse_markers_json(" \n") == []
def test_malformed_json_returns_empty(self) -> None:
assert _parse_markers_json("{not json") == []
def test_dedupes_and_sorts(self) -> None:
raw = '["b.done","a.done","b.done"]'
assert _parse_markers_json(raw) == ["a.done", "b.done"]
# ---------------------------------------------------------------------------
# fetch_install_state — happy path / parsing
# ---------------------------------------------------------------------------
class TestFetchInstallStateAgent:
def test_parses_markers_into_steps(self, monkeypatch: pytest.MonkeyPatch, cfg: Config) -> None:
markers = ["defender_exclusion.done", "agent_ready.done"]
fake = _FakeAgent(
[
ExecResult(rc=0, stdout=json.dumps(markers), stderr=""),
ExecResult(rc=0, stdout="", stderr=""), # no install_failure.json
]
)
_patch_agent(monkeypatch, fake)
state = fetch_install_state(cfg)
assert state.agent_reachable is True
assert state.marker_state_cached is False
assert state.overall_status == "running"
assert len(state.steps) == len(PHASE_ORDER)
by_name = {s.name: s for s in state.steps}
assert by_name["defender_exclusion"].status == "done"
assert by_name["agent_ready"].status == "done"
# state_dir_ready is the next un-done step (in PHASE_ORDER) -> running.
# defender_exclusion (done), state_dir_ready (running), token_staged (pending), ...
assert by_name["state_dir_ready"].status == "running"
assert by_name["token_staged"].status == "pending"
assert by_name["install_complete"].status == "pending"
def test_all_done_yields_overall_done(
self, monkeypatch: pytest.MonkeyPatch, cfg: Config
) -> None:
markers = [f"{name}.done" for _phase, name, _label in PHASE_ORDER]
fake = _FakeAgent(
[
ExecResult(rc=0, stdout=json.dumps(markers), stderr=""),
ExecResult(rc=0, stdout="", stderr=""),
]
)
_patch_agent(monkeypatch, fake)
state = fetch_install_state(cfg)
assert state.overall_status == "done"
assert all(s.status == "done" for s in state.steps)
assert state.failure is None
def test_failure_dict_passthrough(self, monkeypatch: pytest.MonkeyPatch, cfg: Config) -> None:
markers = [
"defender_exclusion.done",
"state_dir_ready.done",
"token_staged.done",
"agent_ready.done",
"rdprrap_installed.done",
]
failure = {
"session_id": "abcd1234-5678",
"failed_step": "rdprrap_installed",
"phase": 2,
"attempt": 3,
"max_attempts": 3,
"exit_code": 1,
"error_class": "rdprrap_install_failed",
"error_summary": "rdprrap-install.exe exited 1",
"timestamp_utc": "2026-05-08T09:17:51Z",
}
fake = _FakeAgent(
[
ExecResult(rc=0, stdout=json.dumps(markers), stderr=""),
ExecResult(rc=0, stdout=json.dumps(failure), stderr=""),
]
)
_patch_agent(monkeypatch, fake)
state = fetch_install_state(cfg)
assert state.failure == failure
assert state.overall_status == "failed"
by_name = {s.name: s for s in state.steps}
assert by_name["rdprrap_installed"].status == "failed"
assert by_name["rdprrap_installed"].attempt == 3
def test_malformed_failure_json_drops_silently(
self, monkeypatch: pytest.MonkeyPatch, cfg: Config
) -> None:
markers = ["defender_exclusion.done"]
fake = _FakeAgent(
[
ExecResult(rc=0, stdout=json.dumps(markers), stderr=""),
ExecResult(rc=0, stdout="{not valid json", stderr=""),
]
)
_patch_agent(monkeypatch, fake)
state = fetch_install_state(cfg)
assert state.failure is None
# Without a failure dict, overall_status follows marker logic.
assert state.overall_status == "running"
# ---------------------------------------------------------------------------
# fetch_install_state — cache fallback / cache write
# ---------------------------------------------------------------------------
class TestFetchInstallStateCache:
def test_agent_unreachable_reads_cache(
self, monkeypatch: pytest.MonkeyPatch, cfg: Config
) -> None:
# Pre-populate cache.
cache = _state_cache_path()
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(
json.dumps(
{
"session_id": "cached-session-1",
"overall_status": "running",
"elapsed_seconds": 42.0,
"agent_reachable": True,
"marker_state_cached": False,
"steps": [
{
"phase": 0,
"name": "defender_exclusion",
"status": "done",
"elapsed_seconds": 12.0,
"attempt": 1,
},
],
"failure": None,
}
),
encoding="utf-8",
)
fake = _FakeAgent([AgentUnavailableError("connect refused")])
_patch_agent(monkeypatch, fake)
state = fetch_install_state(cfg)
assert state.agent_reachable is False
assert state.marker_state_cached is True
assert state.session_id == "cached-session-1"
assert len(state.steps) == 1
assert state.steps[0].name == "defender_exclusion"
def test_agent_unreachable_no_cache_returns_unknown(
self, monkeypatch: pytest.MonkeyPatch, cfg: Config
) -> None:
# Cache file deliberately absent (XDG dirs are isolated by conftest).
assert not _state_cache_path().exists()
fake = _FakeAgent([AgentUnavailableError("connect refused")])
_patch_agent(monkeypatch, fake)
state = fetch_install_state(cfg)
assert state.overall_status == "unknown"
assert state.agent_reachable is False
assert state.marker_state_cached is False
assert state.steps == []
assert state.failure is None
def test_unexpected_exception_falls_back_to_cache(
self, monkeypatch: pytest.MonkeyPatch, cfg: Config
) -> None:
# Non-AgentError exception still must not crash the call.
cache = _state_cache_path()
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(
json.dumps(
{
"session_id": None,
"overall_status": "running",
"elapsed_seconds": 0.0,
"agent_reachable": True,
"marker_state_cached": False,
"steps": [],
"failure": None,
}
),
encoding="utf-8",
)
fake = _FakeAgent([RuntimeError("kaboom")])
_patch_agent(monkeypatch, fake)
state = fetch_install_state(cfg)
assert state.agent_reachable is False
assert state.marker_state_cached is True
def test_corrupt_cache_treated_as_missing(
self, monkeypatch: pytest.MonkeyPatch, cfg: Config
) -> None:
cache = _state_cache_path()
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text("not even close to json", encoding="utf-8")
fake = _FakeAgent([AgentUnavailableError("nope")])
_patch_agent(monkeypatch, fake)
state = fetch_install_state(cfg)
assert state.overall_status == "unknown"
assert state.marker_state_cached is False
def test_successful_fetch_writes_cache_with_mode_0600(
self, monkeypatch: pytest.MonkeyPatch, cfg: Config
) -> None:
markers = ["defender_exclusion.done"]
fake = _FakeAgent(
[
ExecResult(rc=0, stdout=json.dumps(markers), stderr=""),
ExecResult(rc=0, stdout="", stderr=""),
]
)
_patch_agent(monkeypatch, fake)
cache = _state_cache_path()
assert not cache.exists()
fetch_install_state(cfg)
assert cache.exists()
mode = stat.S_IMODE(cache.stat().st_mode)
assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
# Round-trip the persisted JSON to confirm it's parseable.
payload = json.loads(cache.read_text(encoding="utf-8"))
assert payload["agent_reachable"] is True
assert payload["marker_state_cached"] is False
assert any(
s["name"] == "defender_exclusion" and s["status"] == "done" for s in payload["steps"]
)
def test_cache_path_respects_xdg_state_home(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
custom = tmp_path / "custom_state"
monkeypatch.setenv("XDG_STATE_HOME", str(custom))
path = _state_cache_path()
assert path == custom / "winpodx" / "last_install_state.json"
# ---------------------------------------------------------------------------
# fetch_install_state — never raises
# ---------------------------------------------------------------------------
class TestFetchNeverRaises:
@pytest.mark.parametrize(
"exc",
[
AgentUnavailableError("refused"),
AgentError("generic agent failure"),
ValueError("unexpected"),
OSError("disk error"),
],
)
def test_first_exec_raises(
self,
monkeypatch: pytest.MonkeyPatch,
cfg: Config,
exc: Exception,
) -> None:
fake = _FakeAgent([exc])
_patch_agent(monkeypatch, fake)
# Call must not propagate.
state = fetch_install_state(cfg)
assert isinstance(state, GuestInstallState)
assert state.agent_reachable is False
def test_garbage_marker_stdout(self, monkeypatch: pytest.MonkeyPatch, cfg: Config) -> None:
fake = _FakeAgent(
[
ExecResult(rc=0, stdout="<<<not json>>>", stderr=""),
ExecResult(rc=0, stdout="", stderr=""),
]
)
_patch_agent(monkeypatch, fake)
state = fetch_install_state(cfg)
# No markers parsed -> all steps pending -> first step is "running".
assert state.agent_reachable is True
assert state.steps[0].status == "running"