mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 13:20:39 -07:00
The suite hadn't run green since the routes refactor: - test_profile_duplicate_names.py imported the pre-refactor module layout and broke collection; now imports backend.services.profiles - tests/conftest.py puts the repo root and backend dir on sys.path so files collect standalone instead of depending on run order - test_cors.py tested a hand-copied mirror of the origin list that had drifted from app.py (missing http://tauri.localhost); it now builds the app via the real create_app() factory - test_progress.py simulated a 1KB download, below the tracker's 1MB reporting threshold; simulation raised to 5MB - slow/timeout markers registered in pyproject Ruff: ~900 violations auto-fixed (typing modernization, import sorting, unused imports, whitespace). The remaining rules are baselined in pyproject.toml with per-rule counts to burn down, plus per-file carve-outs for deliberate env-before-import ordering. ruff check is now clean; suite is 134 passed, 2 skipped.
131 lines
4.9 KiB
Python
131 lines
4.9 KiB
Python
"""
|
|
Tests for CORS origin restrictions.
|
|
|
|
Validates that the CORS middleware only allows known local origins
|
|
and respects the VOICEBOX_CORS_ORIGINS environment variable.
|
|
|
|
Builds the app via the real ``backend.app.create_app`` factory so the
|
|
tests exercise the actual CORS configuration rather than a copy of it.
|
|
"""
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
from backend.app import create_app
|
|
|
|
|
|
def _build_client(monkeypatch, env_origins: str | None = None) -> TestClient:
|
|
if env_origins is None:
|
|
monkeypatch.delenv("VOICEBOX_CORS_ORIGINS", raising=False)
|
|
else:
|
|
monkeypatch.setenv("VOICEBOX_CORS_ORIGINS", env_origins)
|
|
# Plain TestClient (no context manager) skips lifespan startup, so no
|
|
# model scans or queue workers run — only middleware is exercised.
|
|
return TestClient(create_app())
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch):
|
|
return _build_client(monkeypatch)
|
|
|
|
|
|
@pytest.fixture
|
|
def client_with_custom_origins(monkeypatch):
|
|
return _build_client(monkeypatch, "https://custom.example.com,https://other.example.com")
|
|
|
|
|
|
def _get_with_origin(client: TestClient, origin: str) -> dict:
|
|
"""Send a GET with Origin header, return response headers."""
|
|
response = client.get("/health", headers={"Origin": origin})
|
|
return dict(response.headers)
|
|
|
|
|
|
def _preflight(client: TestClient, origin: str) -> dict:
|
|
"""Send CORS preflight OPTIONS request, return response headers."""
|
|
response = client.options(
|
|
"/health",
|
|
headers={
|
|
"Origin": origin,
|
|
"Access-Control-Request-Method": "GET",
|
|
},
|
|
)
|
|
return dict(response.headers)
|
|
|
|
|
|
class TestCORSDefaultOrigins:
|
|
"""CORS should allow known local origins and block everything else."""
|
|
|
|
@pytest.mark.parametrize("origin", [
|
|
"http://localhost:5173",
|
|
"http://127.0.0.1:5173",
|
|
"http://localhost:17493",
|
|
"http://127.0.0.1:17493",
|
|
"tauri://localhost",
|
|
"https://tauri.localhost",
|
|
"http://tauri.localhost",
|
|
])
|
|
def test_allowed_origins(self, client, origin):
|
|
headers = _get_with_origin(client, origin)
|
|
assert headers.get("access-control-allow-origin") == origin
|
|
|
|
@pytest.mark.parametrize("origin", [
|
|
"http://evil.com",
|
|
"http://localhost:9999",
|
|
"https://attacker.example.com",
|
|
"null",
|
|
])
|
|
def test_blocked_origins(self, client, origin):
|
|
headers = _get_with_origin(client, origin)
|
|
assert "access-control-allow-origin" not in headers
|
|
|
|
def test_preflight_allowed(self, client):
|
|
headers = _preflight(client, "http://localhost:5173")
|
|
assert headers.get("access-control-allow-origin") == "http://localhost:5173"
|
|
|
|
def test_preflight_blocked(self, client):
|
|
headers = _preflight(client, "http://evil.com")
|
|
assert "access-control-allow-origin" not in headers
|
|
|
|
def test_credentials_header_present(self, client):
|
|
headers = _get_with_origin(client, "http://localhost:5173")
|
|
assert headers.get("access-control-allow-credentials") == "true"
|
|
|
|
|
|
class TestCORSCustomOrigins:
|
|
"""VOICEBOX_CORS_ORIGINS env var should extend the allowlist."""
|
|
|
|
def test_custom_origin_allowed(self, client_with_custom_origins):
|
|
headers = _get_with_origin(client_with_custom_origins, "https://custom.example.com")
|
|
assert headers.get("access-control-allow-origin") == "https://custom.example.com"
|
|
|
|
def test_other_custom_origin_allowed(self, client_with_custom_origins):
|
|
headers = _get_with_origin(client_with_custom_origins, "https://other.example.com")
|
|
assert headers.get("access-control-allow-origin") == "https://other.example.com"
|
|
|
|
def test_default_origins_still_work(self, client_with_custom_origins):
|
|
headers = _get_with_origin(client_with_custom_origins, "http://localhost:5173")
|
|
assert headers.get("access-control-allow-origin") == "http://localhost:5173"
|
|
|
|
def test_unlisted_origin_still_blocked(self, client_with_custom_origins):
|
|
headers = _get_with_origin(client_with_custom_origins, "http://evil.com")
|
|
assert "access-control-allow-origin" not in headers
|
|
|
|
|
|
class TestCORSEnvVarParsing:
|
|
"""Edge cases for VOICEBOX_CORS_ORIGINS parsing."""
|
|
|
|
def test_empty_env_var(self, monkeypatch):
|
|
client = _build_client(monkeypatch, "")
|
|
headers = _get_with_origin(client, "http://evil.com")
|
|
assert "access-control-allow-origin" not in headers
|
|
|
|
def test_whitespace_trimmed(self, monkeypatch):
|
|
client = _build_client(monkeypatch, " https://spaced.example.com ")
|
|
headers = _get_with_origin(client, "https://spaced.example.com")
|
|
assert headers.get("access-control-allow-origin") == "https://spaced.example.com"
|
|
|
|
def test_trailing_comma_ignored(self, monkeypatch):
|
|
client = _build_client(monkeypatch, "https://one.example.com,")
|
|
headers = _get_with_origin(client, "https://one.example.com")
|
|
assert headers.get("access-control-allow-origin") == "https://one.example.com"
|