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:
-Puter
2026-07-11 05:56:14 +05:30
parent 98a4ec305e
commit 8b36dd4e99
3 changed files with 339 additions and 11 deletions

224
tests/test_staging_infra.py Normal file
View 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"