thermograph/backend/tests/api/test_internal_token_derivation.py

71 lines
2.8 KiB
Python
Raw Normal View History

daemon: move the Discord gateway and scheduler out of the web process into Go (#21) The gateway bot and APScheduler were long-lived stateful I/O loops running inside the async web app under a leader election. They move into a single Go binary that owns ONLY that I/O -- websocket, RESUME, heartbeat, backoff, timers. It owns no grading logic. Anything needing data calls back over a new internal-only surface (/internal/discord/grade, /internal/jobs/*). Grading depends on polars and the parquet cache; reimplementing it in Go would let the bot's grades drift from the API's. The grade route returns gateway-ready JSON and Go relays the bytes verbatim. The binary ships in the backend image and runs as a second compose service off the same tag, so the two ends of the /internal/* contract can never skew. deploy.sh rolls daemon alongside backend -- without that the service would never be created, since a single-service deploy uses --no-deps. It also probes the image first and skips the daemon when rolling a tag that predates the binary: infra tracks main while image tags are env-staged, so a host can legitimately be asked to roll an older backend image, and creating the service anyway would leave a container crash-looping on a missing binary. replicas: 1 with order: stop-first replaces the leader election -- Discord permits one gateway connection per bot token. THERMOGRAPH_INTERNAL_TOKEN is optional: both ends derive it from THERMOGRAPH_AUTH_SECRET via HMAC under a domain-separation label, so this needs no new vault entry. The derivation is pinned to a shared cross-language test vector asserted on both sides, so drift fails CI instead of 401ing every call. Fail closed when neither secret is set. Improvements over the Python: a close intended for RESUME uses 4000 rather than 1000 (Discord invalidates a session closed 1000, so the old default defeated its own resume); MESSAGE_CREATE runs on a bounded worker pool; and a malformed HELLO returns an error rather than a clean reconnect, which would otherwise reset backoff and hot-loop against the gateway. 365 Python tests pass; Go build/vet/test -race clean; shellcheck 0 findings.
2026-07-23 22:49:54 +00:00
"""The internal token's derivation, and its cross-language contract with the Go daemon.
The daemon (backend/daemon/) and this app must compute byte-identical tokens from
the same THERMOGRAPH_AUTH_SECRET. If they drift, every callback fails with 401 --
which reads like an auth bug rather than like the configuration drift it is, so
the shared vector below is pinned on both sides.
"""
import hashlib
import hmac
import pytest
from api import internal_routes
# Must equal backend/daemon/internal/config/derive_test.go's sharedVector*.
SHARED_VECTOR_SECRET = "test-auth-secret"
SHARED_VECTOR_TOKEN = "4c3830b2158941ff52720d198b65f7924372a3a482b8da52540a4d9be38247ea"
@pytest.fixture(autouse=True)
def _clear_token_env(monkeypatch):
"""Both knobs start unset so each test states exactly the config it exercises."""
monkeypatch.delenv("THERMOGRAPH_INTERNAL_TOKEN", raising=False)
monkeypatch.delenv("THERMOGRAPH_AUTH_SECRET", raising=False)
def test_derived_token_matches_go(monkeypatch):
"""The pinned cross-language vector. Changing the label or construction here
requires the identical change in the Go daemon's config package."""
monkeypatch.setenv("THERMOGRAPH_AUTH_SECRET", SHARED_VECTOR_SECRET)
assert internal_routes.resolve_internal_token() == SHARED_VECTOR_TOKEN
def test_vector_is_actually_hmac_of_the_label():
"""Guards against the vector and the implementation drifting together if
someone regenerates the constant from the code it is supposed to check."""
expected = hmac.new(
SHARED_VECTOR_SECRET.encode(),
internal_routes._DERIVE_LABEL,
hashlib.sha256,
).hexdigest()
assert expected == SHARED_VECTOR_TOKEN
def test_explicit_token_wins_over_derivation(monkeypatch):
monkeypatch.setenv("THERMOGRAPH_AUTH_SECRET", SHARED_VECTOR_SECRET)
monkeypatch.setenv("THERMOGRAPH_INTERNAL_TOKEN", "explicit-wins")
assert internal_routes.resolve_internal_token() == "explicit-wins"
def test_no_secret_at_all_leaves_the_surface_closed():
"""Neither knob set => no token => the router 404s. Fail closed, never open."""
assert internal_routes.resolve_internal_token() == ""
def test_derivation_is_not_the_auth_secret_itself(monkeypatch):
"""Domain separation: a leak of the internal token must not hand over the
session-signing key it was derived from."""
monkeypatch.setenv("THERMOGRAPH_AUTH_SECRET", SHARED_VECTOR_SECRET)
token = internal_routes.resolve_internal_token()
assert token != SHARED_VECTOR_SECRET
assert SHARED_VECTOR_SECRET not in token
def test_different_secrets_derive_different_tokens(monkeypatch):
monkeypatch.setenv("THERMOGRAPH_AUTH_SECRET", "secret-a")
a = internal_routes.resolve_internal_token()
monkeypatch.setenv("THERMOGRAPH_AUTH_SECRET", "secret-b")
b = internal_routes.resolve_internal_token()
assert a != b