Merge remote-tracking branch 'origin/main' into bb/gui
# Conflicts: # apps/dashboard/src/i18n/af.ts # apps/dashboard/src/i18n/de.ts # apps/dashboard/src/i18n/es.ts # apps/dashboard/src/i18n/fr.ts # apps/dashboard/src/i18n/ga.ts # apps/dashboard/src/i18n/hu.ts # apps/dashboard/src/i18n/it.ts # apps/dashboard/src/i18n/ja.ts # apps/dashboard/src/i18n/ko.ts # apps/dashboard/src/i18n/pt.ts # apps/dashboard/src/i18n/ru.ts # apps/dashboard/src/i18n/tr.ts # apps/dashboard/src/i18n/uk.ts # apps/dashboard/src/i18n/zh-hant.ts # gateway/config.py # hermes_cli/main.py # plugins/strike-freedom-cockpit/README.md # tui_gateway/server.py
This commit is contained in:
@@ -1946,6 +1946,117 @@ class TestNormaliseThemeExtensions:
|
||||
assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
|
||||
|
||||
|
||||
class TestPluginAPIAuth:
|
||||
"""Tests that plugin API routes require the session token (issue #19533)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
|
||||
"""Create a TestClient without the session token header."""
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
|
||||
self.client = TestClient(app)
|
||||
self.auth_client = TestClient(app)
|
||||
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def test_plugin_route_requires_auth(self):
|
||||
"""Plugin API routes should return 401 without a valid session token."""
|
||||
# Use a known plugin route (kanban board)
|
||||
resp = self.client.get("/api/plugins/kanban/board")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_route_allows_auth(self):
|
||||
"""Plugin API routes should work with a valid session token.
|
||||
|
||||
Use ``/api/plugins/example/hello`` from the example-dashboard plugin —
|
||||
a stable, side-effect-free GET that's always loaded in tests. With a
|
||||
valid token the handler should run (200); without one the middleware
|
||||
should 401 before the handler is reached.
|
||||
"""
|
||||
# Without auth: middleware blocks before reaching the handler.
|
||||
resp = self.client.get("/api/plugins/example/hello")
|
||||
assert resp.status_code == 401
|
||||
|
||||
# With auth: handler runs.
|
||||
resp = self.auth_client.get("/api/plugins/example/hello")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_plugin_post_requires_auth(self):
|
||||
"""Plugin POST routes should return 401 without a valid session token."""
|
||||
resp = self.client.post("/api/plugins/kanban/tasks", json={"title": "test"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_patch_requires_auth(self):
|
||||
"""Plugin PATCH routes should return 401 without a valid session token.
|
||||
|
||||
PATCH is the mutation method most commonly used by the dashboard for
|
||||
kanban task edits — explicitly cover it so a future middleware
|
||||
regression that whitelists non-GET methods can't sneak through.
|
||||
"""
|
||||
resp = self.client.patch(
|
||||
"/api/plugins/kanban/tasks/t_fake",
|
||||
json={"title": "renamed"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_delete_requires_auth(self):
|
||||
"""Plugin DELETE routes should return 401 without a valid session token."""
|
||||
resp = self.client.delete("/api/plugins/kanban/tasks/t_fake")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_non_kanban_plugin_route_requires_auth(self):
|
||||
"""Auth must be plugin-agnostic, not kanban-specific.
|
||||
|
||||
The middleware fix is at the gate level (no per-plugin allowlist),
|
||||
so any plugin's API surface — kanban, hermes-achievements, future
|
||||
plugins — must require the session token. Hit a non-kanban plugin
|
||||
path to lock that in.
|
||||
"""
|
||||
# Real plugin path (hermes-achievements is loaded by default).
|
||||
resp = self.client.get("/api/plugins/hermes-achievements/overview")
|
||||
assert resp.status_code == 401
|
||||
# Same for an arbitrary plugin namespace that doesn't even exist —
|
||||
# the middleware should 401 before routing decides 404, so an
|
||||
# attacker can't fingerprint plugin names by status codes.
|
||||
resp = self.client.get("/api/plugins/_definitely_not_a_plugin_/anything")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_websocket_unaffected_by_http_middleware(self):
|
||||
"""The kanban /events WebSocket has its own ``?token=`` check;
|
||||
the HTTP middleware change must not start gating WS upgrades.
|
||||
|
||||
Starlette doesn't run HTTP middleware on WebSocket upgrades anyway,
|
||||
but pin the behavior so a future refactor that moves auth into a
|
||||
shared layer can't silently break the WS auth contract.
|
||||
"""
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
from hermes_cli.web_server import _SESSION_TOKEN
|
||||
|
||||
# Without a token the WS endpoint must close the upgrade itself
|
||||
# (its own _check_ws_token), NOT 401 from the HTTP middleware.
|
||||
try:
|
||||
with self.client.websocket_connect(
|
||||
"/api/plugins/kanban/events"
|
||||
):
|
||||
pass # if we got here without disconnect, the WS accepted us
|
||||
except WebSocketDisconnect:
|
||||
pass # expected — WS endpoint rejected via its own check
|
||||
except Exception:
|
||||
# The kanban plugin may not be mounted in this test environment,
|
||||
# in which case the route doesn't exist at all (3xx/4xx during
|
||||
# upgrade). That's fine for this regression — it only matters
|
||||
# that the HTTP middleware didn't start intercepting WS upgrades.
|
||||
pass
|
||||
|
||||
|
||||
class TestDashboardPluginManifestExtensions:
|
||||
"""Tests for the extended plugin manifest fields (tab.override,
|
||||
tab.hidden, slots) read by _discover_dashboard_plugins()."""
|
||||
|
||||
Reference in New Issue
Block a user