Files
FabledSteward/tests/test_config.py
T
bvandeusenandClaude Opus 5 6c9b89390a
CI / lint (push) Successful in 4s
CI / unit (push) Successful in 50s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 1m2s
fix(db): survive a database restart and a not-yet-ready database
Two connection-recovery gaps, both surfaced by a Postgres restart that left
the app throwing tracebacks while the DB itself was healthy.

pool_pre_ping + pool_recycle on the app engine [#2626]: when the database
restarts, every connection already in the pool is dead at the socket level.
SQLAlchemy only discovered that by failing a real query, so the first
operation after a restart errored out on whatever triggered it. pre_ping
checks liveness on checkout and swaps the dead connection transparently;
pool_recycle caps connection age so a socket stranded by a NAT/conntrack
timeout or a Docker network rebuild is retired on a timer instead.

wait_for_database() gate at startup [#2627]: create_app touches the DB
synchronously (migrations, secret re-encryption, settings load) and assumed
it was both resolvable and accepting connections on the first try. Neither
holds after a host reboot (Docker DNS not yet serving `db` -> gaierror -2)
or an unclean shutdown (Postgres still replaying WAL -> "not yet accepting
connections"). Both are transient, so retry with capped backoff behind one
gate ahead of the first DB touch. Credential and missing-database errors
are classified by SQLSTATE and still fail immediately -- waiting cannot fix
those. Budget is bootstrap-configurable (STEWARD_DB_CONNECT_TIMEOUT /
database.connect_timeout, default 60s) since it governs reaching the DB and
so cannot live in the DB-backed settings.

Tests drive the retry loop off a fake clock, so backoff and timeout
behaviour are deterministic rather than wall-clock dependent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:21:58 -04:00

115 lines
4.8 KiB
Python

import textwrap
import pytest
from steward.config import load_bootstrap, _resolve_secret_key
def test_load_bootstrap_from_yaml(tmp_path):
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text(textwrap.dedent("""\
database:
url: postgresql+asyncpg://user:pass@localhost/steward
secret_key: test-secret
"""))
cfg = load_bootstrap(cfg_file)
assert cfg["database_url"] == "postgresql+asyncpg://user:pass@localhost/steward"
assert cfg["secret_key"] == "test-secret"
def test_env_var_database_url_overrides_yaml(tmp_path, monkeypatch):
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text("database:\n url: original\nsecret_key: s\n")
monkeypatch.setenv("STEWARD_DATABASE_URL", "overridden")
cfg = load_bootstrap(cfg_file)
assert cfg["database_url"] == "overridden"
def test_env_var_secret_key_overrides_yaml(tmp_path, monkeypatch):
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text("database:\n url: x\nsecret_key: from-yaml\n")
monkeypatch.setenv("STEWARD_SECRET_KEY", "from-env")
cfg = load_bootstrap(cfg_file)
assert cfg["secret_key"] == "from-env"
def test_missing_database_url_raises(tmp_path):
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text("secret_key: s\n")
with pytest.raises(ValueError, match="Database URL is required"):
load_bootstrap(cfg_file)
# ── secret key resolution ──────────────────────────────────────────────────────
def test_secret_key_existing_file_is_reused(tmp_path, monkeypatch):
key_file = tmp_path / "secret.key"
key_file.write_text("persisted-key\n")
monkeypatch.setattr("steward.config._SECRET_KEY_FILE", key_file)
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
assert _resolve_secret_key({}) == "persisted-key"
def test_secret_key_generated_and_persisted_when_writable(tmp_path, monkeypatch):
key_file = tmp_path / "sub" / "secret.key" # parent doesn't exist yet
monkeypatch.setattr("steward.config._SECRET_KEY_FILE", key_file)
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
key = _resolve_secret_key({})
assert key and key_file.read_text().strip() == key
def test_secret_key_unpersistable_raises_instead_of_ephemeral(tmp_path, monkeypatch):
# A file sits where a directory is needed, so mkdir/write fails → must raise
# (an ephemeral key would silently orphan every encrypted secret).
blocker = tmp_path / "blocker"
blocker.write_text("not a dir")
monkeypatch.setattr("steward.config._SECRET_KEY_FILE", blocker / "secret.key")
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
with pytest.raises(RuntimeError, match="could not persist"):
_resolve_secret_key({})
# ── database connect timeout (bootstrap-only: it governs reaching the DB) ────
def test_db_connect_timeout_defaults_when_unset(tmp_path, monkeypatch):
from steward.database import DB_CONNECT_TIMEOUT_SECONDS
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text("database:\n url: x\nsecret_key: s\n")
monkeypatch.delenv("STEWARD_DB_CONNECT_TIMEOUT", raising=False)
cfg = load_bootstrap(cfg_file)
assert cfg["db_connect_timeout"] == DB_CONNECT_TIMEOUT_SECONDS
def test_db_connect_timeout_from_yaml(tmp_path, monkeypatch):
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text(
"database:\n url: x\n connect_timeout: 120\nsecret_key: s\n")
monkeypatch.delenv("STEWARD_DB_CONNECT_TIMEOUT", raising=False)
assert load_bootstrap(cfg_file)["db_connect_timeout"] == 120.0
def test_db_connect_timeout_env_overrides_yaml(tmp_path, monkeypatch):
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text(
"database:\n url: x\n connect_timeout: 120\nsecret_key: s\n")
monkeypatch.setenv("STEWARD_DB_CONNECT_TIMEOUT", "5")
assert load_bootstrap(cfg_file)["db_connect_timeout"] == 5.0
def test_db_connect_timeout_garbage_falls_back_to_default(tmp_path, monkeypatch):
from steward.database import DB_CONNECT_TIMEOUT_SECONDS
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text("database:\n url: x\nsecret_key: s\n")
monkeypatch.setenv("STEWARD_DB_CONNECT_TIMEOUT", "not-a-number")
cfg = load_bootstrap(cfg_file)
assert cfg["db_connect_timeout"] == DB_CONNECT_TIMEOUT_SECONDS
def test_db_connect_timeout_non_positive_falls_back_to_default(tmp_path, monkeypatch):
# 0 would mean "never wait", reinstating the crash this setting exists to fix.
from steward.database import DB_CONNECT_TIMEOUT_SECONDS
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text("database:\n url: x\nsecret_key: s\n")
monkeypatch.setenv("STEWARD_DB_CONNECT_TIMEOUT", "0")
cfg = load_bootstrap(cfg_file)
assert cfg["db_connect_timeout"] == DB_CONNECT_TIMEOUT_SECONDS