operator-plane: fix dashboard POST NameError (import os/hmac)
Every POST endpoint (approve/reject/exit/watch) crashed with NameError: do_POST references os.environ + hmac.compare_digest but neither was imported. Adds the imports, a non-loopback regression test for the token gate (no token -> routes; token set -> 401 without/with-wrong header, 200 with correct), and plumbs DASHBOARD_TOKEN + PG_SKIP_SCHEMA=1 into both compose dashboard services (+ .env.example docs for DASHBOARD_TOKEN / COMMAND_BUS_ENABLED). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f62ed646dc
commit
07570fb0e4
5 files changed
+106
-2
No files matched your search
@@ -9,6 +9,14 @@
|
|||||||
TOKEN_ENCRYPTION_KEY=<REDACTED>
|
TOKEN_ENCRYPTION_KEY=<REDACTED>
|
||||||
|
|
||||||
DASHBOARD_PORT=8765
|
DASHBOARD_PORT=8765
|
||||||
|
# Dashboard POST auth + operator command bus (the :8765 kill switch).
|
||||||
|
# DASHBOARD_TOKEN is REQUIRED-when-commands-on: /api/command returns 503 unless
|
||||||
|
# it is set (commands never run unauthenticated); it also gates approve/reject/
|
||||||
|
# exit POSTs. Empty = read-only dashboard, no command bus. Keep it out of logs.
|
||||||
|
DASHBOARD_TOKEN=
|
||||||
|
# Enable the operator command bus (flatten-all / flatten-symbol / pause / resume).
|
||||||
|
# Needs DASHBOARD_TOKEN set too; the loop consumes commands on its 5s guard.
|
||||||
|
COMMAND_BUS_ENABLED=0
|
||||||
#curl.exe -H "APCA-API-KEY-ID: $key" -H "APCA-API-SECRET-KEY: $secret" https://paper-api.alpaca.markets/v2/account
|
#curl.exe -H "APCA-API-KEY-ID: $key" -H "APCA-API-SECRET-KEY: $secret" https://paper-api.alpaca.markets/v2/account
|
||||||
# Alpaca API (primary data provider)
|
# Alpaca API (primary data provider)
|
||||||
ALPACA_API_KEY=<REDACTED>
|
ALPACA_API_KEY=<REDACTED>
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ dashboard still works read-only.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|||||||
@@ -156,8 +156,15 @@ services:
|
|||||||
<<: *app-env
|
<<: *app-env
|
||||||
DASHBOARD_HOST: "0.0.0.0"
|
DASHBOARD_HOST: "0.0.0.0"
|
||||||
DASHBOARD_PORT: "8765"
|
DASHBOARD_PORT: "8765"
|
||||||
ports:
|
# read-only viewer: never re-apply the 473-line schema (the SSE change
|
||||||
- "8765:8765"
|
# detector holds one long-lived connection) — mirrors the loop healthcheck.
|
||||||
|
PG_SKIP_SCHEMA: '1'
|
||||||
|
# Shared secret required on every POST (approve/reject/exit + the command
|
||||||
|
# bus). Empty = commands disabled + POSTs unauthenticated; fill to arm the
|
||||||
|
# kill switch. Reference by name only — never inline the secret value.
|
||||||
|
DASHBOARD_TOKEN: <FILL-ME-DASHBOARD-TOKEN>
|
||||||
|
# Gate the operator command bus on (also needs DASHBOARD_TOKEN set).
|
||||||
|
COMMAND_BUS_ENABLED: '1'
|
||||||
|
|
||||||
qabot:
|
qabot:
|
||||||
<<: *app
|
<<: *app
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ services:
|
|||||||
<<: *app-env
|
<<: *app-env
|
||||||
DASHBOARD_HOST: "0.0.0.0"
|
DASHBOARD_HOST: "0.0.0.0"
|
||||||
DASHBOARD_PORT: "8765"
|
DASHBOARD_PORT: "8765"
|
||||||
|
# read-only viewer: never re-apply the 473-line schema (the SSE change
|
||||||
|
# detector holds one long-lived connection) — mirrors the loop healthcheck.
|
||||||
|
PG_SKIP_SCHEMA: "1"
|
||||||
|
# Shared secret required on every POST (approve/reject/exit + the command
|
||||||
|
# bus). Empty = commands disabled and POSTs unauthenticated; set a value to
|
||||||
|
# arm the kill switch. Reference by name only — never inline the secret.
|
||||||
|
DASHBOARD_TOKEN: "${DASHBOARD_TOKEN:-}"
|
||||||
|
# Gate the operator command bus on (also needs DASHBOARD_TOKEN set).
|
||||||
|
COMMAND_BUS_ENABLED: "${COMMAND_BUS_ENABLED:-0}"
|
||||||
ports:
|
ports:
|
||||||
- "8765:8765"
|
- "8765:8765"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""POST auth + no-NameError regression for the dashboard handler (plan 007 step 1).
|
||||||
|
|
||||||
|
These drive ``DashboardHandler.do_POST`` DIRECTLY — the handler is constructed
|
||||||
|
without a socket and its ``_send_json`` is captured — so the test never binds a
|
||||||
|
loopback port (which is refused in this WSL sandbox) yet still exercises the real
|
||||||
|
token gate that used to ``NameError`` on missing ``os``/``hmac`` imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.main import DashboardHandler, DashboardState
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureHandler(DashboardHandler):
|
||||||
|
"""DashboardHandler wired to in-memory buffers instead of a live socket."""
|
||||||
|
|
||||||
|
def __init__(self, state, path, body=b"", headers=None): # noqa: D401 — no super().__init__
|
||||||
|
self.state = state
|
||||||
|
self.path = path
|
||||||
|
self.rfile = io.BytesIO(body)
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.captured = [] # list of (status, payload)
|
||||||
|
|
||||||
|
def _send_json(self, payload, status=200):
|
||||||
|
self.captured.append((status, payload))
|
||||||
|
|
||||||
|
|
||||||
|
def _state():
|
||||||
|
# add_watch needs no DB, so this routes without opening a connection.
|
||||||
|
return DashboardState("momentum", watch_inject=lambda s: None)
|
||||||
|
|
||||||
|
|
||||||
|
def _post(handler_state, path, body, headers=None):
|
||||||
|
body_bytes = json.dumps(body).encode()
|
||||||
|
hdrs = {"Content-Length": str(len(body_bytes))}
|
||||||
|
if headers:
|
||||||
|
hdrs.update(headers)
|
||||||
|
h = CaptureHandler(handler_state, path, body_bytes, hdrs)
|
||||||
|
h.do_POST()
|
||||||
|
return h.captured[-1] if h.captured else None
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_without_token_configured_routes(monkeypatch):
|
||||||
|
# No DASHBOARD_TOKEN -> the token gate is skipped; the POST must ROUTE, not
|
||||||
|
# crash with NameError (the os/hmac import bug this plan fixes).
|
||||||
|
monkeypatch.delenv("DASHBOARD_TOKEN", raising=False)
|
||||||
|
status, payload = _post(_state(), "/api/watch/add", {"symbol": "aapl"})
|
||||||
|
assert status == 200
|
||||||
|
assert payload == {"ok": True, "symbol": "AAPL"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_with_token_missing_header_401(monkeypatch):
|
||||||
|
monkeypatch.setenv("DASHBOARD_TOKEN", "s3cret")
|
||||||
|
status, payload = _post(_state(), "/api/watch/add", {"symbol": "aapl"})
|
||||||
|
assert status == 401
|
||||||
|
assert "X-Dashboard-Token" in payload["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_with_token_wrong_header_401(monkeypatch):
|
||||||
|
monkeypatch.setenv("DASHBOARD_TOKEN", "s3cret")
|
||||||
|
status, _ = _post(_state(), "/api/watch/add", {"symbol": "aapl"},
|
||||||
|
headers={"X-Dashboard-Token": "nope"})
|
||||||
|
assert status == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_with_token_correct_header_routes(monkeypatch):
|
||||||
|
monkeypatch.setenv("DASHBOARD_TOKEN", "s3cret")
|
||||||
|
status, payload = _post(_state(), "/api/watch/add", {"symbol": "aapl"},
|
||||||
|
headers={"X-Dashboard-Token": "s3cret"})
|
||||||
|
assert status == 200
|
||||||
|
assert payload["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
pytest.main([__file__, "-q"])
|
||||||
Reference in new issue
Block a user