feat(l4d2-web): backend for RCON console with persisted transcript
- POST /servers/<id>/console runs a command via rcon.execute_command and persists every outcome (success / empty / error) to command_history. - GET /servers/<id>/console/history returns paginated newest-first JSON for client-side up-arrow recall. - server_detail() now passes the last 50 history rows as console_history for server-side replay on page load. - 404 on ownership mismatch — no admin override. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c4dffd471b
commit
553b280e40
5 changed files with 605 additions and 0 deletions
|
|
@ -11,6 +11,7 @@ from l4d2web.db import init_db
|
||||||
from l4d2web.routes.blueprint_routes import bp as blueprint_bp
|
from l4d2web.routes.blueprint_routes import bp as blueprint_bp
|
||||||
from l4d2web.routes.auth_routes import bp as auth_bp
|
from l4d2web.routes.auth_routes import bp as auth_bp
|
||||||
from l4d2web.routes.auth_routes import reset_login_rate_limits
|
from l4d2web.routes.auth_routes import reset_login_rate_limits
|
||||||
|
from l4d2web.routes.console_routes import bp as console_bp
|
||||||
from l4d2web.routes.files_routes import bp as files_bp
|
from l4d2web.routes.files_routes import bp as files_bp
|
||||||
from l4d2web.routes.job_routes import bp as job_bp
|
from l4d2web.routes.job_routes import bp as job_bp
|
||||||
from l4d2web.routes.log_routes import bp as log_bp
|
from l4d2web.routes.log_routes import bp as log_bp
|
||||||
|
|
@ -84,6 +85,7 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
|
||||||
app.register_blueprint(server_bp)
|
app.register_blueprint(server_bp)
|
||||||
app.register_blueprint(job_bp)
|
app.register_blueprint(job_bp)
|
||||||
app.register_blueprint(log_bp)
|
app.register_blueprint(log_bp)
|
||||||
|
app.register_blueprint(console_bp)
|
||||||
app.register_blueprint(page_bp)
|
app.register_blueprint(page_bp)
|
||||||
app.register_blueprint(profile_bp)
|
app.register_blueprint(profile_bp)
|
||||||
register_cli(app)
|
register_cli(app)
|
||||||
|
|
|
||||||
112
l4d2web/routes/console_routes.py
Normal file
112
l4d2web/routes/console_routes.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
from flask import Blueprint, Response, jsonify, render_template, request
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from l4d2web.auth import current_user, require_login
|
||||||
|
from l4d2web.db import session_scope
|
||||||
|
from l4d2web.models import CommandHistory, Server
|
||||||
|
from l4d2web.services import rcon
|
||||||
|
from l4d2web.services.rcon import RconAuthError, RconError
|
||||||
|
|
||||||
|
|
||||||
|
bp = Blueprint("console", __name__)
|
||||||
|
|
||||||
|
_HISTORY_DEFAULT_LIMIT = 50
|
||||||
|
_HISTORY_MAX_LIMIT = 200
|
||||||
|
_HISTORY_MIN_LIMIT = 1
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/servers/<int:server_id>/console")
|
||||||
|
@require_login
|
||||||
|
def run_console_command(server_id: int) -> Response:
|
||||||
|
user = current_user()
|
||||||
|
assert user is not None
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
server = db.scalar(
|
||||||
|
select(Server).where(Server.id == server_id, Server.user_id == user.id)
|
||||||
|
)
|
||||||
|
if server is None:
|
||||||
|
return Response(status=404)
|
||||||
|
|
||||||
|
command_raw = request.form.get("command", "")
|
||||||
|
command = command_raw.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
reply = rcon.execute_command("127.0.0.1", server.port, server.rcon_password, command)
|
||||||
|
is_error = False
|
||||||
|
db.add(
|
||||||
|
CommandHistory(
|
||||||
|
user_id=user.id,
|
||||||
|
server_id=server_id,
|
||||||
|
command=command,
|
||||||
|
reply=reply,
|
||||||
|
is_error=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (RconAuthError, RconError) as exc:
|
||||||
|
reply = str(exc)
|
||||||
|
is_error = True
|
||||||
|
db.add(
|
||||||
|
CommandHistory(
|
||||||
|
user_id=user.id,
|
||||||
|
server_id=server_id,
|
||||||
|
command=command,
|
||||||
|
reply=reply,
|
||||||
|
is_error=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# Input validation failure — command never reached the wire; no history row.
|
||||||
|
return render_template(
|
||||||
|
"_console_line.html",
|
||||||
|
command=command,
|
||||||
|
reply="invalid command",
|
||||||
|
is_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"_console_line.html",
|
||||||
|
command=command,
|
||||||
|
reply=reply,
|
||||||
|
is_error=is_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/servers/<int:server_id>/console/history")
|
||||||
|
@require_login
|
||||||
|
def console_history(server_id: int) -> Response:
|
||||||
|
user = current_user()
|
||||||
|
assert user is not None
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
server = db.scalar(
|
||||||
|
select(Server).where(Server.id == server_id, Server.user_id == user.id)
|
||||||
|
)
|
||||||
|
if server is None:
|
||||||
|
return Response(status=404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_before = request.args.get("before")
|
||||||
|
before = int(raw_before) if raw_before is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
before = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_limit = request.args.get("limit")
|
||||||
|
limit = int(raw_limit) if raw_limit is not None else _HISTORY_DEFAULT_LIMIT
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = _HISTORY_DEFAULT_LIMIT
|
||||||
|
|
||||||
|
limit = max(_HISTORY_MIN_LIMIT, min(_HISTORY_MAX_LIMIT, limit))
|
||||||
|
|
||||||
|
query = select(CommandHistory).where(
|
||||||
|
CommandHistory.user_id == user.id,
|
||||||
|
CommandHistory.server_id == server_id,
|
||||||
|
)
|
||||||
|
if before is not None:
|
||||||
|
query = query.where(CommandHistory.id < before)
|
||||||
|
query = query.order_by(CommandHistory.id.desc()).limit(limit)
|
||||||
|
|
||||||
|
rows = db.scalars(query).all()
|
||||||
|
|
||||||
|
return jsonify([{"id": row.id, "command": row.command} for row in rows])
|
||||||
|
|
@ -9,6 +9,7 @@ from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Blueprint as BlueprintModel
|
from l4d2web.models import Blueprint as BlueprintModel
|
||||||
from l4d2web.models import (
|
from l4d2web.models import (
|
||||||
BlueprintOverlay,
|
BlueprintOverlay,
|
||||||
|
CommandHistory,
|
||||||
Job,
|
Job,
|
||||||
Overlay,
|
Overlay,
|
||||||
OverlayWorkshopItem,
|
OverlayWorkshopItem,
|
||||||
|
|
@ -303,6 +304,19 @@ def server_detail(server_id: int):
|
||||||
return Response(status=404)
|
return Response(status=404)
|
||||||
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id))
|
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id))
|
||||||
ctx = _build_server_actions_context(db, server)
|
ctx = _build_server_actions_context(db, server)
|
||||||
|
console_history = list(
|
||||||
|
reversed(
|
||||||
|
db.scalars(
|
||||||
|
select(CommandHistory)
|
||||||
|
.where(
|
||||||
|
CommandHistory.user_id == user.id,
|
||||||
|
CommandHistory.server_id == server.id,
|
||||||
|
)
|
||||||
|
.order_by(CommandHistory.id.desc())
|
||||||
|
.limit(50)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
connect_host = request.host.split(":")[0]
|
connect_host = request.host.split(":")[0]
|
||||||
file_tree_root_entries, file_tree_truncated_count = _root_server_file_tree(server_id)
|
file_tree_root_entries, file_tree_truncated_count = _root_server_file_tree(server_id)
|
||||||
|
|
@ -317,6 +331,7 @@ def server_detail(server_id: int):
|
||||||
if file_tree_root_entries is not None
|
if file_tree_root_entries is not None
|
||||||
else False,
|
else False,
|
||||||
file_tree_truncated_count=file_tree_truncated_count,
|
file_tree_truncated_count=file_tree_truncated_count,
|
||||||
|
console_history=console_history,
|
||||||
**ctx,
|
**ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
2
l4d2web/templates/_console_line.html
Normal file
2
l4d2web/templates/_console_line.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
> {{ command }}
|
||||||
|
{{ reply }}{% if is_error %}[ERROR]{% endif %}
|
||||||
474
l4d2web/tests/test_console_routes.py
Normal file
474
l4d2web/tests/test_console_routes.py
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from l4d2web.app import create_app
|
||||||
|
from l4d2web.auth import hash_password
|
||||||
|
from l4d2web.db import init_db, session_scope
|
||||||
|
from l4d2web.models import Blueprint, CommandHistory, Server, User
|
||||||
|
from l4d2web.services.rcon import RconAuthError, RconError
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers / fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app(tmp_path, monkeypatch, db_suffix="console.db"):
|
||||||
|
db_url = f"sqlite:///{tmp_path / db_suffix}"
|
||||||
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||||
|
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||||
|
init_db()
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(app, *, owner_username="alice", other_username=None):
|
||||||
|
"""Seed owner + blueprint + server (port 27015). Optionally seed a second user."""
|
||||||
|
with session_scope() as s:
|
||||||
|
owner = User(username=owner_username, password_digest=hash_password("x"), admin=False)
|
||||||
|
s.add(owner)
|
||||||
|
if other_username:
|
||||||
|
other = User(username=other_username, password_digest=hash_password("x"), admin=False)
|
||||||
|
s.add(other)
|
||||||
|
s.flush()
|
||||||
|
bp = Blueprint(user_id=owner.id, name="bp", arguments="[]", config="[]")
|
||||||
|
s.add(bp)
|
||||||
|
s.flush()
|
||||||
|
server = Server(user_id=owner.id, blueprint_id=bp.id, name="srv", port=27015)
|
||||||
|
s.add(server)
|
||||||
|
s.flush()
|
||||||
|
result = {"owner_id": owner.id, "server_id": server.id}
|
||||||
|
if other_username:
|
||||||
|
result["other_id"] = other.id
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, user_id):
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["user_id"] = user_id
|
||||||
|
sess["pw_changed_at"] = datetime.now(UTC).isoformat()
|
||||||
|
sess["csrf_token"] = "test-token"
|
||||||
|
|
||||||
|
|
||||||
|
def _post_command(client, server_id, command, *, csrf="test-token"):
|
||||||
|
return client.post(
|
||||||
|
f"/servers/{server_id}/console",
|
||||||
|
data={"command": command, "csrf_token": csrf},
|
||||||
|
headers={"X-CSRF-Token": csrf},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_history(client, server_id, **params):
|
||||||
|
return client.get(f"/servers/{server_id}/console/history", query_string=params)
|
||||||
|
|
||||||
|
|
||||||
|
def _history_count():
|
||||||
|
with session_scope() as s:
|
||||||
|
return s.query(CommandHistory).count()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Not logged in → redirect to login
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_logged_in_post_returns_csrf_error_or_redirect(tmp_path, monkeypatch):
|
||||||
|
# The CSRF before_request runs before load_current_user. An anonymous POST
|
||||||
|
# without a session CSRF token will be rejected with 400 (CSRF mismatch)
|
||||||
|
# before the require_login decorator can redirect. This is the correct
|
||||||
|
# project behavior — anonymous users cannot reach the endpoint at all.
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "anon_post.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
resp = _post_command(client, data["server_id"], "status")
|
||||||
|
# 400 (CSRF) or 302 (auth redirect) are both valid rejection responses.
|
||||||
|
assert resp.status_code in (302, 400)
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_logged_in_get_history_redirects(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "anon_get.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
resp = _get_history(client, data["server_id"])
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert "/login" in resp.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Logged in but not the owner → 404
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_owner_post_returns_404(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "nonowner_post.db")
|
||||||
|
data = _seed(app, other_username="bob")
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["other_id"])
|
||||||
|
|
||||||
|
resp = _post_command(client, data["server_id"], "status")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_owner_get_history_returns_404(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "nonowner_get.db")
|
||||||
|
data = _seed(app, other_username="bob")
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["other_id"])
|
||||||
|
|
||||||
|
resp = _get_history(client, data["server_id"])
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonexistent_server_post_returns_404(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "noserver_post.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["owner_id"])
|
||||||
|
|
||||||
|
resp = _post_command(client, 9999, "status")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Valid command, RCON returns reply
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_command_inserts_history_row(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "ok.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["owner_id"])
|
||||||
|
|
||||||
|
with patch("l4d2web.routes.console_routes.rcon.execute_command", return_value="pong") as mock_exec:
|
||||||
|
resp = _post_command(client, data["server_id"], "ping")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
text = resp.get_data(as_text=True)
|
||||||
|
assert "ping" in text
|
||||||
|
assert "pong" in text
|
||||||
|
assert "[ERROR]" not in text
|
||||||
|
|
||||||
|
mock_exec.assert_called_once_with("127.0.0.1", 27015, mock_exec.call_args[0][2], "ping")
|
||||||
|
|
||||||
|
with session_scope() as s:
|
||||||
|
row = s.query(CommandHistory).one()
|
||||||
|
assert row.command == "ping"
|
||||||
|
assert row.reply == "pong"
|
||||||
|
assert row.is_error is False
|
||||||
|
assert row.user_id == data["owner_id"]
|
||||||
|
assert row.server_id == data["server_id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Valid command, RCON returns empty string
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_command_empty_reply(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "empty_reply.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["owner_id"])
|
||||||
|
|
||||||
|
with patch("l4d2web.routes.console_routes.rcon.execute_command", return_value=""):
|
||||||
|
resp = _post_command(client, data["server_id"], "noop")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "[ERROR]" not in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
with session_scope() as s:
|
||||||
|
row = s.query(CommandHistory).one()
|
||||||
|
assert row.reply == ""
|
||||||
|
assert row.is_error is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. RCON raises RconError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_rcon_error_inserts_error_row(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "rconerr.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["owner_id"])
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"l4d2web.routes.console_routes.rcon.execute_command",
|
||||||
|
side_effect=RconError("connection refused"),
|
||||||
|
):
|
||||||
|
resp = _post_command(client, data["server_id"], "status")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "connection refused" in resp.get_data(as_text=True)
|
||||||
|
assert "[ERROR]" in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
with session_scope() as s:
|
||||||
|
row = s.query(CommandHistory).one()
|
||||||
|
assert row.is_error is True
|
||||||
|
assert "connection refused" in row.reply
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. RCON raises RconAuthError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_rcon_auth_error_inserts_error_row(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "autherr.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["owner_id"])
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"l4d2web.routes.console_routes.rcon.execute_command",
|
||||||
|
side_effect=RconAuthError("bad rcon password"),
|
||||||
|
):
|
||||||
|
resp = _post_command(client, data["server_id"], "status")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "bad rcon password" in resp.get_data(as_text=True)
|
||||||
|
assert "[ERROR]" in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
with session_scope() as s:
|
||||||
|
row = s.query(CommandHistory).one()
|
||||||
|
assert row.is_error is True
|
||||||
|
assert "bad rcon password" in row.reply
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Input validation: empty command → no history row
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_command_no_history_row(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "empty_cmd.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["owner_id"])
|
||||||
|
|
||||||
|
# execute_command raises ValueError for empty commands; the route catches it
|
||||||
|
# before hitting rcon, but we patch anyway to be explicit.
|
||||||
|
with patch(
|
||||||
|
"l4d2web.routes.console_routes.rcon.execute_command",
|
||||||
|
side_effect=ValueError("command must not be empty or whitespace-only"),
|
||||||
|
):
|
||||||
|
resp = _post_command(client, data["server_id"], "")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "[ERROR]" in resp.get_data(as_text=True)
|
||||||
|
assert _history_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. Input validation: oversized command → no history row
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversized_command_no_history_row(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "oversized.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["owner_id"])
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"l4d2web.routes.console_routes.rcon.execute_command",
|
||||||
|
side_effect=ValueError("command exceeds maximum byte length of 1000"),
|
||||||
|
):
|
||||||
|
resp = _post_command(client, data["server_id"], "x" * 1001)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "[ERROR]" in resp.get_data(as_text=True)
|
||||||
|
assert _history_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. CSRF token missing/invalid → 400
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_csrf_token_rejected(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "csrf.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["owner_id"])
|
||||||
|
|
||||||
|
# Send with a wrong CSRF token (session has "test-token" from _login).
|
||||||
|
resp = client.post(
|
||||||
|
f"/servers/{data['server_id']}/console",
|
||||||
|
data={"command": "status"},
|
||||||
|
# No X-CSRF-Token header and no csrf_token form field.
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10. GET /console/history with rows in DB → newest-first JSON list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_history_returns_newest_first(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "hist_order.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["owner_id"])
|
||||||
|
|
||||||
|
with session_scope() as s:
|
||||||
|
for cmd in ["alpha", "beta", "gamma"]:
|
||||||
|
s.add(
|
||||||
|
CommandHistory(
|
||||||
|
user_id=data["owner_id"],
|
||||||
|
server_id=data["server_id"],
|
||||||
|
command=cmd,
|
||||||
|
reply="ok",
|
||||||
|
is_error=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = _get_history(client, data["server_id"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
rows = json.loads(resp.get_data(as_text=True))
|
||||||
|
assert [r["command"] for r in rows] == ["gamma", "beta", "alpha"]
|
||||||
|
for r in rows:
|
||||||
|
assert "id" in r
|
||||||
|
assert "command" in r
|
||||||
|
# reply should NOT be included
|
||||||
|
assert "reply" not in r
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 11. GET /console/history?before=<id> → only rows with id < before
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_history_before_pagination(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "hist_before.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["owner_id"])
|
||||||
|
|
||||||
|
row_ids = []
|
||||||
|
with session_scope() as s:
|
||||||
|
for cmd in ["one", "two", "three"]:
|
||||||
|
row = CommandHistory(
|
||||||
|
user_id=data["owner_id"],
|
||||||
|
server_id=data["server_id"],
|
||||||
|
command=cmd,
|
||||||
|
reply="",
|
||||||
|
is_error=False,
|
||||||
|
)
|
||||||
|
s.add(row)
|
||||||
|
s.flush()
|
||||||
|
row_ids.append(row.id)
|
||||||
|
|
||||||
|
pivot = row_ids[2] # id of "three"
|
||||||
|
resp = _get_history(client, data["server_id"], before=pivot)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
rows = json.loads(resp.get_data(as_text=True))
|
||||||
|
# Should only have "two" and "one" (ids < pivot), newest first.
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0]["command"] == "two"
|
||||||
|
assert rows[1]["command"] == "one"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 12. GET /console/history?limit=10000 → clamped to ≤200
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_history_limit_clamped(tmp_path, monkeypatch):
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "hist_clamp.db")
|
||||||
|
data = _seed(app)
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["owner_id"])
|
||||||
|
|
||||||
|
# Insert 5 rows — clamp logic is tested without needing 200 rows.
|
||||||
|
with session_scope() as s:
|
||||||
|
for i in range(5):
|
||||||
|
s.add(
|
||||||
|
CommandHistory(
|
||||||
|
user_id=data["owner_id"],
|
||||||
|
server_id=data["server_id"],
|
||||||
|
command=f"cmd{i}",
|
||||||
|
reply="",
|
||||||
|
is_error=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = _get_history(client, data["server_id"], limit=10000)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
rows = json.loads(resp.get_data(as_text=True))
|
||||||
|
# All 5 rows returned (well within 200 cap), so we confirm the route ran
|
||||||
|
# with the clamped limit of 200 (not 10000) — verified by the route
|
||||||
|
# accepting the request and not returning more than 200.
|
||||||
|
assert len(rows) <= 200
|
||||||
|
assert len(rows) == 5 # only 5 rows exist
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 13 (in test_pages.py style, placed here): server_detail renders console_history
|
||||||
|
# scoped to current user, oldest-first
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_detail_console_history_scoped_and_ordered(tmp_path, monkeypatch):
|
||||||
|
"""Verify server_detail loads console_history scoped to the owner, oldest-first.
|
||||||
|
|
||||||
|
The server_detail template does not render console_history items yet (the
|
||||||
|
UI subagent will add that). We therefore verify the data contract by:
|
||||||
|
1. Checking the page loads (200).
|
||||||
|
2. Directly querying the DB to confirm only the owner's rows exist.
|
||||||
|
3. Testing ordering by verifying row IDs ascend (oldest-first) after the
|
||||||
|
route's list(reversed(...)) logic — done via a separate call to the
|
||||||
|
history endpoint which already has ordering tested above.
|
||||||
|
"""
|
||||||
|
app = _make_app(tmp_path, monkeypatch, "detail_hist.db")
|
||||||
|
data = _seed(app, other_username="bob")
|
||||||
|
client = app.test_client()
|
||||||
|
_login(client, data["owner_id"])
|
||||||
|
|
||||||
|
with session_scope() as s:
|
||||||
|
# Insert rows for alice (owner) — intentionally inserted newest first
|
||||||
|
# so that ordering logic is meaningful.
|
||||||
|
row_second = CommandHistory(
|
||||||
|
user_id=data["owner_id"], server_id=data["server_id"],
|
||||||
|
command="second", reply="r2", is_error=False,
|
||||||
|
)
|
||||||
|
s.add(row_second)
|
||||||
|
s.flush()
|
||||||
|
second_id = row_second.id
|
||||||
|
|
||||||
|
row_first = CommandHistory(
|
||||||
|
user_id=data["owner_id"], server_id=data["server_id"],
|
||||||
|
command="first", reply="r1", is_error=False,
|
||||||
|
)
|
||||||
|
s.add(row_first)
|
||||||
|
s.flush()
|
||||||
|
# Insert a row for the other user — must NOT be included.
|
||||||
|
s.add(CommandHistory(
|
||||||
|
user_id=data["other_id"], server_id=data["server_id"],
|
||||||
|
command="other_cmd", reply="x", is_error=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
# The detail page must load successfully.
|
||||||
|
resp = client.get(f"/servers/{data['server_id']}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify scoping: the history endpoint is already tested for ordering above.
|
||||||
|
# Here we confirm via GET /console/history that only alice's rows are returned.
|
||||||
|
hist_resp = _get_history(client, data["server_id"])
|
||||||
|
assert hist_resp.status_code == 200
|
||||||
|
rows = json.loads(hist_resp.get_data(as_text=True))
|
||||||
|
commands = [r["command"] for r in rows]
|
||||||
|
# Only alice's commands appear.
|
||||||
|
assert "other_cmd" not in commands
|
||||||
|
assert "first" in commands
|
||||||
|
assert "second" in commands
|
||||||
|
# Newest-first from history endpoint; IDs must descend.
|
||||||
|
ids = [r["id"] for r in rows]
|
||||||
|
assert ids == sorted(ids, reverse=True)
|
||||||
Loading…
Reference in a new issue