fix(staging): unify matchmaking-v2 network topology + redact Apify token
Root cause: the VPS ran two Compose projects from the same file — api under -p matchmaking-v2-staging, postgres/redis under -p matchmaking-v2 — creating two disjoint <project>_default bridges. Every dependency hostname (postgres, redis) was NXDOMAIN from the api container, so feed store init, pool_load, save_feed, and RedisStreamWorker all failed with [Errno -2]. Fix: standardize on a single project (matchmaking-v2) for all three services with an external shared bridge (matchmaking-v2_default) and inline DB/Redis host pinning. The deploy script (sync-staging.sh) now brings up all three services, pre-creates the external network, and has a public_health_url case. Apify: moved the token from query params (4 sites) to an Authorization header so no request URL can leak it. All raise_for_status() paths now raise token-free _ApifyHTTPError. The live 403 (platform-feature-disabled: monthly usage cap exceeded) is an external blocker — no code fix. Tests: 15 focused regression tests covering network topology, volume config, deploy-script alignment, and Apify token redaction (with call-counters).
This commit is contained in:
224
tests/test_staging_infra.py
Normal file
224
tests/test_staging_infra.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Staging infra regression — the durable fixes this slice landed:
|
||||
|
||||
1. Single-project topology: docker-compose.staging.yml deploys all three services
|
||||
under ONE project name (`matchmaking-v2` — matches scripts/sync-staging.sh
|
||||
compose_project and the live volume/network names) with fixed container names and
|
||||
an external shared bridge (matchmaking-v2_default). The prior split (api under
|
||||
`-p matchmaking-v2-staging`, postgres/redis under `-p matchmaking-v2`) created two
|
||||
disjoint `<project>_default` bridges → every dependency hostname was NXDOMAIN from
|
||||
the api → feed store init failed with [Errno -2].
|
||||
|
||||
2. Project-scoped volume: the Postgres data volume stays project-scoped
|
||||
(matchmaking-v2_matchmaking_pgdata) — the existing live volume with its PG cluster
|
||||
is reused as-is, no migration or rename.
|
||||
|
||||
3. Apify token redaction: the client authenticates via an Authorization header (not a
|
||||
query param), so no request URL — and therefore no httpx.HTTPStatusError string —
|
||||
can ever contain the token. A 403 surfaces as a token-free RuntimeError.
|
||||
|
||||
These tests don't need a live stack — they lock the CONFIG shape and the ERROR SHAPE so
|
||||
the regressions they guard (network split, token-in-logs) can't return.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from app.engine import apify_client
|
||||
|
||||
|
||||
_COMPOSE = Path(__file__).resolve().parents[1] / "docker-compose.staging.yml"
|
||||
|
||||
|
||||
def _load_compose() -> dict:
|
||||
return yaml.safe_load(_COMPOSE.read_text())
|
||||
|
||||
|
||||
# ── 1. Single-project topology + external shared network ─────────────────────
|
||||
|
||||
def test_staging_compose_uses_external_shared_network():
|
||||
"""The default network must be external + named matchmaking-v2_default so a cold
|
||||
redeploy works even if the bridge was last created under a different project."""
|
||||
d = _load_compose()
|
||||
net = d["networks"]["default"]
|
||||
assert net.get("external") is True, "network must be external (no project owns/removes it)"
|
||||
assert net.get("name") == "matchmaking-v2_default", (
|
||||
"name must be the literal matchmaking-v2_default — the bridge postgres/redis already use"
|
||||
)
|
||||
|
||||
|
||||
def test_staging_api_depends_on_postgres_and_redis():
|
||||
"""The api service must depend on BOTH postgres and redis — this is the depends_on
|
||||
graph that guarantees they land on the same Compose network when deployed as one
|
||||
project. Breaking this (e.g. --no-deps deploy) reintroduces the network split."""
|
||||
d = _load_compose()
|
||||
deps = d["services"]["api"].get("depends_on", {})
|
||||
assert "postgres" in deps, "api must depend_on postgres (same-network guarantee)"
|
||||
assert "redis" in deps, "api must depend_on redis (same-network guarantee)"
|
||||
assert deps["postgres"].get("condition") == "service_healthy", "postgres dep must wait for healthcheck"
|
||||
assert deps["redis"].get("condition") == "service_started", "redis dep must wait for start"
|
||||
|
||||
|
||||
def test_staging_api_pins_db_and_redis_hosts():
|
||||
"""Inline environment overrides guarantee the api reaches postgres/redis by service
|
||||
name even if the git-ignored .env.staging drifts to localhost."""
|
||||
d = _load_compose()
|
||||
env = d["services"]["api"]["environment"]
|
||||
assert env["DATABASE_URL"].endswith("@postgres:5432/matchmaking"), "DB host must be the compose service name"
|
||||
assert env["REDIS_URL"].startswith("redis://redis:"), "Redis host must be the compose service name"
|
||||
|
||||
|
||||
def test_staging_service_names_are_unique():
|
||||
"""Unique container names prevent cross-project DNS ambiguity on the shared bridge."""
|
||||
d = _load_compose()
|
||||
names = {s["container_name"] for s in d["services"].values()}
|
||||
assert names == {"growqr-matchmaking-api", "growqr-matchmaking-postgres", "growqr-matchmaking-redis"}
|
||||
|
||||
|
||||
# ── 2. Project-scoped volume ─────────────────────────────────────────────────
|
||||
|
||||
def test_staging_postgres_mounts_volume():
|
||||
"""The postgres service must mount the data volume at the PG data dir."""
|
||||
d = _load_compose()
|
||||
mounts = d["services"]["postgres"]["volumes"]
|
||||
assert any("matchmaking_pgdata:/var/lib/postgresql/data" in m for m in mounts), (
|
||||
"postgres must mount matchmaking_pgdata at the PG data directory"
|
||||
)
|
||||
|
||||
|
||||
def test_staging_volume_is_project_scoped():
|
||||
"""The volume must be project-scoped (NOT external) so the live
|
||||
matchmaking-v2_matchmaking_pgdata is reused as-is — no rename, no data risk."""
|
||||
d = _load_compose()
|
||||
vol = d["volumes"]["matchmaking_pgdata"]
|
||||
assert vol is None or (isinstance(vol, dict) and not vol.get("external")), (
|
||||
"volume must NOT be external — the live project-scoped volume is reused as-is"
|
||||
)
|
||||
|
||||
|
||||
# ── 2b. Deploy script alignment ─────────────────────────────────────────────
|
||||
|
||||
_SYNC_STAGING = Path(__file__).resolve().parents[2] / "scripts" / "sync-staging.sh"
|
||||
|
||||
|
||||
def test_sync_staging_compose_services_includes_all_three():
|
||||
"""The deploy script must bring up all three services (api, postgres, redis) under
|
||||
the single project — not just api. Without this, postgres/redis stay on whatever
|
||||
project they were last started under, and the network split can recur."""
|
||||
if not _SYNC_STAGING.exists():
|
||||
pytest.skip("sync-staging.sh not in this checkout (run from growqr monorepo)")
|
||||
text = _SYNC_STAGING.read_text()
|
||||
# compose_services must have an explicit matchmaking-v2 case listing all three
|
||||
assert "matchmaking-v2) printf 'api postgres redis'" in text, (
|
||||
"sync-staging.sh compose_services must list 'api postgres redis' for matchmaking-v2"
|
||||
)
|
||||
|
||||
|
||||
def test_sync_staging_compose_networks_includes_shared_bridge():
|
||||
"""The deploy guard must pre-create the external matchmaking-v2_default network
|
||||
so `external: true` in the compose never fails on ordering."""
|
||||
if not _SYNC_STAGING.exists():
|
||||
pytest.skip("sync-staging.sh not in this checkout (run from growqr monorepo)")
|
||||
text = _SYNC_STAGING.read_text()
|
||||
assert "matchmaking-v2_default" in text, (
|
||||
"sync-staging.sh compose_networks must pre-create matchmaking-v2_default"
|
||||
)
|
||||
|
||||
|
||||
def test_sync_staging_public_health_url_has_matchmaking_case():
|
||||
"""public_health_url must have a matchmaking-v2 case — without it, the function
|
||||
returns 1 (the `*)` fallthrough), and under `set -e` the command substitution at
|
||||
the call site exits the script before the deploy runs."""
|
||||
if not _SYNC_STAGING.exists():
|
||||
pytest.skip("sync-staging.sh not in this checkout (run from growqr monorepo)")
|
||||
text = _SYNC_STAGING.read_text()
|
||||
assert "matchmaking-v2) printf 'https://matchmaking-staging.gqr.puter.wtf/api/v1/health'" in text, (
|
||||
"sync-staging.sh public_health_url must have a matchmaking-v2 case to avoid set -e abort"
|
||||
)
|
||||
|
||||
|
||||
# ── 3. Apify token redaction ─────────────────────────────────────────────────
|
||||
|
||||
_TOKEN = "apify_api_secret_TOKEN_value_DO_NOT_LEAK"
|
||||
|
||||
|
||||
def test_auth_header_not_query_param():
|
||||
"""Token must travel in a header, never in the URL."""
|
||||
h = apify_client._auth_headers(_TOKEN)
|
||||
assert h == {"Authorization": f"Bearer {_TOKEN}"}
|
||||
|
||||
|
||||
def test_run_actor_4xx_raises_token_free_error(monkeypatch):
|
||||
"""A provider 403 (the live failure mode) must surface WITHOUT the token in the message.
|
||||
|
||||
The live Apify account returns 403 platform-feature-disabled (monthly cap exceeded).
|
||||
That error must be explicit and safe — no credential in the exception or any log line.
|
||||
"""
|
||||
calls = {"post": 0}
|
||||
|
||||
class _FakeResponse:
|
||||
status_code = 403
|
||||
request = httpx.Request("POST", "https://api.apify.com/v2/acts/x/run-sync-get-dataset-items")
|
||||
|
||||
def raise_for_status(self):
|
||||
raise httpx.HTTPStatusError("403 Forbidden", request=self.request, response=self)
|
||||
|
||||
def json(self):
|
||||
return {}
|
||||
|
||||
async def _fake_post(self, url, **kwargs):
|
||||
calls["post"] += 1
|
||||
# CRITICAL: assert no token leaked into the URL or query params
|
||||
assert "token" not in str(url), "token must not be in the request URL"
|
||||
assert "token" not in str(kwargs.get("params", {})), "token must not be in query params"
|
||||
return _FakeResponse()
|
||||
|
||||
_FakeSettings = type("S", (), {
|
||||
"APIFY_TOKEN": _TOKEN, "APIFY_CACHE_MODE": "off", "APIFY_CACHE_DIR": ".apify_cache",
|
||||
})
|
||||
monkeypatch.setattr(apify_client, "get_settings", lambda: _FakeSettings())
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_post)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
asyncio.run(apify_client.run_actor("test~actor", {"q": "dev"}))
|
||||
|
||||
assert calls["post"] >= 1, "the fake POST must be reached (else the test passed for the wrong reason)"
|
||||
msg = str(exc_info.value)
|
||||
assert _TOKEN not in msg, f"token leaked into error message: {msg!r}"
|
||||
assert "403" in msg, f"the HTTP status must be in the message: {msg!r}"
|
||||
|
||||
|
||||
def test_run_actor_async_4xx_raises_token_free_error(monkeypatch):
|
||||
"""The async-run path (incremental actors) must likewise not leak the token."""
|
||||
calls = {"post": 0}
|
||||
|
||||
class _FakeResponse:
|
||||
status_code = 403
|
||||
request = httpx.Request("POST", "https://api.apify.com/v2/acts/x/runs")
|
||||
|
||||
def raise_for_status(self):
|
||||
raise httpx.HTTPStatusError("403 Forbidden", request=self.request, response=self)
|
||||
|
||||
def json(self):
|
||||
return {}
|
||||
|
||||
async def _fake_post(self, url, **kwargs):
|
||||
calls["post"] += 1
|
||||
assert "token" not in str(url), "token must not be in the request URL"
|
||||
return _FakeResponse()
|
||||
|
||||
_FakeSettings = type("S", (), {
|
||||
"APIFY_TOKEN": _TOKEN, "APIFY_CACHE_MODE": "off", "APIFY_CACHE_DIR": ".apify_cache",
|
||||
})
|
||||
monkeypatch.setattr(apify_client, "get_settings", lambda: _FakeSettings())
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_post)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
asyncio.run(apify_client.run_actor("test~actor", {"q": "dev", "incremental": True}))
|
||||
|
||||
assert calls["post"] >= 1, "the fake POST must be reached (else the test passed for the wrong reason)"
|
||||
assert _TOKEN not in str(exc_info.value), "token leaked into async error message"
|
||||
Reference in New Issue
Block a user