This commit is contained in:
Javis committed 2026-10-04 15:31:44 +02:00
1 parent 0246cbb6ba
commit 0ca4b4bf7a
89 files changed
+7042 -2480

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
"""Regression tests for the javis-slave audit fixes (docs/AUDIT_SLAVE.md)."""
from __future__ import annotations
import asyncio
import json
import sys
import time
from pathlib import Path
import httpx
import numpy as np
import pytest
SLAVE_SRC = Path(__file__).resolve().parents[1] / "javis-slave" / "src"
if str(SLAVE_SRC) not in sys.path:
sys.path.insert(0, str(SLAVE_SRC))
from javis_slave import config as slave_config # noqa: E402
from javis_slave.api_client import MasterClient, MasterError # noqa: E402
from javis_slave.config import SlaveConfig, ensure_control_token # noqa: E402
TOKEN = "t" * 32
@pytest.fixture
def cfg_dir(tmp_path, monkeypatch):
monkeypatch.setenv("JAVIS_SLAVE_CONFIG_DIR", str(tmp_path))
return tmp_path
# ── SLV-07: config loading ───────────────────────────────────────────────────
def test_corrupt_config_is_kept_aside(cfg_dir):
(cfg_dir / "slave_config.json").write_text("{not json", encoding="utf-8")
cfg = slave_config.load_config()
assert not cfg.paired
assert (cfg_dir / "slave_config.json.corrupt").read_text(encoding="utf-8") == "{not json"
assert not (cfg_dir / "slave_config.json").exists()
def test_config_type_mismatch_keeps_default(cfg_dir):
(cfg_dir / "slave_config.json").write_text(
json.dumps({"master_port": "abc", "input_gain": 2, "api_key": "k", "master_host": "h"}),
encoding="utf-8")
cfg = slave_config.load_config()
assert cfg.master_port == 8765
assert cfg.input_gain == 2.0 and isinstance(cfg.input_gain, float)
assert cfg.paired
def test_control_token_roundtrip(cfg_dir):
cfg = SlaveConfig()
assert ensure_control_token(cfg) is True
assert len(cfg.control_token) >= 24
assert ensure_control_token(cfg) is False
slave_config.save_config(cfg)
assert slave_config.load_config().control_token == cfg.control_token
# ── SLV-01: control API authentication ───────────────────────────────────────
def _app(token: str = TOKEN):
from javis_slave.http_server import build_http_app
cfg = SlaveConfig(control_token=token)
return build_http_app(cfg, lambda: None, lambda: "idle", lambda: None, lambda: (0.0, 0.0))
def test_control_api_requires_token_from_lan():
from fastapi.testclient import TestClient
remote = TestClient(_app(), client=("192.168.1.50", 50000))
assert remote.get("/mcp/v1/health").status_code == 200
assert remote.get("/mcp/v1/status").status_code == 401
# Rejected by the middleware before the handler runs: nothing is executed.
assert remote.post("/mcp/v1/power/cancel").status_code == 401
assert remote.get("/mcp/v1/status", headers={"Authorization": "Bearer wrong"}).status_code == 401
# The loopback header is not a credential for remote callers.
assert remote.get("/mcp/v1/status", headers={"X-Javis-Control": "1"}).status_code == 401
ok = remote.get("/mcp/v1/status", headers={"Authorization": f"Bearer {TOKEN}"})
assert ok.status_code == 200 and ok.json()["audio_state"] == "idle"
assert remote.get("/docs").status_code in (401, 404)
def test_control_api_loopback_needs_custom_header():
from fastapi.testclient import TestClient
for host in ("127.0.0.1", "::1", "::ffff:127.0.0.1"):
local = TestClient(_app(), client=(host, 50000))
# Plain loopback request (e.g. a browser form post / CSRF) is refused.
assert local.get("/mcp/v1/status").status_code == 401, host
assert local.get("/mcp/v1/status", headers={"X-Javis-Control": "1"}).status_code == 200, host
def test_empty_token_never_authorizes():
from fastapi.testclient import TestClient
remote = TestClient(_app(token=""), client=("10.0.0.2", 50000))
assert remote.get("/mcp/v1/status", headers={"Authorization": "Bearer "}).status_code == 401
# ── SLV-03: no blind retry of a non-idempotent turn after a read timeout ────
def _client_with(handler) -> MasterClient:
cfg = SlaveConfig(master_host="master.invalid", api_key="k")
client = MasterClient(cfg)
client._backoff_base = 0.0
client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
return client
def test_send_turn_does_not_retry_after_read_timeout():
calls = []
def handler(request):
calls.append(request.url.path)
raise httpx.ReadTimeout("slow", request=request)
async def run():
client = _client_with(handler)
try:
with pytest.raises(MasterError, match="Zeitüberschreitung"):
await client.send_turn(b"RIFF")
finally:
await client.aclose()
asyncio.run(run())
assert calls == ["/api/v1/turn"]
def test_send_turn_retries_connect_error_once():
calls = []
def handler(request):
calls.append(1)
raise httpx.ConnectError("refused", request=request)
async def run():
client = _client_with(handler)
try:
with pytest.raises(MasterError):
await client.send_turn(b"RIFF")
finally:
await client.aclose()
asyncio.run(run())
assert len(calls) == 2
# ── SLV-04 / SLV-06: audio engine ────────────────────────────────────────────
def test_engine_start_failure_releases_mic(monkeypatch):
from javis_slave import audio_engine
class FakeMic:
instances: list = []
def __init__(self, **kw):
self.stopped = False
FakeMic.instances.append(self)
async def start(self):
pass
async def stop(self):
self.stopped = True
def broken_vad(**kw):
raise RuntimeError("vad model missing")
monkeypatch.setattr(audio_engine, "MicStream", FakeMic)
monkeypatch.setattr(audio_engine, "make_vad", broken_vad)
async def run():
eng = audio_engine.AudioEngine(SlaveConfig(), client=None)
with pytest.raises(RuntimeError, match="vad model missing"):
await eng.start()
return eng
eng = asyncio.run(run())
assert FakeMic.instances and FakeMic.instances[0].stopped
assert eng.mic is None
@pytest.mark.parametrize("name", ["", ".", "..", "../evil", "sub/model", "..\\evil", "C:\\x\\model"])
def test_wake_model_name_cannot_address_a_path(name):
from javis_slave.audio_engine import AudioEngine
class NoDownload:
async def download_wake_model(self, dest): # pragma: no cover - must not be reached
raise AssertionError(f"download attempted for {dest}")
async def run():
eng = AudioEngine(SlaveConfig(), client=NoDownload())
with pytest.raises(RuntimeError, match="invalid wake word model name"):
await eng._load_wake(name, 0.5)
asyncio.run(run())
# ── SLV-05: playback writer stops before cancellation completes ─────────────
def test_write_pcm_blocks_cancel_stops_writer_thread():
from javis_slave.audio_io.stream import write_pcm_blocks
class SlowStream:
writes = 0
def write(self, block):
time.sleep(0.01)
SlowStream.writes += 1
async def run():
task = asyncio.ensure_future(
write_pcm_blocks(SlowStream(), np.zeros(16000 * 5, dtype=np.float32), 160))
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
done = SlowStream.writes
await asyncio.sleep(0.05)
return done
done = asyncio.run(run())
assert 0 < done < 500
assert SlowStream.writes == done # no write after the stream may be closed
def test_write_pcm_blocks_cancel_event():
from javis_slave.audio_io.stream import write_pcm_blocks
class Stream:
def write(self, block):
pass
async def run():
ev = asyncio.Event()
ev.set()
return await write_pcm_blocks(Stream(), np.zeros(1600, dtype=np.float32), 160, ev)
assert asyncio.run(run()) is True
# ── SLV-02 / SLV-09: Windows system control ─────────────────────────────────
def test_volume_controller_uses_current_pycaw_api(monkeypatch):
import types
from javis_slave import system_control
class Endpoint:
level = 0.25
def GetMasterVolumeLevelScalar(self):
return Endpoint.level
def SetMasterVolumeLevelScalar(self, level, ctx):
Endpoint.level = level
def GetMute(self):
return 1
class Device: # pycaw >= 2024 AudioDevice: has .EndpointVolume, no .Activate
EndpointVolume = Endpoint()
fake = types.ModuleType("pycaw.pycaw")
fake.AudioUtilities = types.SimpleNamespace(GetSpeakers=lambda: Device())
fake.IAudioEndpointVolume = object
monkeypatch.setitem(sys.modules, "pycaw", types.ModuleType("pycaw"))
monkeypatch.setitem(sys.modules, "pycaw.pycaw", fake)
monkeypatch.setattr(system_control.platform, "system", lambda: "Windows")
vol = system_control.VolumeController()
assert vol.get_volume() == 0.25
assert vol.set_volume(1.7) and Endpoint.level == 1.0
assert vol.is_muted() is True
@pytest.mark.skipif(sys.platform != "win32", reason="Windows exit-code handling")
def test_windows_power_runner_checks_exit_codes():
from javis_slave.system_control import _run_win
assert _run_win(["cmd", "/c", "exit 0"]) is True
assert _run_win(["cmd", "/c", "exit 5"]) is False
assert _run_win(["cmd", "/c", "exit 1116"], ok_codes=(0, 1116)) is True