Files
VoiceStudio/tests/test_network_middleware.py
Palash DebnathandClaude Opus 4.8 f7cfd33994 polish(network): outermost PIN gate + non-buffering ASGI middleware + listener test (#160)
* fix(network-share): mount RemoteAuthGate at outermost provider

Move the <RemoteAuthGate> wrap from App.jsx's main-studio return up to
main-app.jsx, inside QueryClientProvider and wrapping the entire app tree
(both the dictation widget and <App />). Previously the gate only wrapped
the studio return, so a remote device opening a bare URL (no ?pin=) during
first-run states — the /setup/status check, SetupWizard, or BootstrapSplash
early returns — would 401 with no gate rendered to collect the PIN. The QR
path was fine (PIN captured pre-fetch in client.ts); only bare-URL was broken.

Remove the App.jsx wrap to avoid double-gating (two PIN dialogs). No behavior
change for loopback or QR users.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(network-share): make NetworkAccessMiddleware non-buffering ASGI

Rewrite NetworkAccessMiddleware from a starlette BaseHTTPMiddleware into a
pure ASGI middleware (class with __init__(app) and __call__(scope, receive,
send)). BaseHTTPMiddleware buffers StreamingResponse/SSE bodies before
forwarding them, so PIN'd LAN clients on streaming endpoints (dictation SSE,
tts streaming, /system/logs/stream) got buffered/laggy responses. Loopback was
unaffected (bypasses early), but remote-share streaming was degraded.

The ASGI form forwards send untouched on every pass-through path, and only
wraps send to inject Set-Cookie on the http.response.start message for the
first valid-PIN request — the body keeps streaming chunk-by-chunk. request.app
resolves in ASGI scope (Starlette sets scope["app"]), so the inert/loopback/
shell/PIN logic is identical to before. Registered after CORS (unchanged) so
CORS stays outermost.

All 5 existing behavior tests pass unchanged. Adds three tests: a guard that
the middleware is not a BaseHTTPMiddleware subclass, a StreamingResponse
pass-through (401 without PIN, full chunked stream with PIN, no buffered
Content-Length), and a Set-Cookie-via-ASGI assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(network-share): integration test for real listener lifecycle

Add tests/test_network_share_lifecycle.py exercising the real second uvicorn
listener: await network_share.enable(app) on a minimal FastAPI app, assert
get_state().enabled is True with a share_port set and a live TCP listener on
that port (real socket connect), then await disable(app) and assert the state
resets and the port stops accepting connections.

Uses the returned share_port (never a hardcoded port) and tolerates teardown
timing by polling for socket close. Wrapped in asyncio.run inside a sync test
so it does not depend on a pytest-asyncio event-loop mode; skips gracefully if
binding 0.0.0.0 is not permitted in the sandbox. Defensive cleanup resets the
module-level state on any failure path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:45:54 +05:30

115 lines
4.1 KiB
Python

# tests/test_network_middleware.py
from fastapi.testclient import TestClient
from services import network_share as ns
def _app_with_pin(pin="123456"):
from main import app
app.state.network_share = ns.ShareState(enabled=True, share_port=3901, pin=pin, lan_addresses=["10.0.0.9"])
return app
def teardown_function():
from main import app
app.state.network_share = ns.ShareState() # reset → middleware inert
def test_inert_when_no_pin():
from main import app
app.state.network_share = ns.ShareState() # no pin
c = TestClient(app, client=("10.0.0.5", 1)) # non-loopback
assert c.get("/health").status_code == 200
def test_loopback_bypasses_pin():
c = TestClient(_app_with_pin(), client=("127.0.0.1", 1))
assert c.get("/system/info").status_code == 200 # loopback → ok
def test_non_loopback_without_pin_401_on_api():
c = TestClient(_app_with_pin(), client=("10.0.0.5", 1))
r = c.get("/api/voices") # any non-shell API path
assert r.status_code in (401,) # PIN required
def test_non_loopback_with_valid_pin_passes():
c = TestClient(_app_with_pin("654321"), client=("10.0.0.5", 1))
r = c.get("/api/voices", headers={"X-OmniVoice-Pin": "654321"})
assert r.status_code != 401
def test_spa_shell_served_without_pin():
c = TestClient(_app_with_pin(), client=("10.0.0.5", 1))
assert c.get("/health").status_code == 200
def test_middleware_is_plain_asgi_not_buffering():
# A pure ASGI middleware (class with __call__(scope, receive, send)) does
# NOT subclass starlette's BaseHTTPMiddleware, which buffers streaming
# responses. Guard against a regression back to the buffering base class.
from starlette.middleware.base import BaseHTTPMiddleware
from main import NetworkAccessMiddleware
assert not issubclass(NetworkAccessMiddleware, BaseHTTPMiddleware)
assert callable(getattr(NetworkAccessMiddleware, "__call__", None))
def test_streaming_response_passes_through_with_valid_pin():
# A PIN'd, non-loopback request to a StreamingResponse route must stream
# chunk-by-chunk, not be collected into one buffered body. We mount a tiny
# streaming route on a fresh app wrapped with the real middleware and
# confirm the response arrives chunked (multiple yields concatenated).
from fastapi import FastAPI
from starlette.responses import StreamingResponse
from main import NetworkAccessMiddleware
app = FastAPI()
app.add_middleware(NetworkAccessMiddleware)
app.state.network_share = ns.ShareState(
enabled=True, share_port=3901, pin="777888", lan_addresses=["10.0.0.9"]
)
@app.get("/stream")
def stream():
def gen():
for i in range(5):
yield f"chunk-{i}\n"
return StreamingResponse(gen(), media_type="text/plain")
c = TestClient(app, client=("10.0.0.5", 1))
# Without the PIN, the stream route is gated.
assert c.get("/stream").status_code == 401
# With the PIN, it streams the full body through the ASGI middleware.
r = c.get("/stream", headers={"X-OmniVoice-Pin": "777888"})
assert r.status_code == 200
body = r.text
for i in range(5):
assert f"chunk-{i}" in body
# Streaming responses carry no precomputed Content-Length — a buffering
# middleware would re-materialise the body and set one.
assert "content-length" not in {k.lower() for k in r.headers}
def test_valid_pin_sets_cookie_via_asgi():
from fastapi import FastAPI
from main import NetworkAccessMiddleware
app = FastAPI()
app.add_middleware(NetworkAccessMiddleware)
app.state.network_share = ns.ShareState(
enabled=True, share_port=3901, pin="424242", lan_addresses=["10.0.0.9"]
)
@app.get("/api/ping")
def ping():
return {"ok": True}
c = TestClient(app, client=("10.0.0.5", 1))
r = c.get("/api/ping", headers={"X-OmniVoice-Pin": "424242"})
assert r.status_code == 200
# The ASGI send-wrapper injects Set-Cookie on the first valid-PIN request
# (when the cookie isn't already present).
set_cookie = r.headers.get("set-cookie", "")
assert "ov_pin=424242" in set_cookie