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
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.
70 lines
2.8 KiB
Python
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
|