test(dashboard-auth): Phase 0 baseline harness for legacy token teardown
Pins the pre-teardown auth contract of both regimes: - gated mode ignores the legacy X-Hermes-Session-Token header - WS auth matrix (loopback ?token= vs gated ?ticket=/?internal=) - no _require_token-guarded sensitive path is in PUBLIC_API_PATHS Complements the existing test_dashboard_auth_gate.py coverage rather than duplicating it.
This commit is contained in:
parent
36ae958473
commit
3ca9c72d84
177
tests/hermes_cli/test_legacy_token_teardown_baseline.py
Normal file
177
tests/hermes_cli/test_legacy_token_teardown_baseline.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""Baseline harness for the legacy-session-token teardown.
|
||||
|
||||
Pins the CURRENT (pre-teardown) auth contract of BOTH dashboard regimes
|
||||
so the phased removal of ``_SESSION_TOKEN`` can prove it didn't regress
|
||||
the gated path or silently widen the public surface.
|
||||
|
||||
This file ADDS the contracts not already covered by
|
||||
``test_dashboard_auth_gate.py`` (which already pins loopback token
|
||||
enforcement, the ``should_require_auth`` truth table, and ``start_server``
|
||||
flag-stashing):
|
||||
|
||||
* gated mode IGNORES the legacy ``X-Hermes-Session-Token`` header
|
||||
* the WS auth matrix via ``_ws_auth_reason`` (loopback token vs gated
|
||||
ticket/internal)
|
||||
* no ``_require_token``-guarded sensitive path is in PUBLIC_API_PATHS
|
||||
|
||||
The expectations in this file are intentionally the PRE-teardown contract.
|
||||
Later phases edit the specific assertions they intentionally change (and
|
||||
the commit that changes them documents why).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
# These tests mutate ``web_server.app.state.auth_required`` at module
|
||||
# scope; share the xdist group used by every dashboard-auth gate test so
|
||||
# they don't race against each other.
|
||||
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loopback_client():
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
web_server.app.state.bound_host = "127.0.0.1"
|
||||
web_server.app.state.bound_port = 9119
|
||||
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
|
||||
yield client
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gated_client():
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
web_server.app.state.bound_port = 443
|
||||
web_server.app.state.auth_required = True
|
||||
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
|
||||
yield client
|
||||
clear_providers()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gated mode ignores the legacy session token (mutual-exclusivity invariant)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gated_ignores_legacy_token_header(gated_client):
|
||||
"""In gated mode the legacy token header is inert: a request carrying
|
||||
a *valid* ``X-Hermes-Session-Token`` and no cookie must still 401."""
|
||||
r = gated_client.get(
|
||||
"/api/sessions",
|
||||
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert r.json().get("error") in ("unauthenticated", "session_expired")
|
||||
|
||||
|
||||
def test_gated_status_still_public(gated_client):
|
||||
"""``/api/status`` stays public in gated mode (NAS liveness probe)."""
|
||||
assert gated_client.get("/api/status").status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WS auth matrix (via _ws_auth_reason — TestClient.websocket_connect is
|
||||
# unreliable for handshake-rejection assertions, so test the function)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_ws(params: dict):
|
||||
class _Client:
|
||||
host = "127.0.0.1"
|
||||
|
||||
class _URL:
|
||||
path = "/api/ws"
|
||||
|
||||
class _WS:
|
||||
query_params = params
|
||||
client = _Client()
|
||||
url = _URL()
|
||||
|
||||
return _WS()
|
||||
|
||||
|
||||
def test_ws_loopback_token_accepted():
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
try:
|
||||
reason, cred = web_server._ws_auth_reason(
|
||||
_fake_ws({"token": web_server._SESSION_TOKEN})
|
||||
)
|
||||
assert reason is None and cred == "token"
|
||||
finally:
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
|
||||
def test_ws_loopback_bad_token_rejected():
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
try:
|
||||
reason, cred = web_server._ws_auth_reason(_fake_ws({"token": "nope"}))
|
||||
assert reason == "token_mismatch" and cred == "token"
|
||||
finally:
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
|
||||
def test_ws_gated_rejects_legacy_token():
|
||||
"""Gated mode never consults the legacy ``?token=`` path."""
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = True
|
||||
try:
|
||||
reason, cred = web_server._ws_auth_reason(
|
||||
_fake_ws({"token": web_server._SESSION_TOKEN})
|
||||
)
|
||||
assert reason == "no_credential" # token ignored; no ticket present
|
||||
finally:
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _require_token gating invariant — no sensitive guarded path is public
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_require_token_call_sites_exist():
|
||||
"""At least one handler still guards via ``_require_token``."""
|
||||
text = open(web_server.__file__).read()
|
||||
n_sites = len(re.findall(r"_require_token\(request\)", text)) - 1 # minus def
|
||||
assert n_sites >= 1
|
||||
|
||||
|
||||
def test_sensitive_paths_not_in_public_allowlist():
|
||||
"""The public allowlist must never contain a sensitive route. This is
|
||||
the audit invariant the gate relies on (a _require_token route that is
|
||||
also public-allowlisted gets no session attached and 401s under the
|
||||
gate even after the loopback teardown)."""
|
||||
for sensitive in (
|
||||
"/api/env/reveal",
|
||||
"/api/providers/validate",
|
||||
"/api/dashboard/agent-plugins/install",
|
||||
):
|
||||
assert sensitive not in PUBLIC_API_PATHS
|
||||
Loading…
x
Reference in New Issue
Block a user