Make the offline RAG chain observable without widening credential access
Some checks failed
verify / verify (push) Has been cancelled
Some checks failed
verify / verify (push) Has been cancelled
Expose the verified synthetic retrieval path through a typed React client and a non-root Nginx edge while keeping database credentials and data-network reachability out of the browser tier. A fixed-origin gateway preserves request boundaries and now closes upstream streams even when downstream disconnects before body iteration. The deployment ADR and runbooks record the four-network topology and its accepted Web edge-egress risk. Constraint: The previously exposed Bailian key must be revoked and no live provider credential may enter Git, images, logs, or the browser. Rejected: Connect Web directly to the data network | expands lateral reach to PostgreSQL. Rejected: Publish the database-aware API on the edge network | gives a credential-bearing process a public default route. Rejected: Buffer streaming responses in either proxy | prevents incremental chat delivery in the future. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Do not mark Stage 1 or Stage 2 complete until the rotated-key live smoke and remaining stage gates pass. Tested: make verify; 65 backend tests; 14 frontend tests; Docker image build; container health and isolation probes; real HTTP demo/status/search/docs/OpenAPI checks. Not-tested: Live Bailian models, real document ingestion, business chat SSE generation, and final browser screenshot automation because the browser skill runtime was unavailable.
This commit is contained in:
@@ -1,16 +1,38 @@
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from starlette.requests import ClientDisconnect
|
||||
from starlette.types import Message, Scope
|
||||
|
||||
from app.gateway import MAX_REQUEST_BODY_BYTES, create_gateway_app
|
||||
|
||||
type Handler = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
class _TwoEventStream(httpx.AsyncByteStream):
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
yield b"data: first\n\n"
|
||||
yield b"data: second\n\n"
|
||||
|
||||
|
||||
class _TrackedStream(httpx.AsyncByteStream):
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self.started = False
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
self.started = True
|
||||
yield b"data: never-delivered\n\n"
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _gateway_client(handler: Handler) -> AsyncIterator[httpx.AsyncClient]:
|
||||
gateway = create_gateway_app(httpx.MockTransport(handler))
|
||||
@@ -161,3 +183,105 @@ async def test_sensitive_upstream_response_headers_are_not_forwarded() -> None:
|
||||
assert "set-cookie" not in response.headers
|
||||
assert "server" not in response.headers
|
||||
assert "x-internal-token" not in response.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_events_are_forwarded_as_separate_asgi_body_chunks() -> None:
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=_TwoEventStream(),
|
||||
)
|
||||
|
||||
gateway = create_gateway_app(httpx.MockTransport(handler))
|
||||
sent: list[Message] = []
|
||||
request_delivered = False
|
||||
|
||||
async def receive() -> Message:
|
||||
nonlocal request_delivered
|
||||
if not request_delivered:
|
||||
request_delivered = True
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent.append(message)
|
||||
|
||||
scope = cast(
|
||||
Scope,
|
||||
{
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0", "spec_version": "2.4"},
|
||||
"http_version": "1.1",
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"path": "/api/v1/chat/stream",
|
||||
"raw_path": b"/api/v1/chat/stream",
|
||||
"query_string": b"",
|
||||
"headers": [(b"accept", b"text/event-stream")],
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("gateway", 80),
|
||||
},
|
||||
)
|
||||
|
||||
async with gateway.router.lifespan_context(gateway):
|
||||
await gateway(scope, receive, send)
|
||||
|
||||
body_messages = [message for message in sent if message["type"] == "http.response.body"]
|
||||
assert [message["body"] for message in body_messages] == [
|
||||
b"data: first\n\n",
|
||||
b"data: second\n\n",
|
||||
b"",
|
||||
]
|
||||
assert [message["more_body"] for message in body_messages] == [True, True, False]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upstream_stream_closes_when_downstream_disconnects_before_body_iteration() -> None:
|
||||
upstream_stream = _TrackedStream()
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=upstream_stream,
|
||||
)
|
||||
|
||||
gateway = create_gateway_app(httpx.MockTransport(handler))
|
||||
request_delivered = False
|
||||
|
||||
async def receive() -> Message:
|
||||
nonlocal request_delivered
|
||||
if not request_delivered:
|
||||
request_delivered = True
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
async def disconnect_on_response_start(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
raise OSError("downstream disconnected")
|
||||
|
||||
scope = cast(
|
||||
Scope,
|
||||
{
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0", "spec_version": "2.4"},
|
||||
"http_version": "1.1",
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"path": "/api/v1/chat/stream",
|
||||
"raw_path": b"/api/v1/chat/stream",
|
||||
"query_string": b"",
|
||||
"headers": [(b"accept", b"text/event-stream")],
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("gateway", 80),
|
||||
},
|
||||
)
|
||||
|
||||
async with gateway.router.lifespan_context(gateway):
|
||||
with pytest.raises(ClientDisconnect):
|
||||
await gateway(scope, receive, disconnect_on_response_start)
|
||||
|
||||
assert upstream_stream.closed is True
|
||||
assert upstream_stream.started is False
|
||||
|
||||
Reference in New Issue
Block a user