Files
Timmy-time-dashboard/tests/infrastructure/world/test_hardening.py
Claude (Opus 4.6) d4e5a5d293
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
[claude] TES3MP server hardening — multi-player stability & anti-grief (#860) (#1321)
2026-03-24 02:13:57 +00:00

548 lines
20 KiB
Python

"""Tests for TES3MP server hardening — multi-player stability & anti-grief.
Covers:
- MultiClientStressRunner (Phase 8: 6+ concurrent clients)
- QuestArbiter (conflict resolution)
- AntiGriefPolicy (rate limiting, blocked actions)
- RecoveryManager (snapshot / restore)
- WorldStateBackup (create / restore / rotate)
- ResourceMonitor (sampling, peak, summary)
"""
from __future__ import annotations
import pytest
from infrastructure.world.adapters.mock import MockWorldAdapter
from infrastructure.world.benchmark.scenarios import BenchmarkScenario
from infrastructure.world.hardening.anti_grief import AntiGriefPolicy
from infrastructure.world.hardening.backup import BackupRecord, WorldStateBackup
from infrastructure.world.hardening.monitor import ResourceMonitor, ResourceSnapshot
from infrastructure.world.hardening.quest_arbiter import (
QuestArbiter,
QuestStage,
)
from infrastructure.world.hardening.recovery import RecoveryManager, WorldSnapshot
from infrastructure.world.hardening.stress import (
MultiClientStressRunner,
StressTestReport,
)
from infrastructure.world.types import CommandInput
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_SIMPLE_SCENARIO = BenchmarkScenario(
name="Stress Smoke",
description="Minimal scenario for stress testing",
start_location="Seyda Neen",
entities=["Guard"],
events=["player_spawned"],
max_cycles=3,
tags=["stress"],
)
# ---------------------------------------------------------------------------
# MultiClientStressRunner
# ---------------------------------------------------------------------------
class TestMultiClientStressRunner:
def test_phase8_requirement_met(self):
runner = MultiClientStressRunner(client_count=6)
assert runner.meets_phase8_requirement is True
def test_phase8_requirement_not_met(self):
runner = MultiClientStressRunner(client_count=5)
assert runner.meets_phase8_requirement is False
def test_invalid_client_count(self):
with pytest.raises(ValueError):
MultiClientStressRunner(client_count=0)
@pytest.mark.asyncio
async def test_run_six_clients(self):
runner = MultiClientStressRunner(client_count=6, cycles_per_client=3)
report = await runner.run(_SIMPLE_SCENARIO)
assert isinstance(report, StressTestReport)
assert report.client_count == 6
assert len(report.results) == 6
assert report.all_passed is True
@pytest.mark.asyncio
async def test_all_clients_complete_cycles(self):
runner = MultiClientStressRunner(client_count=6, cycles_per_client=4)
report = await runner.run(_SIMPLE_SCENARIO)
for result in report.results:
assert result.cycles_completed == 4
assert result.actions_taken == 4
assert result.errors == []
@pytest.mark.asyncio
async def test_report_has_timestamp(self):
runner = MultiClientStressRunner(client_count=2, cycles_per_client=1)
report = await runner.run(_SIMPLE_SCENARIO)
assert report.timestamp
@pytest.mark.asyncio
async def test_report_summary_string(self):
runner = MultiClientStressRunner(client_count=2, cycles_per_client=1)
report = await runner.run(_SIMPLE_SCENARIO)
summary = report.summary()
assert "Stress Smoke" in summary
assert "Clients: 2" in summary
@pytest.mark.asyncio
async def test_single_client(self):
runner = MultiClientStressRunner(client_count=1, cycles_per_client=2)
report = await runner.run(_SIMPLE_SCENARIO)
assert report.success_count == 1
@pytest.mark.asyncio
async def test_client_ids_are_unique(self):
runner = MultiClientStressRunner(client_count=6, cycles_per_client=1)
report = await runner.run(_SIMPLE_SCENARIO)
ids = [r.client_id for r in report.results]
assert len(ids) == len(set(ids))
# ---------------------------------------------------------------------------
# QuestArbiter
# ---------------------------------------------------------------------------
class TestQuestArbiter:
def test_first_claim_granted(self):
arbiter = QuestArbiter()
assert arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE) is True
def test_conflict_rejected(self):
arbiter = QuestArbiter()
arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
assert arbiter.claim("bob", "fargoth_ring", QuestStage.ACTIVE) is False
def test_conflict_recorded(self):
arbiter = QuestArbiter()
arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
arbiter.claim("bob", "fargoth_ring", QuestStage.ACTIVE)
assert arbiter.conflict_count == 1
assert arbiter.conflicts[0].winner == "alice"
assert arbiter.conflicts[0].loser == "bob"
def test_same_player_can_update_own_lock(self):
arbiter = QuestArbiter()
arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
# Alice updates her own lock — no conflict
assert arbiter.claim("alice", "fargoth_ring", QuestStage.COMPLETED) is True
assert arbiter.conflict_count == 0
def test_release_frees_quest(self):
arbiter = QuestArbiter()
arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
arbiter.release("alice", "fargoth_ring")
# Bob can now claim
assert arbiter.claim("bob", "fargoth_ring", QuestStage.ACTIVE) is True
def test_release_wrong_player_fails(self):
arbiter = QuestArbiter()
arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
assert arbiter.release("bob", "fargoth_ring") is False
assert arbiter.active_lock_count == 1
def test_advance_updates_stage(self):
arbiter = QuestArbiter()
arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
assert arbiter.advance("alice", "fargoth_ring", QuestStage.COMPLETED) is True
# Lock should be released after COMPLETED
assert arbiter.active_lock_count == 0
def test_advance_failed_releases_lock(self):
arbiter = QuestArbiter()
arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
arbiter.advance("alice", "fargoth_ring", QuestStage.FAILED)
assert arbiter.active_lock_count == 0
def test_advance_wrong_player_fails(self):
arbiter = QuestArbiter()
arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
assert arbiter.advance("bob", "fargoth_ring", QuestStage.COMPLETED) is False
def test_get_stage(self):
arbiter = QuestArbiter()
arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
assert arbiter.get_stage("fargoth_ring") == QuestStage.ACTIVE
def test_get_stage_unknown_quest(self):
assert QuestArbiter().get_stage("nonexistent") is None
def test_lock_holder(self):
arbiter = QuestArbiter()
arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
assert arbiter.lock_holder("fargoth_ring") == "alice"
def test_active_lock_count(self):
arbiter = QuestArbiter()
arbiter.claim("alice", "quest_a", QuestStage.ACTIVE)
arbiter.claim("bob", "quest_b", QuestStage.ACTIVE)
assert arbiter.active_lock_count == 2
def test_multiple_quests_independent(self):
arbiter = QuestArbiter()
arbiter.claim("alice", "quest_a", QuestStage.ACTIVE)
# Bob can claim a different quest without conflict
assert arbiter.claim("bob", "quest_b", QuestStage.ACTIVE) is True
assert arbiter.conflict_count == 0
# ---------------------------------------------------------------------------
# AntiGriefPolicy
# ---------------------------------------------------------------------------
class TestAntiGriefPolicy:
def test_permitted_action_returns_none(self):
policy = AntiGriefPolicy()
cmd = CommandInput(action="move", target="north")
assert policy.check("player-01", cmd) is None
def test_blocked_action_rejected(self):
policy = AntiGriefPolicy()
cmd = CommandInput(action="destroy", target="barrel")
result = policy.check("player-01", cmd)
assert result is not None
assert "destroy" in result.message
assert policy.violation_count == 1
def test_custom_blocked_action(self):
policy = AntiGriefPolicy(blocked_actions={"teleport"})
cmd = CommandInput(action="teleport")
result = policy.check("player-01", cmd)
assert result is not None
def test_is_blocked_action(self):
policy = AntiGriefPolicy()
assert policy.is_blocked_action("kill_npc") is True
assert policy.is_blocked_action("move") is False
def test_rate_limit_exceeded(self):
policy = AntiGriefPolicy(max_actions_per_window=3, window_seconds=60.0)
cmd = CommandInput(action="move")
# First 3 actions should pass
for _ in range(3):
assert policy.check("player-01", cmd) is None
# 4th action should be blocked
result = policy.check("player-01", cmd)
assert result is not None
assert "Rate limit" in result.message
def test_rate_limit_per_player(self):
policy = AntiGriefPolicy(max_actions_per_window=2, window_seconds=60.0)
cmd = CommandInput(action="move")
# player-01 exhausts limit
policy.check("player-01", cmd)
policy.check("player-01", cmd)
assert policy.check("player-01", cmd) is not None
# player-02 is unaffected
assert policy.check("player-02", cmd) is None
def test_reset_player_clears_bucket(self):
policy = AntiGriefPolicy(max_actions_per_window=2, window_seconds=60.0)
cmd = CommandInput(action="move")
policy.check("player-01", cmd)
policy.check("player-01", cmd)
policy.reset_player("player-01")
# Should be allowed again
assert policy.check("player-01", cmd) is None
def test_violations_list(self):
policy = AntiGriefPolicy()
policy.check("player-01", CommandInput(action="steal"))
assert len(policy.violations) == 1
assert policy.violations[0].player_id == "player-01"
assert policy.violations[0].action == "steal"
def test_all_default_blocked_actions(self):
policy = AntiGriefPolicy()
for action in ("destroy", "kill_npc", "steal", "grief", "cheat", "spawn_item"):
assert policy.is_blocked_action(action), f"{action!r} should be blocked"
# ---------------------------------------------------------------------------
# RecoveryManager
# ---------------------------------------------------------------------------
class TestRecoveryManager:
def test_snapshot_creates_file(self, tmp_path):
path = tmp_path / "recovery.jsonl"
mgr = RecoveryManager(path)
adapter = MockWorldAdapter(location="Vivec")
adapter.connect()
snap = mgr.snapshot(adapter)
assert path.exists()
assert snap.location == "Vivec"
def test_snapshot_returns_world_snapshot(self, tmp_path):
mgr = RecoveryManager(tmp_path / "recovery.jsonl")
adapter = MockWorldAdapter(location="Balmora", entities=["Guard"])
adapter.connect()
snap = mgr.snapshot(adapter)
assert isinstance(snap, WorldSnapshot)
assert snap.location == "Balmora"
assert "Guard" in snap.entities
def test_restore_latest(self, tmp_path):
mgr = RecoveryManager(tmp_path / "recovery.jsonl")
adapter = MockWorldAdapter(location="Seyda Neen")
adapter.connect()
mgr.snapshot(adapter)
# Change location and restore
adapter._location = "Somewhere Else"
result = mgr.restore(adapter)
assert result is not None
assert adapter._location == "Seyda Neen"
def test_restore_by_id(self, tmp_path):
mgr = RecoveryManager(tmp_path / "recovery.jsonl")
adapter = MockWorldAdapter(location="Ald'ruhn")
adapter.connect()
mgr.snapshot(adapter, snapshot_id="snap-001")
mgr.snapshot(adapter) # second snapshot
adapter._location = "Elsewhere"
result = mgr.restore(adapter, snapshot_id="snap-001")
assert result is not None
assert result.snapshot_id == "snap-001"
def test_restore_missing_id_returns_none(self, tmp_path):
mgr = RecoveryManager(tmp_path / "recovery.jsonl")
adapter = MockWorldAdapter()
adapter.connect()
mgr.snapshot(adapter)
result = mgr.restore(adapter, snapshot_id="nonexistent")
assert result is None
def test_restore_empty_history_returns_none(self, tmp_path):
mgr = RecoveryManager(tmp_path / "recovery.jsonl")
adapter = MockWorldAdapter()
adapter.connect()
assert mgr.restore(adapter) is None
def test_load_history_most_recent_first(self, tmp_path):
mgr = RecoveryManager(tmp_path / "recovery.jsonl")
for i in range(3):
adapter = MockWorldAdapter(location=f"location-{i}")
adapter.connect()
mgr.snapshot(adapter)
history = mgr.load_history()
assert len(history) == 3
# Most recent was location-2
assert history[0]["location"] == "location-2"
def test_latest_returns_snapshot(self, tmp_path):
mgr = RecoveryManager(tmp_path / "recovery.jsonl")
adapter = MockWorldAdapter(location="Gnisis")
adapter.connect()
mgr.snapshot(adapter)
latest = mgr.latest()
assert latest is not None
assert latest.location == "Gnisis"
def test_max_snapshots_trim(self, tmp_path):
mgr = RecoveryManager(tmp_path / "recovery.jsonl", max_snapshots=3)
for i in range(5):
adapter = MockWorldAdapter(location=f"loc-{i}")
adapter.connect()
mgr.snapshot(adapter)
assert mgr.snapshot_count == 3
def test_snapshot_count(self, tmp_path):
mgr = RecoveryManager(tmp_path / "recovery.jsonl")
adapter = MockWorldAdapter()
adapter.connect()
for _ in range(4):
mgr.snapshot(adapter)
assert mgr.snapshot_count == 4
# ---------------------------------------------------------------------------
# WorldStateBackup
# ---------------------------------------------------------------------------
class TestWorldStateBackup:
def test_create_writes_file(self, tmp_path):
backup = WorldStateBackup(tmp_path / "backups")
adapter = MockWorldAdapter(location="Tel Vos")
adapter.connect()
record = backup.create(adapter)
assert (tmp_path / "backups" / f"{record.backup_id}.json").exists()
def test_create_returns_record(self, tmp_path):
backup = WorldStateBackup(tmp_path / "backups")
adapter = MockWorldAdapter(location="Caldera", entities=["Merchant"])
adapter.connect()
record = backup.create(adapter, notes="test note")
assert isinstance(record, BackupRecord)
assert record.location == "Caldera"
assert record.entity_count == 1
assert record.notes == "test note"
assert record.size_bytes > 0
def test_restore_from_backup(self, tmp_path):
backup = WorldStateBackup(tmp_path / "backups")
adapter = MockWorldAdapter(location="Ald-ruhn")
adapter.connect()
record = backup.create(adapter)
adapter._location = "Nowhere"
assert backup.restore(adapter, record.backup_id) is True
assert adapter._location == "Ald-ruhn"
def test_restore_missing_backup(self, tmp_path):
backup = WorldStateBackup(tmp_path / "backups")
adapter = MockWorldAdapter()
adapter.connect()
assert backup.restore(adapter, "backup_nonexistent") is False
def test_list_backups_most_recent_first(self, tmp_path):
backup = WorldStateBackup(tmp_path / "backups")
adapter = MockWorldAdapter()
adapter.connect()
ids = []
for i in range(3):
adapter._location = f"loc-{i}"
r = backup.create(adapter)
ids.append(r.backup_id)
listed = backup.list_backups()
assert len(listed) == 3
# Most recent last created → first in list
assert listed[0].backup_id == ids[-1]
def test_latest_returns_most_recent(self, tmp_path):
backup = WorldStateBackup(tmp_path / "backups")
adapter = MockWorldAdapter(location="Vivec")
adapter.connect()
backup.create(adapter)
adapter._location = "Molag Mar"
record = backup.create(adapter)
assert backup.latest().backup_id == record.backup_id
def test_empty_list_returns_empty(self, tmp_path):
backup = WorldStateBackup(tmp_path / "backups")
assert backup.list_backups() == []
assert backup.latest() is None
def test_rotation_removes_oldest(self, tmp_path):
backup = WorldStateBackup(tmp_path / "backups", max_backups=3)
adapter = MockWorldAdapter()
adapter.connect()
records = [backup.create(adapter) for _ in range(5)]
listed = backup.list_backups()
assert len(listed) == 3
# Oldest two should be gone
listed_ids = {r.backup_id for r in listed}
assert records[0].backup_id not in listed_ids
assert records[1].backup_id not in listed_ids
# Newest three should be present
for rec in records[2:]:
assert rec.backup_id in listed_ids
# ---------------------------------------------------------------------------
# ResourceMonitor
# ---------------------------------------------------------------------------
class TestResourceMonitor:
def test_sample_returns_snapshot(self):
monitor = ResourceMonitor()
snap = monitor.sample()
assert isinstance(snap, ResourceSnapshot)
assert snap.timestamp
def test_snapshot_has_disk_fields(self):
monitor = ResourceMonitor(watch_path=".")
snap = monitor.sample()
# Disk should be available on any OS
assert snap.disk_used_gb >= 0
assert snap.disk_total_gb > 0
def test_history_grows(self):
monitor = ResourceMonitor()
monitor.sample()
monitor.sample()
assert len(monitor.history) == 2
def test_history_capped(self):
monitor = ResourceMonitor(max_history=3)
for _ in range(5):
monitor.sample()
assert len(monitor.history) == 3
def test_sample_n(self):
monitor = ResourceMonitor()
results = monitor.sample_n(4, interval_s=0)
assert len(results) == 4
assert all(isinstance(s, ResourceSnapshot) for s in results)
def test_peak_cpu_no_samples(self):
monitor = ResourceMonitor()
assert monitor.peak_cpu() == -1.0
def test_peak_memory_no_samples(self):
monitor = ResourceMonitor()
assert monitor.peak_memory_mb() == -1.0
def test_summary_no_samples(self):
monitor = ResourceMonitor()
assert "no samples" in monitor.summary()
def test_summary_with_samples(self):
monitor = ResourceMonitor()
monitor.sample()
summary = monitor.summary()
assert "ResourceMonitor" in summary
assert "samples" in summary
def test_history_is_copy(self):
monitor = ResourceMonitor()
monitor.sample()
history = monitor.history
history.clear()
assert len(monitor.history) == 1 # original unaffected
# ---------------------------------------------------------------------------
# Module-level import test
# ---------------------------------------------------------------------------
class TestHardeningModuleImport:
def test_all_exports_importable(self):
from infrastructure.world.hardening import (
AntiGriefPolicy,
MultiClientStressRunner,
QuestArbiter,
RecoveryManager,
ResourceMonitor,
WorldStateBackup,
)
for cls in (
AntiGriefPolicy,
MultiClientStressRunner,
QuestArbiter,
RecoveryManager,
ResourceMonitor,
WorldStateBackup,
):
assert cls is not None