548 lines
20 KiB
Python
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
|