OmniVoice-Studio / tests /backend /test_engine_spawn_token.py
Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
8.7 kB
"""Subprocess env injection + Settings API endpoint tests (Task 3).
Covers AUTH-04 (subprocess HF_TOKEN injection) and AUTH-03 backend half
(Settings API endpoints — POST/DELETE/GET, all loopback-gated).
"""
import sys
from typing import Optional
from unittest.mock import MagicMock, patch
import pytest
SAMPLE_TOKEN = "hf_subsubsubsubsubsubsubsubsubsubsubsubsub01"
@pytest.fixture
def fresh_app(monkeypatch, tmp_path):
"""Build a fresh FastAPI app instance with isolated DB + cleared HF env.
The Settings router is mounted manually because the full main.py app
factory imports the entire backend stack (torch, whisperx, demucs, …)
which is too heavy for a unit test."""
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
monkeypatch.delenv("HF_TOKEN", raising=False)
monkeypatch.delenv("HUGGING_FACE_HUB_TOKEN", raising=False)
for mod in list(sys.modules):
if (
mod == "core" or mod.startswith("core.")
or mod == "services" or mod.startswith("services.")
or mod == "api" or mod.startswith("api.")
):
del sys.modules[mod]
from core import db as _db
_db.init_db()
from fastapi import FastAPI
from api.routers import settings as settings_router
app = FastAPI()
app.include_router(settings_router.router)
return app
def _client(app):
"""TestClient anchored to a loopback client tuple so require_loopback
treats requests as local. The default TestClient client tuple is
('testclient', 50000), which the dep rejects."""
from fastapi.testclient import TestClient
return TestClient(app, client=("127.0.0.1", 12345))
def test_post_hf_token_loopback_succeeds(fresh_app, monkeypatch):
"""A loopback-origin POST persists the token and returns the updated
cascade state."""
import huggingface_hub
monkeypatch.setattr(huggingface_hub, "login", lambda **kw: None)
monkeypatch.setattr(
huggingface_hub,
"whoami",
lambda token=None, **kw: {"name": "alice"},
)
monkeypatch.setattr(huggingface_hub, "get_token", lambda: None)
c = _client(fresh_app)
r = c.post("/api/settings/hf-token", json={"token": SAMPLE_TOKEN})
assert r.status_code == 200, r.text
# Round-trip via settings_store.
from services import settings_store
assert settings_store.get_hf_token() == SAMPLE_TOKEN
# Response contains the masked active source.
body = r.json()
assert body["active"] == "app"
assert any(s["source"] == "app" and s["set"] for s in body["sources"])
def test_post_hf_token_non_loopback_returns_403(fresh_app):
"""A non-loopback origin (simulated via TestClient client tuple) is
rejected with 403 per the require_loopback dep."""
from fastapi.testclient import TestClient
with TestClient(fresh_app, client=("10.0.0.5", 12345)) as c:
r = c.post("/api/settings/hf-token", json={"token": SAMPLE_TOKEN})
assert r.status_code == 403
assert "loopback" in r.json().get("detail", "").lower()
def test_delete_hf_token_clears_store(fresh_app, monkeypatch):
import huggingface_hub
monkeypatch.setattr(huggingface_hub, "login", lambda **kw: None)
monkeypatch.setattr(huggingface_hub, "logout", lambda: None)
monkeypatch.setattr(
huggingface_hub,
"whoami",
lambda token=None, **kw: {"name": "alice"},
)
monkeypatch.setattr(huggingface_hub, "get_token", lambda: None)
c = _client(fresh_app)
c.post("/api/settings/hf-token", json={"token": SAMPLE_TOKEN})
from services import settings_store
assert settings_store.get_hf_token() == SAMPLE_TOKEN
r = c.delete("/api/settings/hf-token")
assert r.status_code == 200
assert settings_store.get_hf_token() is None
body = r.json()
assert body["active"] is None
assert all(not s["set"] for s in body["sources"])
def test_get_hf_token_state_returns_three_rows(fresh_app, monkeypatch):
"""GET state returns the same shape as token_resolver.state(): three
SourceState rows in priority order, plus an `active` field."""
import huggingface_hub
monkeypatch.setattr(huggingface_hub, "get_token", lambda: None)
monkeypatch.setattr(
huggingface_hub,
"whoami",
lambda token=None, **kw: {"name": "alice"},
)
c = _client(fresh_app)
r = c.get("/api/settings/hf-token/state")
assert r.status_code == 200
body = r.json()
assert "active" in body
assert "sources" in body
assert [s["source"] for s in body["sources"]] == ["app", "env", "hf-cli"]
def test_get_hf_token_state_loopback_only(fresh_app):
"""GET state is on the same loopback-only router; non-loopback → 403."""
from fastapi.testclient import TestClient
with TestClient(fresh_app, client=("10.0.0.5", 12345)) as c:
r = c.get("/api/settings/hf-token/state")
assert r.status_code == 403
def _build_subprocess_env(resolved_token: Optional[str]) -> dict:
"""Reproduce the env-injection logic that lives in
backend/services/sonitranslate.py:start(). We extract it here so the
test exercises the canonical pattern without standing up the whole
SoniTranslate machinery (which assumes an installed Gradio app on
disk). The pattern matches the one documented in 01-01-PLAN.md Task 3
Step 1."""
import os
from services import token_resolver
env = os.environ.copy()
resolved = token_resolver.resolve()
hf_token = resolved.token if resolved else ""
if hf_token:
env["HF_TOKEN"] = hf_token
env["YOUR_HF_TOKEN"] = hf_token
return env
def test_subprocess_env_includes_hf_token_when_resolved(monkeypatch, tmp_path):
"""When token_resolver.resolve() returns a token, the env block passed
to a Popen-style subprocess launcher contains HF_TOKEN=<that token>.
This is the AUTH-04 invariant; the SoniTranslate launcher (and any
future subprocess launcher) MUST follow this exact pattern."""
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
monkeypatch.delenv("HF_TOKEN", raising=False)
for mod in list(sys.modules):
if (
mod == "core" or mod.startswith("core.")
or mod == "services" or mod.startswith("services.")
):
del sys.modules[mod]
from core import db as _db
_db.init_db()
from services import token_resolver
fake = token_resolver.ResolvedToken(
token=SAMPLE_TOKEN, source="app", username="alice"
)
monkeypatch.setattr(token_resolver, "resolve", lambda **kw: fake)
env = _build_subprocess_env(SAMPLE_TOKEN)
assert env["HF_TOKEN"] == SAMPLE_TOKEN
assert env["YOUR_HF_TOKEN"] == SAMPLE_TOKEN
def test_subprocess_env_unchanged_when_no_token(monkeypatch, tmp_path):
"""When token_resolver.resolve() returns None, the env block does NOT
contain an injected HF_TOKEN. (If the parent had one in os.environ
it would still be in the copy — but the launcher does not _add_ an
empty string, which would clobber any child-set default.)"""
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
monkeypatch.delenv("HF_TOKEN", raising=False)
monkeypatch.delenv("HUGGING_FACE_HUB_TOKEN", raising=False)
for mod in list(sys.modules):
if (
mod == "core" or mod.startswith("core.")
or mod == "services" or mod.startswith("services.")
):
del sys.modules[mod]
from core import db as _db
_db.init_db()
from services import token_resolver
monkeypatch.setattr(token_resolver, "resolve", lambda **kw: None)
env = _build_subprocess_env(None)
# HF_TOKEN must not have been injected by the launcher.
assert "HF_TOKEN" not in env or env.get("HF_TOKEN") == ""
assert "YOUR_HF_TOKEN" not in env
def test_sonitranslate_module_uses_resolver(monkeypatch, tmp_path):
"""Source-level check: the SoniTranslate module's subprocess.Popen
launcher block contains the canonical resolver import. This guards
against future refactors silently reverting the AUTH-04 wiring."""
import inspect
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
for mod in list(sys.modules):
if (
mod == "core" or mod.startswith("core.")
or mod == "services" or mod.startswith("services.")
):
del sys.modules[mod]
from services import sonitranslate
src = inspect.getsource(sonitranslate)
# The launcher must read from the resolver, not from os.environ.
assert "token_resolver.resolve" in src
# And the env injection assigns HF_TOKEN explicitly.
assert 'env["HF_TOKEN"]' in src or "env['HF_TOKEN']" in src