open-range / tests /test_console.py
Lars Talian
Wire console to real episode state
e37c7b1
"""Tests for the operator debugging console.
Uses Starlette's TestClient against the OpenEnv app with console router.
No Docker dependency.
The console prefers live WebSocket session state, then published reset/step
state from real traffic, then a local fallback env for dev/test use.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from starlette.testclient import TestClient
from open_range.protocols import SnapshotSpec
from open_range.server.app import create_app
from open_range.server.console import clear_episode, clear_history, record_action
from open_range.server.environment import RangeEnvironment
_TEST_SNAPSHOT = SnapshotSpec(
topology={"hosts": ["attacker", "siem"]},
flags=[],
golden_path=[],
task={
"red_briefing": "Console test mode.",
"blue_briefing": "Console test mode.",
},
)
@pytest.fixture()
def client():
"""Create a TestClient with a shared env on app.state for console API."""
app = create_app()
# Store a shared env so console API endpoints can access state
env = RangeEnvironment(docker_available=False)
app.state.env = env
clear_episode()
clear_history()
yield TestClient(app)
clear_episode()
clear_history()
@pytest.fixture()
def env(client: TestClient) -> RangeEnvironment:
"""Return the shared env stored on app.state."""
return client.app.state.env
# ===================================================================
# GET /console -- HTML page
# ===================================================================
class TestConsolePage:
def test_returns_html(self, client: TestClient):
resp = client.get("/console")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
def test_html_contains_title(self, client: TestClient):
resp = client.get("/console")
assert "OpenRange Operator Console" in resp.text
def test_trailing_slash(self, client: TestClient):
resp = client.get("/console/")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
# ===================================================================
# GET /console/api/snapshot
# ===================================================================
class TestSnapshotAPI:
def test_returns_json(self, client: TestClient):
resp = client.get("/console/api/snapshot")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
def test_snapshot_before_reset(self, client: TestClient):
"""Before reset, snapshot should have null id."""
data = client.get("/console/api/snapshot").json()
assert data["id"] is None
def test_snapshot_after_reset(self, client: TestClient, env: RangeEnvironment):
env.reset(snapshot=_TEST_SNAPSHOT, episode_id="snap_test_1")
data = client.get("/console/api/snapshot").json()
assert data["id"] == "snap_test_1"
assert "hosts" in data
assert "zones" in data
assert "vuln_count" in data
assert "tier" in data
def test_snapshot_no_truth_graph_or_flags(self, client: TestClient, env: RangeEnvironment):
"""Snapshot API must NOT leak truth_graph or flag values."""
env.reset(snapshot=_TEST_SNAPSHOT)
data = client.get("/console/api/snapshot").json()
assert "truth_graph" not in data
assert "flags" not in data
assert "golden_path" not in data
def test_http_reset_publishes_snapshot_to_console(self):
with patch(
"open_range.server.environment.RangeEnvironment._select_snapshot",
return_value=_TEST_SNAPSHOT,
), patch(
"open_range.server.environment.RangeEnvironment._ensure_clean_reset_path",
):
app = create_app()
clear_episode()
clear_history()
client = TestClient(app)
resp = client.post("/reset", json={"episode_id": "http_reset_1"})
assert resp.status_code == 200
data = client.get("/console/api/snapshot").json()
assert data["id"] == "http_reset_1"
assert data["state_scope"] == "published_episode"
assert data["hosts"] == ["attacker", "siem"]
clear_episode()
clear_history()
# ===================================================================
# GET /console/api/episode
# ===================================================================
class TestEpisodeAPI:
def test_returns_json(self, client: TestClient):
resp = client.get("/console/api/episode")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
def test_episode_fields(self, client: TestClient, env: RangeEnvironment):
env.reset(snapshot=_TEST_SNAPSHOT)
data = client.get("/console/api/episode").json()
assert "step_count" in data
assert "flags_found" in data
assert "mode" in data
assert "services_status" in data
def test_episode_step_count_updates(self, client: TestClient, env: RangeEnvironment):
from open_range.server.models import RangeAction
env.reset(snapshot=_TEST_SNAPSHOT)
data = client.get("/console/api/episode").json()
assert data["step_count"] == 0
env.step(RangeAction(command="nmap web", mode="red"))
data = client.get("/console/api/episode").json()
assert data["step_count"] == 1
def test_http_reset_publishes_episode_to_console(self):
with patch(
"open_range.server.environment.RangeEnvironment._select_snapshot",
return_value=_TEST_SNAPSHOT,
), patch(
"open_range.server.environment.RangeEnvironment._ensure_clean_reset_path",
):
app = create_app()
clear_episode()
clear_history()
client = TestClient(app)
client.post("/reset", json={"episode_id": "http_reset_2"})
data = client.get("/console/api/episode").json()
assert data["step_count"] == 0
assert data["mode"] == "red"
assert data["state_scope"] == "published_episode"
clear_episode()
clear_history()
# ===================================================================
# GET /console/api/history
# ===================================================================
class TestHistoryAPI:
def test_returns_list(self, client: TestClient):
resp = client.get("/console/api/history")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
def test_history_empty_initially(self, client: TestClient):
data = client.get("/console/api/history").json()
assert data == []
def test_history_records_actions(self, client: TestClient):
import time
record_action({"step": 1, "command": "nmap -sV web", "mode": "red", "time": time.time()})
record_action({"step": 2, "command": "tail -f /var/log/syslog", "mode": "blue", "time": time.time()})
data = client.get("/console/api/history").json()
assert len(data) == 2
# Newest first
assert data[0]["mode"] == "blue"
assert data[1]["mode"] == "red"
def test_history_has_timestamps(self, client: TestClient):
import time
record_action({"step": 1, "command": "nmap web", "mode": "red", "time": time.time()})
data = client.get("/console/api/history").json()
assert len(data) == 1
assert "time" in data[0]
assert isinstance(data[0]["time"], float)
def test_history_updates_from_environment_steps(self, client: TestClient, env: RangeEnvironment):
from open_range.server.models import RangeAction
env.reset(snapshot=_TEST_SNAPSHOT)
env.step(RangeAction(command="nmap -sV web", mode="red"))
data = client.get("/console/api/history").json()
assert len(data) == 2
assert data[0]["command"] == "nmap -sV web"
assert data[0]["mode"] == "red"
assert data[1]["command"] == "reset"
assert data[1]["mode"] == "system"
def test_history_records_meta_step_commands(self, client: TestClient, env: RangeEnvironment):
from open_range.server.models import RangeAction
env.reset(snapshot=_TEST_SNAPSHOT)
env.step(RangeAction(command="submit_finding suspicious scan on web", mode="blue"))
data = client.get("/console/api/history").json()
assert data[0]["command"] == "submit_finding suspicious scan on web"
assert data[0]["mode"] == "blue"
def test_history_reset_clears_prior_entries_and_records_reset(self, client: TestClient, env: RangeEnvironment):
import time
record_action({"step": 99, "command": "old", "mode": "red", "time": time.time()})
env.reset(snapshot=_TEST_SNAPSHOT, episode_id="history_reset")
data = client.get("/console/api/history").json()
assert len(data) == 1
assert data[0]["command"] == "reset"
assert data[0]["mode"] == "system"
assert data[0]["episode_id"] == "history_reset"
def test_history_max_20(self, client: TestClient):
"""History API should return at most 20 entries."""
import time
for i in range(25):
record_action({"step": i, "command": f"cmd_{i}", "mode": "red", "time": time.time()})
data = client.get("/console/api/history").json()
assert len(data) == 20
def test_http_reset_records_history(self):
with patch(
"open_range.server.environment.RangeEnvironment._select_snapshot",
return_value=_TEST_SNAPSHOT,
), patch(
"open_range.server.environment.RangeEnvironment._ensure_clean_reset_path",
):
app = create_app()
clear_episode()
clear_history()
client = TestClient(app)
client.post("/reset", json={"episode_id": "http_reset_3"})
data = client.get("/console/api/history").json()
assert len(data) == 1
assert data[0]["command"] == "reset"
assert data[0]["mode"] == "system"
clear_episode()
clear_history()