* 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>
95 lines
3.4 KiB
Python
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()))
|