File size: 8,697 Bytes
94004a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
"""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