Expose a runnable backend without giving the ingress layer secrets
Some checks failed
verify / verify (push) Has been cancelled

The backend can now be inspected through a loopback-only gateway while the database-aware API remains on the internal data network. A governed synthetic demo proves readiness, pgvector retrieval, reranking, and citation output through real HTTP without invoking cloud models.

Constraint: The previously exposed Bailian key is compromised and cannot be used for live validation

Constraint: The API must be locally reachable while retaining no internet egress

Rejected: Attach the API directly to the ingress network | a real socket test proved that configuration still had egress

Rejected: Publish a port from the internal-only network | Docker Desktop did not expose the host port

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep model and database credentials out of the gateway; do not relax the fixed demo identity/profile filters

Tested: make verify; 63 pytest tests; strict mypy; Ruff; Secret scan; Compose config; three backend image builds; API/DB/gateway healthy; migration exit 0; Swagger browser check; live/ready/meta/status/search HTTP; 20/20/20 index; API egress ENETUNREACH; empty gateway mounts and business environment

Not-tested: Live Bailian calls require a newly rotated key; full generated-answer flow and React UI are not implemented
This commit is contained in:
2026-07-12 16:37:02 +08:00
parent e89cca2b55
commit cfd6d4cbad
16 changed files with 1207 additions and 37 deletions

View File

@@ -1,22 +1,82 @@
import httpx
import pytest
from unittest.mock import MagicMock, patch
from app.main import app
import httpx
import psycopg
import pytest
from sqlalchemy import URL
from app import main
class _SettingsStub:
def database_url(self) -> URL:
return URL.create(
"postgresql+psycopg",
username="app",
password="database-test-password",
host="db.internal",
port=5432,
database="rag",
)
@pytest.mark.asyncio
async def test_liveness_does_not_require_database_or_model_credentials() -> None:
transport = httpx.ASGITransport(app=app)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/health/live")
response = await client.get("/health/live")
assert response.status_code == 200
assert response.json()["status"] == "ok"
@pytest.mark.asyncio
async def test_readiness_executes_select_one_and_returns_redacted_status() -> None:
connection = MagicMock()
connection.__enter__.return_value = connection
connection.execute.return_value.fetchone.return_value = (1,)
with (
patch.object(main, "get_settings", return_value=_SettingsStub()),
patch.object(psycopg, "connect", return_value=connection) as connect,
):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/health/ready")
assert response.status_code == 200
assert response.json() == {"status": "ready", "checks": {"database": "ok"}}
connection.execute.assert_called_once_with("SELECT 1")
assert connect.call_args.kwargs == {"connect_timeout": 2, "autocommit": True}
assert "database-test-password" not in response.text
assert "db.internal" not in response.text
@pytest.mark.asyncio
async def test_readiness_returns_503_without_exposing_database_error() -> None:
private_error = psycopg.OperationalError(
"could not connect to db.internal with database-test-password"
)
with (
patch.object(main, "get_settings", return_value=_SettingsStub()),
patch.object(psycopg, "connect", side_effect=private_error),
):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/health/ready")
assert response.status_code == 503
assert response.json() == {
"status": "not_ready",
"checks": {"database": "unavailable"},
}
assert "database-test-password" not in response.text
assert "db.internal" not in response.text
@pytest.mark.asyncio
async def test_meta_exposes_model_names_but_no_credentials() -> None:
transport = httpx.ASGITransport(app=app)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/meta")