Expose a runnable backend without giving the ingress layer secrets
Some checks failed
verify / verify (push) Has been cancelled
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:
@@ -17,6 +17,8 @@ from app.persistence.job_queue_sql import ( # noqa: E402
|
||||
)
|
||||
|
||||
COMPOSE = (ROOT / "compose.yaml").read_text(encoding="utf-8")
|
||||
DOCKERFILE = (BACKEND / "Dockerfile").read_text(encoding="utf-8")
|
||||
DEMO_API = (BACKEND / "app/api/v1/demo.py").read_text(encoding="utf-8")
|
||||
BOOTSTRAP = (ROOT / "ops/postgres/init/10-bootstrap-rag.sh").read_text(encoding="utf-8")
|
||||
MIGRATION = (BACKEND / "migrations/versions/0001_initial_schema.py").read_text(encoding="utf-8")
|
||||
|
||||
@@ -31,6 +33,8 @@ def _service_block(name: str) -> str:
|
||||
def test_compose_isolates_database_credentials_and_networks() -> None:
|
||||
db = _service_block("db")
|
||||
migrate = _service_block("migrate")
|
||||
api = _service_block("api")
|
||||
gateway = _service_block("gateway")
|
||||
provider_smoke = _service_block("provider-smoke")
|
||||
seed_demo = _service_block("seed-demo")
|
||||
seed_demo_offline = _service_block("seed-demo-offline")
|
||||
@@ -43,6 +47,27 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
|
||||
assert "postgres_bootstrap_password" not in migrate
|
||||
assert "postgres_app_password" not in migrate
|
||||
|
||||
assert "postgres_app_password" in api
|
||||
assert "postgres_bootstrap_password" not in api
|
||||
assert "postgres_migrator_password" not in api
|
||||
assert "bailian_api_key" not in api
|
||||
assert '"127.0.0.1:8000:8000"' not in api
|
||||
assert " - data" in api
|
||||
assert " - edge" not in api
|
||||
assert " - egress" not in api
|
||||
assert "read_only: true" in api
|
||||
assert "no-new-privileges:true" in api
|
||||
assert "cap_drop:" in api and " - ALL" in api
|
||||
|
||||
assert '"127.0.0.1:8000:8000"' in gateway
|
||||
assert " - edge" in gateway
|
||||
assert " - data" in gateway
|
||||
assert "secrets:" not in gateway
|
||||
assert "POSTGRES_" not in gateway
|
||||
assert "BAILIAN_" not in gateway
|
||||
assert "read_only: true" in gateway
|
||||
assert "no-new-privileges:true" in gateway
|
||||
|
||||
assert "bailian_api_key" in provider_smoke
|
||||
assert "postgres_" not in provider_smoke
|
||||
|
||||
@@ -61,6 +86,34 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
|
||||
assert re.search(r"(?m)^ edge:$", COMPOSE)
|
||||
assert re.search(r"(?m)^ egress:$", COMPOSE)
|
||||
|
||||
assert "USER 10001:10001" in DOCKERFILE
|
||||
assert 'CMD ["uvicorn", "app.main:app"' in DOCKERFILE
|
||||
|
||||
|
||||
def test_demo_queries_pin_governed_offline_identity() -> None:
|
||||
normalized = " ".join(DEMO_API.split())
|
||||
|
||||
for required_filter in (
|
||||
"chunk.knowledge_base_id = %s",
|
||||
"chunk.access_scope_id = %s",
|
||||
"chunk.metadata ->> 'source_type' = 'synthetic'",
|
||||
"chunk.searchable IS TRUE",
|
||||
"chunk.index_status = 'READY'",
|
||||
"chunk.approval_status = 'CLOUD_APPROVED'",
|
||||
"document.active_version_id = chunk.document_version_id",
|
||||
"version.review_state = 'CLOUD_APPROVED'",
|
||||
"version.outbound_manifest_sha256 = chunk.outbound_manifest_sha256",
|
||||
"version.embedding_profile_hash = chunk.embedding_profile_hash",
|
||||
"chunk.embedding_model = %s",
|
||||
"chunk.embedding_profile_hash = %s",
|
||||
):
|
||||
assert required_filter in normalized
|
||||
|
||||
assert "KNOWLEDGE_BASE_ID" in DEMO_API
|
||||
assert "ACCESS_SCOPE_ID" in DEMO_API
|
||||
assert "DEMO_FAKE_EMBEDDING_MODEL" in DEMO_API
|
||||
assert "offline_embedding_profile_hash" in DEMO_API
|
||||
|
||||
|
||||
def test_database_health_requires_tcp_and_atomic_bootstrap_sentinel() -> None:
|
||||
db = _service_block("db")
|
||||
|
||||
224
backend/tests/unit/test_demo_api.py
Normal file
224
backend/tests/unit/test_demo_api.py
Normal file
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
import psycopg
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.v1.demo import (
|
||||
DemoCandidate,
|
||||
DemoCounts,
|
||||
DemoRepository,
|
||||
get_demo_repository,
|
||||
make_citation_id,
|
||||
make_page_label,
|
||||
router,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StubRepository:
|
||||
count_result: DemoCounts
|
||||
candidates: list[DemoCandidate]
|
||||
failure: psycopg.Error | None = None
|
||||
|
||||
def counts(self) -> DemoCounts:
|
||||
if self.failure is not None:
|
||||
raise self.failure
|
||||
return self.count_result
|
||||
|
||||
def search(self, query_vector: tuple[float, ...], *, limit: int) -> list[DemoCandidate]:
|
||||
if self.failure is not None:
|
||||
raise self.failure
|
||||
assert len(query_vector) == 1024
|
||||
return self.candidates[:limit]
|
||||
|
||||
|
||||
def create_app(repository: DemoRepository) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_demo_repository] = lambda: repository
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_reports_only_public_synthetic_counts() -> None:
|
||||
app = create_app(
|
||||
StubRepository(
|
||||
count_result=DemoCounts(chunks=20, vectors=20, searchable=20),
|
||||
candidates=[],
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
response = await client.get("/api/v1/demo/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "ready",
|
||||
"dataset": "synthetic-demo",
|
||||
"counts": {"chunks": 20, "vectors": 20, "searchable": 20},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_explicitly_reports_empty_dataset() -> None:
|
||||
app = create_app(
|
||||
StubRepository(
|
||||
count_result=DemoCounts(chunks=0, vectors=0, searchable=0),
|
||||
candidates=[],
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
response = await client.get("/api/v1/demo/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "empty_dataset"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_does_not_mark_a_partial_demo_index_ready() -> None:
|
||||
app = create_app(
|
||||
StubRepository(
|
||||
count_result=DemoCounts(chunks=20, vectors=19, searchable=18),
|
||||
candidates=[],
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
response = await client.get("/api/v1/demo/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "incomplete_dataset"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_embeds_reranks_and_returns_only_safe_fields() -> None:
|
||||
repository = StubRepository(
|
||||
count_result=DemoCounts(chunks=2, vectors=2, searchable=2),
|
||||
candidates=[
|
||||
DemoCandidate(
|
||||
source_key="internal-chunk-one",
|
||||
title="西岭铜矿化合成记录",
|
||||
text="该合成记录描述花岗斑岩接触带,未包含真实矿权或人员信息。",
|
||||
page_start=3,
|
||||
page_end=3,
|
||||
),
|
||||
DemoCandidate(
|
||||
source_key="internal-chunk-two",
|
||||
title="北谷金异常合成记录",
|
||||
text="北谷合成样点出现低强度金异常,仅用于离线验证。",
|
||||
page_start=7,
|
||||
page_end=8,
|
||||
),
|
||||
],
|
||||
)
|
||||
app = create_app(repository)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/demo/search",
|
||||
json={"query": "花岗斑岩 铜矿化", "top_k": 1},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["dataset"] == "synthetic-demo"
|
||||
assert len(payload["results"]) == 1
|
||||
result = payload["results"][0]
|
||||
assert set(result) == {"title", "snippet", "page_label", "score", "citation_id"}
|
||||
assert result["title"].startswith("合成资料|")
|
||||
assert result["page_label"] == "第 3 页"
|
||||
assert result["citation_id"] == make_citation_id("internal-chunk-one")
|
||||
assert "internal-chunk-one" not in str(payload)
|
||||
assert "manifest" not in str(payload).lower()
|
||||
assert "sha256" not in str(payload).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_returns_explicit_empty_dataset_state() -> None:
|
||||
app = create_app(
|
||||
StubRepository(
|
||||
count_result=DemoCounts(chunks=0, vectors=0, searchable=0),
|
||||
candidates=[],
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/demo/search",
|
||||
json={"query": "不存在的合成资料", "top_k": 3},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "empty_dataset",
|
||||
"dataset": "synthetic-demo",
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{"query": " ", "top_k": 3},
|
||||
{"query": "x" * 501, "top_k": 3},
|
||||
{"query": "铜矿", "top_k": 0},
|
||||
{"query": "铜矿", "top_k": 11},
|
||||
],
|
||||
)
|
||||
async def test_search_rejects_invalid_bounded_input(body: dict[str, object]) -> None:
|
||||
app = create_app(
|
||||
StubRepository(
|
||||
count_result=DemoCounts(chunks=0, vectors=0, searchable=0),
|
||||
candidates=[],
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
response = await client.post("/api/v1/demo/search", json=body)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", ["/api/v1/demo/status", "/api/v1/demo/search"])
|
||||
async def test_database_failure_is_sanitized_as_503(path: str) -> None:
|
||||
app = create_app(
|
||||
StubRepository(
|
||||
count_result=DemoCounts(chunks=0, vectors=0, searchable=0),
|
||||
candidates=[],
|
||||
failure=psycopg.OperationalError("host=db password=must-not-leak"),
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
if path.endswith("search"):
|
||||
response = await client.post(path, json={"query": "铜矿", "top_k": 3})
|
||||
else:
|
||||
response = await client.get(path)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"detail": "database unavailable"}
|
||||
assert "must-not-leak" not in response.text
|
||||
|
||||
|
||||
def test_citation_and_page_labels_are_stable_and_opaque() -> None:
|
||||
first = make_citation_id("private-database-uuid")
|
||||
assert first == make_citation_id("private-database-uuid")
|
||||
assert first != make_citation_id("another-private-database-uuid")
|
||||
assert "private" not in first
|
||||
assert make_page_label(None, None) == "页码未知"
|
||||
assert make_page_label(2, 4) == "第 2-4 页"
|
||||
163
backend/tests/unit/test_gateway.py
Normal file
163
backend/tests/unit/test_gateway.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.gateway import MAX_REQUEST_BODY_BYTES, create_gateway_app
|
||||
|
||||
type Handler = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _gateway_client(handler: Handler) -> AsyncIterator[httpx.AsyncClient]:
|
||||
gateway = create_gateway_app(httpx.MockTransport(handler))
|
||||
async with gateway.router.lifespan_context(gateway):
|
||||
transport = httpx.ASGITransport(app=gateway)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://gateway") as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_live_and_docs_are_disabled() -> None:
|
||||
gateway = create_gateway_app(httpx.MockTransport(lambda _: httpx.Response(500)))
|
||||
try:
|
||||
assert gateway.docs_url is None
|
||||
assert gateway.redoc_url is None
|
||||
assert gateway.openapi_url is None
|
||||
|
||||
transport = httpx.ASGITransport(app=gateway)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://gateway") as client:
|
||||
response = await client.get("/gateway/live")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
finally:
|
||||
await _close_gateway(gateway)
|
||||
|
||||
|
||||
async def _close_gateway(gateway: FastAPI) -> None:
|
||||
async with gateway.router.lifespan_context(gateway):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_get_preserves_path_query_and_allowed_header() -> None:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(200, json={"result": "ok"}, headers={"x-request-id": "req-1"})
|
||||
|
||||
async with _gateway_client(handler) as client:
|
||||
response = await client.get(
|
||||
"/api/v1/search?q=granite%20deposit&limit=5",
|
||||
headers={"x-request-id": "req-1", "x-forwarded-host": "evil.invalid"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"result": "ok"}
|
||||
assert response.headers["x-request-id"] == "req-1"
|
||||
assert seen[0].url == "http://api:8000/api/v1/search?q=granite%20deposit&limit=5"
|
||||
assert seen[0].headers["host"] == "api:8000"
|
||||
assert "x-forwarded-host" not in seen[0].headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_json_body_and_content_type_are_forwarded() -> None:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(201, json={"accepted": True})
|
||||
|
||||
async with _gateway_client(handler) as client:
|
||||
response = await client.post("/api/v1/query", json={"question": "Where is gold?"})
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {"accepted": True}
|
||||
assert seen[0].method == "POST"
|
||||
assert seen[0].headers["content-type"] == "application/json"
|
||||
assert json.loads(seen[0].content) == {"question": "Where is gold?"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upstream_origin_is_fixed_against_host_and_path_ssrf_attempts() -> None:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(200)
|
||||
|
||||
async with _gateway_client(handler) as client:
|
||||
response = await client.get(
|
||||
"//evil.invalid/http://metadata.internal/latest?target=http://attacker.invalid",
|
||||
headers={"host": "attacker.invalid", "forwarded": "host=attacker.invalid"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert seen[0].url.scheme == "http"
|
||||
assert seen[0].url.host == "api"
|
||||
assert seen[0].url.port == 8000
|
||||
assert seen[0].headers["host"] == "api:8000"
|
||||
assert "forwarded" not in seen[0].headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_larger_than_one_mib_is_rejected_before_upstream() -> None:
|
||||
upstream_calls = 0
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
nonlocal upstream_calls
|
||||
upstream_calls += 1
|
||||
return httpx.Response(200)
|
||||
|
||||
async with _gateway_client(handler) as client:
|
||||
response = await client.post("/upload", content=b"x" * (MAX_REQUEST_BODY_BYTES + 1))
|
||||
|
||||
assert response.status_code == 413
|
||||
assert response.json() == {"detail": "request body too large"}
|
||||
assert upstream_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upstream_transport_error_returns_redacted_502() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError(
|
||||
"api:8000 refused connection with internal-token-value",
|
||||
request=request,
|
||||
)
|
||||
|
||||
async with _gateway_client(handler) as client:
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
|
||||
assert response.status_code == 502
|
||||
assert response.json() == {"detail": "upstream unavailable"}
|
||||
assert "api:8000" not in response.text
|
||||
assert "internal-token-value" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sensitive_upstream_response_headers_are_not_forwarded() -> None:
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=b"ok",
|
||||
headers={
|
||||
"content-type": "text/plain",
|
||||
"cache-control": "no-store",
|
||||
"set-cookie": "session=private",
|
||||
"server": "internal-api",
|
||||
"x-internal-token": "private",
|
||||
},
|
||||
)
|
||||
|
||||
async with _gateway_client(handler) as client:
|
||||
response = await client.get("/status")
|
||||
|
||||
assert response.headers["content-type"] == "text/plain"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "set-cookie" not in response.headers
|
||||
assert "server" not in response.headers
|
||||
assert "x-internal-token" not in response.headers
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user