Spaces:
Running
Running
| """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" | |
| 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 | |