thermograph/backend/tests/api/test_internal_token_derivation.py
Emi Griffith c3b906a9ed
All checks were successful
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / build-backend (pull_request) Successful in 1m18s
PR build (required check) / changes (pull_request) Successful in 6s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 3s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / validate-observability (pull_request) Has been skipped
daemon: move the Discord gateway and scheduler out of the web process into Go
web/app.py started two long-lived background jobs under a leader election: the
Discord gateway bot and an APScheduler. Both are stateful I/O loops -- reconnect,
RESUME, heartbeat, backoff, interval timers -- living inside an async web app
that also has to serve requests. This moves them into a single Go binary.

Go owns ONLY the stateful I/O. It owns no climate or grading logic: anything
needing data calls back into Python over a new internal-only HTTP surface
(/internal/discord/grade, /internal/jobs/warm-cities, /internal/jobs/indexnow).
Grading depends on polars and the parquet cache; reimplementing it in Go would
make the bot's grades drift from the API's, and the slash-command path
deliberately shares one grade builder so the two can never disagree. The grade
route returns gateway-ready JSON -- including the ephemeral-flag drop that
discord_bot.py used to do -- and Go relays those bytes verbatim without parsing
the embed.

Packaging: the binary is built by a golang:1.26 stage in the backend Dockerfile
and shipped in the SAME image, run as a second compose service off the SAME tag.
The daemon and backend share the /internal/* contract, so they must never skew
versions; one image makes that structural rather than a convention. Its
entrypoint bypasses entrypoint.sh -- the backend owns alembic, and two racing
migrators is a real hazard.

replicas: 1 in the Swarm stack is load-bearing. Discord permits exactly one
gateway connection per bot token; the pin replaces core/singleton.claim_leader
for this workload. update_config uses order: stop-first, since start-first would
briefly run two gateways. autoscale.sh targets ${STACK_NAME}_web only, so it
cannot scale this.

Security: the internal routes compare the token with hmac.compare_digest and the
whole router 404s when THERMOGRAPH_INTERNAL_TOKEN is unset -- fail closed, never
default open. Caddy only routes /api/*, /digest and /discord/interactions to the
backend, so /internal/* was never publicly reachable; the token is defence in
depth. The router mounts before the catch-all frontend proxy so /internal/*
cannot fall through to it. The daemon refuses to start without the token.

Behaviour preserved from the Python, with the reasoning carried into the Go
comments: non-privileged intents (no MESSAGE_CONTENT, so no portal review);
fatal close codes 4004/4010-4014 stop rather than loop; the bot-author and
self-author mention-loop guard; allowed_mentions locked to {"parse":[],
"replied_user":true} so a crafted query cannot turn a reply into an @everyone
ping; the first cron tick deferred one full interval rather than firing at boot,
since warm-cities already runs at deploy time; and no overlapping warm-cities
run, which would double-spend the archive-fetch quota.

Two deliberate improvements over the Python. A close intended for RESUME now
uses 4000 rather than 1000 -- Discord invalidates a session closed 1000/1001, so
the Python's default close silently defeated its own resume. And MESSAGE_CREATE
is handled on a bounded worker pool rather than an unbounded thread hand-off, so
a flood of mentions cannot spawn unbounded work against the backend.

A .dockerignore is added because a disposable backend/.venv was being swallowed
by COPY . /app/ and duplicated again by the chown layer, inflating the image to
1.8 GB; it builds at 578 MB.

Tests: 29 Go gateway tests covering every behaviour the deleted
test_discord_bot.py asserted, plus cron/config/apiclient suites; 10 new Python
tests for the internal routes (fail-closed, auth, flag drop, per-job 409 guard).
Full suite 359 passed / 7 skipped; go build, vet and test -race clean.
2026-07-23 15:42:44 -07: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