Files
kernalix7--winpodx/tests/test_agent_install_state.py
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

391 lines
13 KiB
Python

# SPDX-License-Identifier: MIT
"""Tests for ``core/agent_install_state.py``.
Covers marker primitives, ``RetryCounter``, the redactor, and
``write_install_failure``. See ``docs/design/AGENT_FIRST_INSTALL_DESIGN.md``
Section "Schemas" + "Security threat model" for the contract under test.
"""
from __future__ import annotations
import json
import re
import threading
from pathlib import Path
import pytest
from winpodx.core.agent_install_state import (
RetryCounter,
atomic_write_marker,
list_completed_steps,
read_marker,
redact_log_line,
redact_payload,
write_install_failure,
)
# ---------------------------------------------------------------------------
# Marker primitives
# ---------------------------------------------------------------------------
def test_atomic_write_marker_produces_empty_file(tmp_path: Path) -> None:
target = tmp_path / "step1.done"
atomic_write_marker(target)
assert target.exists()
assert target.is_file()
assert target.stat().st_size == 0
def test_read_marker_true_for_existing_file(tmp_path: Path) -> None:
target = tmp_path / "ok.done"
target.write_text("")
assert read_marker(target) is True
def test_read_marker_false_for_missing_file(tmp_path: Path) -> None:
assert read_marker(tmp_path / "nope.done") is False
def test_list_completed_steps_returns_sorted_names(tmp_path: Path) -> None:
for name in ("zeta", "alpha", "beta"):
(tmp_path / f"{name}.done").write_text("")
(tmp_path / "ignored.txt").write_text("nope")
(tmp_path / "subdir").mkdir()
assert list_completed_steps(tmp_path) == ["alpha", "beta", "zeta"]
def test_list_completed_steps_missing_dir(tmp_path: Path) -> None:
assert list_completed_steps(tmp_path / "does-not-exist") == []
def test_concurrent_atomic_writes_do_not_corrupt(tmp_path: Path) -> None:
target = tmp_path / "race.done"
errors: list[BaseException] = []
def worker() -> None:
try:
for _ in range(20):
atomic_write_marker(target)
except BaseException as exc: # pragma: no cover - surfaced via assert
errors.append(exc)
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors
assert target.is_file()
assert target.stat().st_size == 0
leftovers = [p for p in tmp_path.iterdir() if p.name != "race.done"]
assert leftovers == []
# ---------------------------------------------------------------------------
# RetryCounter
# ---------------------------------------------------------------------------
def test_retry_counter_get_missing_file_returns_zero(tmp_path: Path) -> None:
counter = RetryCounter(tmp_path / "retry_counts.json")
assert counter.get("rdprrap_install") == 0
assert counter.all() == {}
def test_retry_counter_increment_persists_across_reload(tmp_path: Path) -> None:
path = tmp_path / "retry_counts.json"
counter = RetryCounter(path)
assert counter.increment("step_a") == 1
assert counter.increment("step_a") == 2
assert counter.increment("step_b") == 1
reloaded = RetryCounter(path)
assert reloaded.get("step_a") == 2
assert reloaded.get("step_b") == 1
assert reloaded.all() == {"step_a": 2, "step_b": 1}
def test_retry_counter_atomic_update_no_partial_state(tmp_path: Path) -> None:
"""Concurrent increments must always leave a fully-formed JSON file."""
path = tmp_path / "retry_counts.json"
counter = RetryCounter(path)
counter.increment("seed") # ensure file exists
errors: list[BaseException] = []
def worker() -> None:
try:
for _ in range(50):
counter.increment("hot")
except BaseException as exc: # pragma: no cover - surfaced via assert
errors.append(exc)
threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors
# File must always be valid JSON; final count is at least seed entry.
parsed = json.loads(path.read_text())
assert isinstance(parsed, dict)
assert parsed.get("seed") == 1
assert parsed.get("hot", 0) >= 1 # racy increments may collapse, but no corruption
leftovers = [p for p in tmp_path.iterdir() if p.name != "retry_counts.json"]
assert leftovers == []
def test_retry_counter_corrupt_json_treated_as_empty(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
path = tmp_path / "retry_counts.json"
path.write_text("{not json")
counter = RetryCounter(path)
with caplog.at_level("WARNING"):
assert counter.get("anything") == 0
assert counter.all() == {}
assert any("corrupt JSON" in rec.message for rec in caplog.records)
def test_retry_counter_reset_only_one_step(tmp_path: Path) -> None:
counter = RetryCounter(tmp_path / "retry_counts.json")
counter.increment("a")
counter.increment("a")
counter.increment("b")
counter.reset("a")
assert counter.get("a") == 0
assert counter.get("b") == 1
# ---------------------------------------------------------------------------
# Redactor
# ---------------------------------------------------------------------------
def test_redact_net_user_pattern() -> None:
out = redact_log_line("net user kernalix7 SecretP@ss")
assert out.endswith("<REDACTED>")
assert "SecretP@ss" not in out
assert "kernalix7" in out # username is fine to keep
def test_redact_authorization_bearer_header() -> None:
out = redact_log_line("Authorization: Bearer abc123def456")
assert out.endswith("<REDACTED>")
assert "abc123def456" not in out
def test_redact_authorization_bearer_case_insensitive() -> None:
out = redact_log_line("authorization: bearer ZZZTOKENZZZ")
assert "ZZZTOKENZZZ" not in out
assert "<REDACTED>" in out
def test_redact_password_kv_pattern() -> None:
out = redact_log_line("foo password=p@ssw0rd&other=keep")
assert "p@ssw0rd" not in out
assert "password=<REDACTED>" in out
# We deliberately leave 'other=keep' alone.
assert "other=keep" in out
def test_redact_token_kv_case_insensitive() -> None:
out = redact_log_line("TOKEN=xyz extra")
assert "xyz" not in out
assert "TOKEN=<REDACTED>" in out
def test_redact_apikey_variants() -> None:
for raw in ("apikey=hunter2", "ApiKey=hunter2", "API_KEY=hunter2"):
out = redact_log_line(raw)
assert "hunter2" not in out, raw
def test_redact_base64_blob_60_chars() -> None:
blob = "A" * 60
out = redact_log_line(f"prefix {blob} suffix")
assert blob not in out
assert "<BASE64-REDACTED>" in out
def test_redact_base64_boundary_40_chars() -> None:
blob = "B" * 40
out = redact_log_line(blob)
assert out == "<BASE64-REDACTED>"
def test_redact_base64_below_threshold_passes_through() -> None:
blob = "C" * 39
assert redact_log_line(blob) == blob
def test_redact_multi_pattern_line() -> None:
line = "Authorization: Bearer abc123 password=hunter2"
out = redact_log_line(line)
assert "abc123" not in out
assert "hunter2" not in out
assert out.count("<REDACTED>") == 2
def test_redact_empty_string() -> None:
assert redact_log_line("") == ""
def test_redact_non_string_input_coerced() -> None:
# Caller's contract says this shouldn't happen; we coerce defensively
# rather than crash a logging path.
out = redact_log_line(12345) # type: ignore[arg-type]
assert out == "12345"
def test_redact_payload_deep_walks_dict() -> None:
payload = {
"error_summary": "Authorization: Bearer abc123def456ghi789",
"nested": {"hint": "password=hunter2"},
"exit_code": 1,
}
out = redact_payload(payload)
assert "abc123def456ghi789" not in out["error_summary"]
assert "<REDACTED>" in out["nested"]["hint"]
assert out["exit_code"] == 1
# original is not mutated
assert "abc123def456ghi789" in payload["error_summary"]
def test_redact_payload_deep_walks_list() -> None:
payload = {
"last_log_lines": [
"net user kernalix7 P@ss",
"ok line",
{"deep": "TOKEN=xxx"},
],
}
out = redact_payload(payload)
assert "P@ss" not in out["last_log_lines"][0]
assert out["last_log_lines"][1] == "ok line"
assert "xxx" not in out["last_log_lines"][2]["deep"]
# ---------------------------------------------------------------------------
# write_install_failure
# ---------------------------------------------------------------------------
_REPO_ROOT = Path(__file__).resolve().parents[1]
_INSTALL_FAILURE_SCHEMA = _REPO_ROOT / "docs" / "design" / "install_failure.schema.json"
def _valid_payload() -> dict:
return {
"session_id": "abcd1234-1111-2222-3333-444455556666",
"failed_step": "multi_session_activate",
"phase": 2,
"attempt": 3,
"max_attempts": 3,
"exit_code": 1,
"error_class": "rdprrap_activate_failed",
"error_summary": "Authorization: Bearer abc123def456ghi789jkl012mno345",
"timestamp_utc": "2026-05-08T09:17:51Z",
"environment": {
"windows_build": "10.0.26100.0",
"disk_fs": "ntfs",
"free_bytes": 12345678901,
"ram_total_mb": 8192,
},
"last_log_lines": ["net user kernalix7 SecretP@ss"],
}
def test_write_install_failure_happy_path(tmp_path: Path) -> None:
out_path = tmp_path / "install_failure.json"
write_install_failure(out_path, _valid_payload())
written = json.loads(out_path.read_text())
assert "abc123def456ghi789jkl012mno345" not in written["error_summary"]
assert "SecretP@ss" not in written["last_log_lines"][0]
assert written["session_id"] == _valid_payload()["session_id"]
def test_write_install_failure_rejects_missing_required_field(tmp_path: Path) -> None:
payload = _valid_payload()
del payload["session_id"]
out_path = tmp_path / "install_failure.json"
with pytest.raises(ValueError, match="session_id"):
write_install_failure(out_path, payload)
assert not out_path.exists()
def test_write_install_failure_no_schema_still_redacts(tmp_path: Path) -> None:
out_path = tmp_path / "install_failure.json"
payload = _valid_payload()
write_install_failure(out_path, payload, schema_path=None)
written = json.loads(out_path.read_text())
assert "<REDACTED>" in written["error_summary"]
def test_write_install_failure_rejects_non_dict(tmp_path: Path) -> None:
out_path = tmp_path / "install_failure.json"
with pytest.raises(ValueError):
write_install_failure(out_path, "not a dict") # type: ignore[arg-type]
assert not out_path.exists()
def test_write_install_failure_full_schema_happy_path(tmp_path: Path) -> None:
pytest.importorskip("jsonschema")
if not _INSTALL_FAILURE_SCHEMA.is_file():
pytest.skip("install_failure.schema.json not present in this checkout")
out_path = tmp_path / "install_failure.json"
write_install_failure(out_path, _valid_payload(), schema_path=_INSTALL_FAILURE_SCHEMA)
written = json.loads(out_path.read_text())
assert "<REDACTED>" in written["error_summary"]
def test_write_install_failure_full_schema_rejects_unknown_field(tmp_path: Path) -> None:
pytest.importorskip("jsonschema")
if not _INSTALL_FAILURE_SCHEMA.is_file():
pytest.skip("install_failure.schema.json not present in this checkout")
payload = _valid_payload()
payload["surprise_field"] = "boom"
out_path = tmp_path / "install_failure.json"
with pytest.raises(ValueError):
write_install_failure(out_path, payload, schema_path=_INSTALL_FAILURE_SCHEMA)
assert not out_path.exists()
# ---------------------------------------------------------------------------
# Hypothesis property test
# ---------------------------------------------------------------------------
def test_redact_round_trip_invariant() -> None:
pytest.importorskip("hypothesis")
from hypothesis import given, settings # type: ignore[import-not-found]
from hypothesis import strategies as st # type: ignore[import-not-found]
# Substring patterns whose presence would mean the redactor missed a
# leak. We deliberately do NOT include the "kv secret" pattern here:
# the `password=`/`token=`/`apikey=` rule replaces only the value, so
# the literal "password=" still appears in output by design.
leak_patterns = [
re.compile(r"net user\s+\S+\s+(?!<REDACTED>)\S+", re.IGNORECASE),
re.compile(r"Authorization:\s*Bearer\s+(?!<REDACTED>)\S+", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9+/=])[A-Za-z0-9+/]{40,}={0,2}(?![A-Za-z0-9+/=])"),
]
@given(st.text(max_size=400))
@settings(max_examples=200, deadline=None)
def _check(line: str) -> None:
out = redact_log_line(line)
for pat in leak_patterns:
# Allow the pattern only if it matches a literal redaction marker
# we put in ourselves.
for match in pat.finditer(out):
assert "<REDACTED>" in match.group(0) or "<BASE64-REDACTED>" in match.group(0), (
f"leak {match.group(0)!r} survived redaction of {line!r} -> {out!r}"
)
_check()