"""Optional GUI API, origin/root boundary, snapshots, and real sound previews.""" from dataclasses import asdict import hashlib from pathlib import Path import struct import time import numpy as np import pytest import soundfile as sf pytest.importorskip("fastapi") from fastapi.testclient import TestClient from producer.config import Settings from producer.gui import create_app from producer import gui_jobs TOKEN = "test-session-credential" AUTH = {"Authorization": f"Bearer {TOKEN}"} URL = "http://127.0.0.1:8765" @pytest.fixture def client(tmp_path): with TestClient(create_app(tmp_path, 8765, TOKEN), base_url=URL, headers=AUTH) as client: yield client def wait(client, job_id): deadline = time.monotonic() + 30 while time.monotonic() < deadline: job = client.get(f"/api/jobs/{job_id}").json() if job["status"] in ("succeeded", "failed", "cancelled"): return job time.sleep(0.02) pytest.fail("Job failed to terminate") def submit(client, path, kind, **kwargs): info = client.get("/api/input", params={"path": path.name}) assert info.status_code == 200, info.text response = client.post("/api/jobs", json={"kind": kind, "path": path.name, "input_id": info.json()["input_id"], **kwargs}) assert response.status_code == 202, response.text return response.json() def test_security_credentials_origins_hosts_and_headers(tmp_path): with TestClient(create_app(tmp_path, 8765, TOKEN), base_url=URL) as client: assert client.get("/").status_code == 200 assert client.get("/api/schema").status_code == 401 assert client.get(f"/api/schema?token={TOKEN}").status_code == 401 assert client.get("/api/schema", headers={"Cookie": f"token={TOKEN}"}).status_code == 401 for headers in [AUTH | {"Host": "evil.example:8765"}, AUTH | {"Host": "localhost:8765"}, AUTH | {"Origin": "https://evil.example"}, AUTH | {"Origin": "null"}, AUTH | {"Origin": "http://127.0.0.1:9999"}, AUTH | {"Sec-Fetch-Site": "cross-site"}]: assert client.get("/api/schema", headers=headers).status_code == 403 assert client.post("/api/jobs", json={}, headers=headers).status_code == 403 response = client.get("/api/schema", headers=AUTH | {"Origin": URL}) assert response.status_code == 200 assert response.headers["Cache-Control"] == "no-store" assert response.headers["Referrer-Policy"] == "no-referrer" assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"] assert "Access-Control-Allow-Origin" not in response.headers assert TOKEN not in response.text assert client.get("/openapi.json").status_code == 404 assert client.post("/api/resolve", content=b"x" * 65537, headers=AUTH).status_code == 413 assert client.post("/api/resolve", content="{", headers=AUTH).status_code == 400 def test_root_browser_symlinks_traversal_and_no_arbitrary_download(client, tmp_path, wav): source = wav() outside = tmp_path.parent / "outside.wav" outside.write_bytes(b"not accessible") (tmp_path / "escape.wav").symlink_to(outside) (tmp_path / "escape-dir").symlink_to(tmp_path.parent, target_is_directory=True) (tmp_path / "inside.wav").symlink_to(source) (tmp_path / "note.txt").write_text("not exposed") (tmp_path / "subdir").mkdir() listing = client.get("/api/files").json() assert listing["parent"] is None assert {item["name"] for item in listing["entries"]} == {"input.wav", "inside.wav", "subdir"} for path in ("../outside.wav", str(outside), "escape.wav", "escape-dir", "subdir/../../outside.wav"): assert client.get("/api/input", params={"path": path}).status_code == 400 assert client.get("/api/files", params={"path": ".."}).status_code == 400 assert client.get("/api/files", params={"limit": 201}).status_code == 400 assert len(client.get("/api/files", params={"limit": 1}).json()["entries"]) == 1 assert client.get("/api/files", params={"limit": 1}).json()["next_offset"] == 1 assert client.get("/assets/../gui.py").status_code == 404 assert client.get("/assets/%2e%2e/gui.py").status_code == 404 assert client.get("/api/results/missing/original.wav").status_code == 404 def test_schema_complete_strict_resolution_and_preset_roundtrip(client, bypass): schema = client.get("/api/schema").json() assert {item["name"] for item in schema["fields"]} == set(asdict(Settings())) assert all(item["group"] and item["help"] for item in schema["fields"]) assert schema["settings"] == asdict(Settings()) for value in ({"unknown": 1}, {"normalize": "false"}, {"target_lufs": -100}, {"normalize": True, "limiter": False}, {"sample_rate": 1234}): assert client.post("/api/resolve", json={"settings": value}).status_code == 400 response = client.post("/api/presets/download", json={"settings": asdict(bypass)}) assert response.status_code == 200 assert "attachment" in response.headers["Content-Disposition"] imported = client.post("/api/presets/import", json={"toml": response.text}) assert imported.json()["settings"] == asdict(bypass) assert client.post("/api/presets/import", json={"toml": "no_such_setting = 2"}).status_code == 400 assert client.post("/api/presets/import", json={"toml": "["}).status_code == 400 def test_config_precedence_and_startup_snapshot(tmp_path): config = tmp_path / "config.toml" config.write_text('profile = "narrator"\ntarget_lufs = -25\n') app = create_app(tmp_path, 8765, TOKEN, config=config, profile="radio", overrides={"target_lufs": -22}) config.write_text("this file changed after startup") with TestClient(app, base_url=URL, headers=AUTH) as client: settings = client.get("/api/schema").json()["settings"] assert settings["profile"] == "radio" and settings["compressor_ratio"] == 3.5 assert settings["target_lufs"] == -22 resolved = client.post("/api/resolve", json={"settings": {"target_lufs": -18, "profile": "natural"}}) assert resolved.json()["settings"]["target_lufs"] == -18 assert resolved.json()["settings"]["compressor_ratio"] == 2 def test_scan_fixed_bins_cache_and_stale_identity(client, wav, monkeypatch): source = wav() original = source.read_bytes() first = wait(client, submit(client, source, "scan")["id"]) assert first["status"] == "succeeded", first result = first["result"] assert len(result["min"]) == len(result["max"]) == 2048 assert max(result["max"]) == pytest.approx(0.15, abs=1e-6) monkeypatch.setattr(gui_jobs, "waveform", lambda *args: pytest.fail("Expected repeated-result cache")) second = wait(client, submit(client, source, "scan")["id"]) assert second["cached"] and second["result"] == result assert source.read_bytes() == original old_identity = first["snapshot"]["input_id"] with source.open("ab") as handle: handle.write(b"changed") response = client.post("/api/jobs", json={"kind": "scan", "path": source.name, "input_id": old_identity}) assert response.status_code == 400 assert "changed" in response.text def test_real_preview_context_exact_crop_bypass_and_export_trim(client, wav, bypass, ffmpeg): t = np.arange(48000 * 8) / 48000 samples = 0.15 * np.sin(2 * np.pi * 440 * t) source = wav(samples) before = hashlib.sha256(source.read_bytes()).hexdigest() requested = asdict(bypass) | {"trim_silence": True, "mp3": True} queued = submit(client, source, "preview", settings=requested, start=2, duration=3) preview = wait(client, queued["id"]) assert preview["status"] == "succeeded", preview result = preview["result"] assert preview["snapshot"]["settings"] == requested assert result["requested_settings"] == requested assert result["effective_settings"] == requested | {"trim_silence": False, "mp3": False} assert result["normalization_mode"] == "disabled" assert result["context_start"] == 0 and result["context_end"] == 8 assert result["approximate"] is True assert "Pacing edits are not previewed" in result["notice"] import io originals = client.get(result["audio"]["original"]) processed = client.get(result["audio"]["processed"]) for response in (originals, processed): assert response.status_code == 200 info = sf.info(io.BytesIO(response.content)) assert info.frames == 3 * 48000 assert info.subtype == "PCM_16" original_audio, _ = sf.read(io.BytesIO(originals.content)) processed_audio, _ = sf.read(io.BytesIO(processed.content)) np.testing.assert_allclose(original_audio, processed_audio, atol=1 / 32768) np.testing.assert_allclose(original_audio, samples[2 * 48000:5 * 48000], atol=1 / 32768) assert result["monitoring"]["available"] assert result["monitoring"]["original_gain_db"] <= 0 assert result["monitoring"]["processed_gain_db"] <= 0 repeated = wait(client, submit(client, source, "preview", settings=requested, start=2, duration=3)["id"]) assert repeated["cached"] and repeated["result"]["result_id"] == result["result_id"] changed = wait(client, submit(client, source, "preview", settings=requested | {"highpass": True}, start=2, duration=3)["id"]) assert not changed["cached"] and changed["result"]["result_id"] != result["result_id"] for _ in range(2): exported = wait(client, submit(client, source, "export", settings=requested)["id"]) assert exported["status"] == "succeeded", exported assert exported["result"]["settings"] == requested assert len(exported["result"]["paths"]) == 3 assert "not auditioned" in exported["result"]["notice"] for path in exported["result"]["paths"]: assert (source.parent / path).is_file() assert len(list(source.parent.glob("producer-export-*"))) == 2 assert hashlib.sha256(source.read_bytes()).hexdigest() == before # A cached audio URL cannot read stale results after the recording changes. with source.open("ab") as handle: handle.write(b"changed") assert client.get(result["audio"]["original"]).status_code == 400 def test_preview_bounds_settings_errors_and_unsupported_input(client, wav): source = wav() for start, duration in ((-1, 1), (0, 121), (0, 0), (2, 2), (True, 1)): info = client.get("/api/input", params={"path": source.name}).json() response = client.post("/api/jobs", json={"kind": "preview", "path": source.name, "input_id": info["input_id"], "start": start, "duration": duration}) assert response.status_code == 400 stereo = wav(np.zeros((20, 2)), name="stereo.wav") assert "Stereo" in client.get("/api/input", params={"path": stereo.name}).json()["detail"] surround = wav(np.zeros((20, 3)), name="surround.wav") assert client.get("/api/input", params={"path": surround.name}).status_code == 400 source.write_bytes(b"not a wav") response = client.get("/api/input", params={"path": source.name}) assert response.status_code == 400 and "Unsupported" in response.text def test_no_total_duration_cap_and_seek_to_end_of_four_hour_input(tmp_path, progress): # Sparse mono PCM16 WAV. No real recordings touched and no huge allocation. source = tmp_path / "four-hours.wav" sr, seconds = 8000, 4 * 60 * 60 data_bytes = sr * seconds * 2 header = (b"RIFF" + struct.pack("