Files
VoiceStudio/tests/test_tailscale_service.py
T
Palash DebnathandClaude Opus 4.8 fa1503c4eb feat: network sharing (PIN-gated LAN + QR) & Tailscale remote access (#125) (#159)
* docs(spec): network sharing + Tailscale remote access design

Same-state LAN sharing via a second in-process uvicorn listener on a
dedicated share port (no restart, model/jobs preserved), PIN-gated for
non-loopback clients, with QR + all-LAN-addresses panel. Tailscale serve
for private remote access. Supersedes the raw 0.0.0.0 default-flip in #125.

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

* docs(spec): control endpoints reuse existing require_loopback gate

Security review of #157 confirmed the /system router is already loopback-gated
via Depends(require_loopback) (non-spoofable request.client.host). The network
control endpoints inherit it and /system/set-env is auto-protected from the
LAN listener — no new guard needed.

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

* feat(network): share-listener module — LAN enumeration + PIN + lifecycle

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

* feat(network): loopback-only control endpoints + /system/info sharing fields

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

* test(cjk): scan git-tracked files only, not untracked vendored dirs

The no-hardcoded-CJK guard walked the filesystem, so local untracked
vendored experiments (research/voice-pro etc. with JP issue templates)
caused false local failures while CI (committed files) passed. Scan via
git ls-files so local-only and CI behavior match.

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

* feat(network): PIN middleware — gate non-loopback API access when sharing on

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

* feat(network): inject X-OmniVoice-Pin globally + capture ?pin= from QR URL

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

* feat(network): remote PIN gate on 401

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

* chore(network): add qrcode dep for share QR

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

* feat(network): footer Local/Network toggle with LAN addresses, QR, copy/open

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

* feat(tailscale): CLI status + serve enable/disable + endpoints

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

* feat(network): Settings → Sharing & Remote Access panel (LAN + Tailscale)

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

* docs(network): sharing & remote access guide (LAN PIN/QR + Tailscale)

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

* fix(network): enable() tears down and raises if the share listener never binds

Defensive guard (spec §7): if the second uvicorn server doesn't reach
'started' (e.g. the share port was taken in the race after the free-port
probe), cancel the task, reset state, and raise — so the API surfaces the
failure and the UI stays Local rather than reporting a dead 'Network' state.

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

* test(network): use globalThis (not Node global) in client.test.ts for tsc

CI runs 'tsc --noEmit --checkJs false', which type-checks .ts files; Node's
'global' isn't typed there (TS2304). vitest (esbuild) tolerated it locally.
Use globalThis (standard, typed) + cast the mock.

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

* fix(network): apiFetch leaves opts untouched when no PIN set

The unconditional headers merge changed the request shape for callers with
no headers (e.g. FormData posts), breaking the legacy 'apiPost passes
FormData without Content-Type override' node test. Only spread opts +
inject X-OmniVoice-Pin when a PIN is actually present; otherwise pass opts
through unchanged.

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:26:16 +05:30

21 lines
853 B
Python

# tests/test_tailscale_service.py
import json
from unittest.mock import patch, MagicMock
from services import tailscale as ts
def test_status_absent_cli_is_graceful():
with patch("services.tailscale.shutil.which", return_value=None):
s = ts.status()
assert s["installed"] is False and s["running"] is False
def test_status_parses_json():
payload = {"BackendState": "Running", "Self": {"DNSName": "box.tail1234.ts.net.", "TailscaleIPs": ["100.64.0.1"]}}
with patch("services.tailscale.shutil.which", return_value="/usr/bin/tailscale"), \
patch("services.tailscale.subprocess.run", return_value=MagicMock(returncode=0, stdout=json.dumps(payload))):
s = ts.status()
assert s["installed"] and s["running"]
assert s["magic_dns_name"] == "box.tail1234.ts.net"
assert s["tailnet_ips"] == ["100.64.0.1"]