thermograph/backend/tests/api/test_internal_token_derivation.py
emi 2d3f37c474
All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 8s
Sync infra to hosts / sync-prod (push) Successful in 7s
secrets-guard / encrypted (push) Successful in 8s
shell-lint / shellcheck (push) Successful in 10s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m14s
Deploy backend to beta VPS / deploy (push) Successful in 2m4s
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

70 lines
2.8 KiB
Python

"""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