Files
VoiceStudio/tests/test_network_share_lifecycle.py
T
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

95 lines
3.4 KiB
Python

"""Integration test for the real network-share listener lifecycle.
enable() starts a SECOND in-process uvicorn.Server bound to 0.0.0.0 on a
dedicated port serving the same app; disable() stops it. This test exercises
the real socket: it confirms a TCP connection succeeds on the reported
share_port while enabled, and is refused after disable().
Wrapped in asyncio.run inside a sync test so it does not depend on a
pytest-asyncio event-loop mode being configured.
"""
import asyncio
import socket
import pytest
from services import network_share as ns
def _can_connect(port: int, host: str = "127.0.0.1", timeout: float = 0.5) -> bool:
"""True if a TCP connection to host:port is accepted."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
try:
s.connect((host, port))
return True
except OSError:
return False
def _wait_closed(port: int, host: str = "127.0.0.1", tries: int = 40) -> bool:
"""Poll until the port stops accepting connections (tolerates teardown lag)."""
for _ in range(tries):
if not _can_connect(port, host):
return True
# Synchronous sleep is fine here — runs outside the event loop, between
# connect probes, after the server has been asked to exit.
socket_wait = 0.05
import time
time.sleep(socket_wait)
return False
async def _exercise_lifecycle():
# A minimal FastAPI app is enough — enable() only needs an ASGI app object
# and a place to stash app.state.network_share.
from fastapi import FastAPI
app = FastAPI()
# Sanity: starts Local (nothing bound to 0.0.0.0).
assert ns.get_state().enabled is False
state = await ns.enable(app)
try:
assert state.enabled is True
assert ns.get_state().enabled is True
port = state.share_port
assert isinstance(port, int) and port > 0
# app.state is updated to the enabled state.
assert app.state.network_share.enabled is True
assert app.state.network_share.share_port == port
# The listener is really up: a TCP connect to the reported port succeeds.
# The server binds 0.0.0.0; connect via loopback, which 0.0.0.0 covers.
assert _can_connect(port), f"expected a live listener on port {port}"
finally:
await ns.disable(app)
# After disable(): state reset and the socket is closed.
assert ns.get_state().enabled is False
assert app.state.network_share.enabled is False
assert _wait_closed(port), f"expected port {port} closed after disable()"
return port
def test_share_listener_lifecycle():
# Ensure a clean starting state regardless of test ordering.
if ns.get_state().enabled:
asyncio.run(ns.disable(__import__("fastapi").FastAPI()))
try:
# Probe whether binding 0.0.0.0 is permitted in this sandbox.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
try:
probe.bind(("0.0.0.0", 0))
except OSError as e:
pytest.skip(f"binding 0.0.0.0 not permitted in this sandbox: {e}")
asyncio.run(_exercise_lifecycle())
finally:
# Defensive cleanup so a failure mid-test never leaves a stray listener
# or a dirty module-level _state for the next test.
if ns.get_state().enabled:
asyncio.run(ns.disable(__import__("fastapi").FastAPI()))