FIX: 11 bugs bảo mật/ổn định (static mount chặn dotfile, delete traversal, cleanup giữ clips serverFileId, upload whitelist, password strength, auth audio endpoints, pedalboard==0.9.19, vendor CDN local) + FEATURE: Carla bridge preview/export MIDI notes âm VSTi (POST /midi-render, /carla-play-notes, nút Preview VSTi/Export MIDI->Audio; pedalboard 0.9.19 raw MIDI bytes; SONICFORGE_STORAGE_DIR cô lập test storage)
This commit is contained in:
@@ -12,3 +12,9 @@ os.environ.setdefault(
|
||||
"SONICFORGE_DB_PATH",
|
||||
os.path.join(tempfile.gettempdir(), "sonicforge_test.db"),
|
||||
)
|
||||
# Cô lập storage khỏi app production (đang chạy root, ghi sf_scan_state.json
|
||||
# vào storage thật → pytest gặp PermissionError khi file root-owned).
|
||||
os.environ.setdefault(
|
||||
"SONICFORGE_STORAGE_DIR",
|
||||
os.path.join(tempfile.gettempdir(), "sonicforge_test_storage"),
|
||||
)
|
||||
|
||||
@@ -11,8 +11,11 @@ client = TestClient(app)
|
||||
|
||||
|
||||
def get_admin_token():
|
||||
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
if resp.status_code == 200:
|
||||
# test_auth_and_quota.py có thể đã rotate password; thử cả 2.
|
||||
for pwd in ("admin123", "admin_new_password_2026"):
|
||||
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": pwd})
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
token = resp.json()["access_token"]
|
||||
# Admin is seeded with must_change_password=1; the app blocks music
|
||||
# processing until the first password change. Complete that flow here
|
||||
@@ -20,7 +23,7 @@ def get_admin_token():
|
||||
user = resp.json()["user"]
|
||||
if user.get("must_change_password"):
|
||||
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
|
||||
json={"old_password": "admin123", "new_password": "admin123"})
|
||||
json={"old_password": pwd, "new_password": pwd})
|
||||
if r.status_code == 200:
|
||||
token = r.json()["access_token"]
|
||||
return token
|
||||
@@ -145,3 +148,67 @@ class TestPluginAPI:
|
||||
client.post("/api/v1/plugins/dirs", headers=h, json={"plugin_dirs": [d1]})
|
||||
g = client.get("/api/v1/plugins/dirs", headers=h)
|
||||
assert g.json()["plugin_dirs"] == [d1]
|
||||
|
||||
class TestMidiRenderVSTi:
|
||||
"""Preview/export MIDI notes với âm VSTi — feature Carla bridge → pedalboard."""
|
||||
|
||||
def test_midi_render_requires_auth(self):
|
||||
client.cookies.clear() # TestClient giữ cookie login từ test trước
|
||||
resp = client.post("/api/v1/plugins/midi-render",
|
||||
json={"instrument_id": "x", "notes": [{"pitch": 60}]})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_midi_render_no_notes(self):
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
resp = client.post("/api/v1/plugins/midi-render", headers={"Authorization": f"Bearer {token}"},
|
||||
json={"instrument_id": "x", "notes": []})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_midi_render_unknown_instrument(self):
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
resp = client.post("/api/v1/plugins/midi-render", headers={"Authorization": f"Bearer {token}"},
|
||||
json={"instrument_id": "NoSuchPluginXYZ",
|
||||
"notes": [{"pitch": 60, "start_beat": 0, "duration_beats": 1, "velocity": 0.8}]})
|
||||
assert resp.status_code == 404
|
||||
assert "VSTi" in resp.json()["detail"]
|
||||
|
||||
def test_midi_render_requires_pedalboard(self):
|
||||
# Nếu pedalboard thiếu → 501 (không crash)
|
||||
import app.api.v1.plugins as plugins_mod
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
if not plugins_mod.HAS_PEDALBOARD:
|
||||
resp = client.post("/api/v1/plugins/midi-render", headers={"Authorization": f"Bearer {token}"},
|
||||
json={"instrument_id": "x",
|
||||
"notes": [{"pitch": 60, "start_beat": 0, "duration_beats": 1}]})
|
||||
assert resp.status_code == 501
|
||||
else:
|
||||
pytest.skip("pedalboard present — render path covered by unknown-instrument test")
|
||||
|
||||
def test_carla_play_notes_requires_auth(self):
|
||||
client.cookies.clear() # TestClient giữ cookie login từ test trước
|
||||
resp = client.post("/api/v1/plugins/carla-play-notes",
|
||||
json={"notes": [{"pitch": 60}], "bpm": 120})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_carla_play_notes_no_notes(self):
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
resp = client.post("/api/v1/plugins/carla-play-notes", headers={"Authorization": f"Bearer {token}"},
|
||||
json={"notes": [], "bpm": 120})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_carla_play_notes_no_carla(self):
|
||||
# Máy test không có Carla local → 409 hướng dẫn định vị/mở Carla.
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
resp = client.post("/api/v1/plugins/carla-play-notes", headers={"Authorization": f"Bearer {token}"},
|
||||
json={"notes": [{"pitch": 60, "start_beat": 0, "duration_beats": 1}], "bpm": 120})
|
||||
assert resp.status_code == 409
|
||||
|
||||
@@ -10,7 +10,9 @@ Covers the vulnerabilities found during the 2026-08 audit:
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
@@ -68,9 +70,32 @@ class TestAIProxySSRF:
|
||||
|
||||
def test_proxy_allows_configured_localhost_provider(self):
|
||||
# localhost:11434 is in the default AI provider list; it must pass the
|
||||
# SSRF check (and then fail to connect in this environment -> 502).
|
||||
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||
json={"url": "http://localhost:11434/v1/chat/completions", "body": {}})
|
||||
# SSRF check (and then fail to connect -> 502). Bug #5: máy có Ollama
|
||||
# chạy ở localhost:11434 trả 200 → test fail giả. Mock connection fail
|
||||
# để test deterministic, không phụ thuộc service ngoài.
|
||||
import app.api.v1.ai_proxy as ai_proxy
|
||||
|
||||
class _FakeConnectError(httpx.ConnectError):
|
||||
def __init__(self, *a, **k):
|
||||
super().__init__("mock connection refused", request=httpx.Request("POST", "http://localhost:11434/"))
|
||||
|
||||
class _FakeAsyncClient(httpx.AsyncClient):
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, *a, **k):
|
||||
raise _FakeConnectError()
|
||||
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setattr(ai_proxy.httpx, "AsyncClient", _FakeAsyncClient)
|
||||
try:
|
||||
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||
json={"url": "http://localhost:11434/v1/chat/completions", "body": {}})
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
@@ -232,3 +257,101 @@ class TestRenderResample:
|
||||
os.remove(src_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ── 7. Bug fixes 2026-08-10 audit round 2 ──
|
||||
|
||||
class TestBugFixes:
|
||||
def test_static_mount_blocks_dotfiles(self):
|
||||
# Bug #1: /static/audio/.secret_key, sonicforge.db, temp/autosave.json
|
||||
# từng serve 200 không cần auth — giờ phải 404.
|
||||
assert client.get("/static/audio/.secret_key").status_code == 404
|
||||
assert client.get("/static/audio/sonicforge.db").status_code == 404
|
||||
assert client.get("/static/audio/temp/autosave.json").status_code == 404
|
||||
assert client.get("/static/audio/../.secret_key").status_code in (404, 400)
|
||||
|
||||
def test_static_uploads_still_served(self):
|
||||
# File audio hợp lệ trong uploads vẫn phải serve được (không vỡ UI).
|
||||
fid = f"user_test_{os.urandom(4).hex()}.wav"
|
||||
p = os.path.join(settings.UPLOADS_DIR, fid)
|
||||
with open(p, "wb") as f:
|
||||
f.write(b"RIFFxxxxWAVE")
|
||||
try:
|
||||
assert client.get(f"/static/audio/uploads/{fid}").status_code == 200
|
||||
finally:
|
||||
os.remove(p)
|
||||
|
||||
def test_delete_user_file_blocks_traversal(self):
|
||||
# Bug #2: `user_<id>_../../x` không được xóa file ngoài storage.
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
prof = client.get("/api/v1/auth/profile", headers={"Authorization": f"Bearer {token}"})
|
||||
uid = prof.json()["id"]
|
||||
marker = os.path.join(settings.STORAGE_DIR, "pwn_marker_%s.txt" % os.urandom(4).hex())
|
||||
with open(marker, "w") as f:
|
||||
f.write("x")
|
||||
try:
|
||||
resp = client.delete(f"/api/v1/audio/my-files/user_{uid}_../../{os.path.basename(marker)}",
|
||||
headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code in (403, 404)
|
||||
assert os.path.exists(marker), "traversal delete phải bị chặn"
|
||||
finally:
|
||||
if os.path.exists(marker):
|
||||
os.remove(marker)
|
||||
|
||||
def test_upload_rejects_non_audio_extension(self):
|
||||
# Bug #4: upload .exe bị từ chối.
|
||||
resp = client.post("/api/v1/audio/upload",
|
||||
files={"file": ("evil.exe", b"MZ\x90\x00", "application/octet-stream")})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_change_password_rejects_weak(self):
|
||||
# Bug #7: change-password áp cùng policy độ mạnh như register.
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
resp = client.post("/api/v1/auth/change-password",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"old_password": "admin123", "new_password": "a"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_cleanup_keeps_referenced_files(self):
|
||||
# Bug #3: cleanup_expired_files_task không xóa file được project tham chiếu.
|
||||
from app.tasks.worker import cleanup_expired_files_task
|
||||
from app.models.user import get_db_connection
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
prof = client.get("/api/v1/auth/profile", headers={"Authorization": f"Bearer {token}"})
|
||||
uid = prof.json()["id"]
|
||||
ref_id = f"user_{uid}_ref_{os.urandom(4).hex()}.wav"
|
||||
stray_id = f"user_{uid}_stray_{os.urandom(4).hex()}.wav"
|
||||
ref_path = os.path.join(settings.PROCESSED_DIR, ref_id)
|
||||
stray_path = os.path.join(settings.PROCESSED_DIR, stray_id)
|
||||
old = time.time() - 999999
|
||||
for p in (ref_path, stray_path):
|
||||
with open(p, "wb") as f:
|
||||
f.write(b"RIFFxxxxWAVE")
|
||||
os.utime(p, (old, old))
|
||||
pid = f"cleanup_test_{os.urandom(4).hex()}"
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
proj = {"tracks": [{"serverFileId": ref_id}]}
|
||||
cursor.execute("INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at) VALUES (?,?,?,?,0,?,?)",
|
||||
(pid, uid, "cleanup test", json.dumps(proj), 0, old))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
try:
|
||||
result = cleanup_expired_files_task(max_age_hours=0)
|
||||
assert os.path.exists(ref_path), "file được project tham chiếu phải giữ"
|
||||
assert not os.path.exists(stray_path), "file không tham chiếu phải bị dọn"
|
||||
assert result["referenced_files_kept"] >= 1
|
||||
finally:
|
||||
for p in (ref_path, stray_path):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM projects WHERE id = ?", (pid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -21,14 +21,9 @@ class TestPluginManager:
|
||||
sr = 44100
|
||||
msgs = PluginManager.midi_events_to_messages(events, bpm, sr)
|
||||
beat_sec = 60.0 / 120
|
||||
expected_note_on_offset = 0
|
||||
expected_note_off_offset = int(beat_sec * sr)
|
||||
# Check note_on message
|
||||
assert msgs[0].sample_offset == expected_note_on_offset
|
||||
assert msgs[0].note == 60
|
||||
# Check note_off message
|
||||
assert msgs[1].sample_offset == expected_note_off_offset
|
||||
assert msgs[1].note == 60
|
||||
# pedalboard >= 0.9: message = (bytes raw MIDI, timestamp_seconds)
|
||||
assert msgs[0] == (bytes([0x90, 60, 100]), 0.0)
|
||||
assert msgs[1] == (bytes([0x80, 60, 0]), beat_sec)
|
||||
|
||||
def test_list_available_empty(self):
|
||||
pm = PluginManager(vst_dir="/tmp/nonexistent_vst_dir_xyz", sf_dir="/tmp/nonexistent_sf_dir_xyz")
|
||||
|
||||
Reference in New Issue
Block a user