diff --git a/music-lab/app/auth.py b/music-lab/app/auth.py new file mode 100644 index 0000000..e3fb4f2 --- /dev/null +++ b/music-lab/app/auth.py @@ -0,0 +1,17 @@ +"""SP-6 — Windows worker → NAS internal webhook 인증. + +X-Internal-Key 헤더를 .env의 INTERNAL_API_KEY와 비교. +서버 측 키 미설정 시 401 (안전한 기본값). +""" +from __future__ import annotations + +import os +from fastapi import Header, HTTPException + + +def verify_internal_key(x_internal_key: str = Header(...)): + expected = os.getenv("INTERNAL_API_KEY") + if not expected: + raise HTTPException(401, "INTERNAL_API_KEY not configured on server") + if x_internal_key != expected: + raise HTTPException(401, "Invalid X-Internal-Key") diff --git a/music-lab/app/internal_router.py b/music-lab/app/internal_router.py new file mode 100644 index 0000000..6bddfe1 --- /dev/null +++ b/music-lab/app/internal_router.py @@ -0,0 +1,61 @@ +"""SP-6 — Windows music-render → NAS internal webhook. + +POST /api/internal/music/update +- X-Internal-Key 인증 필수 +- music_tasks 테이블 row update (status, progress, message, audio_url, error) +- 옵션 `track` 페이로드가 있으면 music_library에 add_track 호출 +""" +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from . import db +from .auth import verify_internal_key + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class UpdatePayload(BaseModel): + task_id: str + status: str = Field(..., description="processing|succeeded|failed") + progress: int = Field(..., ge=0, le=100) + message: str = "" + audio_url: Optional[str] = None + error: Optional[str] = None + track: Optional[Dict[str, Any]] = None + + +@router.post( + "/api/internal/music/update", + dependencies=[Depends(verify_internal_key)], +) +def music_update(payload: UpdatePayload): + task = db.get_task(payload.task_id) + if task is None: + raise HTTPException(404, f"task not found: {payload.task_id}") + + db.update_task( + payload.task_id, + payload.status, + payload.progress, + message=payload.message, + audio_url=payload.audio_url, + error=payload.error, + ) + + if payload.track: + try: + db.add_track(payload.track) + except Exception: + logger.exception("add_track 실패 task=%s (무시)", payload.task_id) + + logger.info( + "internal/music/update task=%s status=%s progress=%d", + payload.task_id, payload.status, payload.progress, + ) + return {"ok": True} diff --git a/music-lab/tests/test_auth.py b/music-lab/tests/test_auth.py new file mode 100644 index 0000000..2a0912c --- /dev/null +++ b/music-lab/tests/test_auth.py @@ -0,0 +1,23 @@ +"""verify_internal_key dependency — Windows music-render webhook 인증.""" +import pytest +from fastapi import HTTPException +from app.auth import verify_internal_key + + +def test_valid_key_passes(monkeypatch): + monkeypatch.setenv("INTERNAL_API_KEY", "secret123") + verify_internal_key(x_internal_key="secret123") + + +def test_invalid_key_raises_401(monkeypatch): + monkeypatch.setenv("INTERNAL_API_KEY", "secret123") + with pytest.raises(HTTPException) as exc: + verify_internal_key(x_internal_key="wrong") + assert exc.value.status_code == 401 + + +def test_missing_env_key_raises_401(monkeypatch): + monkeypatch.delenv("INTERNAL_API_KEY", raising=False) + with pytest.raises(HTTPException) as exc: + verify_internal_key(x_internal_key="any") + assert exc.value.status_code == 401 diff --git a/music-lab/tests/test_internal_router.py b/music-lab/tests/test_internal_router.py new file mode 100644 index 0000000..e930bca --- /dev/null +++ b/music-lab/tests/test_internal_router.py @@ -0,0 +1,103 @@ +"""POST /api/internal/music/update — Windows music-render webhook.""" +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from app.internal_router import router +from app import db + + +@pytest.fixture(autouse=True) +def _set_key(monkeypatch): + monkeypatch.setenv("INTERNAL_API_KEY", "test-secret") + + +@pytest.fixture +def client(tmp_path, monkeypatch): + monkeypatch.setenv("MUSIC_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "test_music.db")) + db.init_db() + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def _make_task(): + tid = "test-task-1" + db.create_task(tid, {"provider": "suno", "title": "T"}, provider="suno") + return tid + + +def test_update_with_valid_key_updates_db(client): + tid = _make_task() + r = client.post( + "/api/internal/music/update", + headers={"X-Internal-Key": "test-secret"}, + json={"task_id": tid, "status": "processing", "progress": 30, "message": "downloading"}, + ) + assert r.status_code == 200 + task = db.get_task(tid) + assert task["status"] == "processing" + assert task["progress"] == 30 + + +def test_update_with_invalid_key_returns_401(client): + tid = _make_task() + r = client.post( + "/api/internal/music/update", + headers={"X-Internal-Key": "wrong"}, + json={"task_id": tid, "status": "processing", "progress": 30}, + ) + assert r.status_code == 401 + + +def test_update_succeeded_with_audio_url(client): + tid = _make_task() + r = client.post( + "/api/internal/music/update", + headers={"X-Internal-Key": "test-secret"}, + json={ + "task_id": tid, "status": "succeeded", "progress": 100, + "message": "완료", "audio_url": "/media/music/test-task-1.mp3", + }, + ) + assert r.status_code == 200 + task = db.get_task(tid) + assert task["status"] == "succeeded" + assert task["audio_url"] == "/media/music/test-task-1.mp3" + + +def test_update_failed_records_error(client): + tid = _make_task() + r = client.post( + "/api/internal/music/update", + headers={"X-Internal-Key": "test-secret"}, + json={"task_id": tid, "status": "failed", "progress": 0, "error": "Suno API rate limit"}, + ) + assert r.status_code == 200 + task = db.get_task(tid) + assert task["status"] == "failed" + assert "Suno" in (task.get("error") or "") + + +def test_add_track_action_inserts_library(client): + tid = _make_task() + r = client.post( + "/api/internal/music/update", + headers={"X-Internal-Key": "test-secret"}, + json={ + "task_id": tid, "status": "succeeded", "progress": 100, + "message": "ok", + "track": { + "title": "My Song", "genre": "lofi", "moods": ["chill"], + "instruments": [], "duration_sec": 180, "bpm": 80, + "key": "C", "scale": "major", "prompt": "", + "audio_url": "/media/music/test-task-1.mp3", + "file_path": "/app/data/test-task-1.mp3", + "task_id": tid, "provider": "suno", + "lyrics": "", "image_url": "", "suno_id": "suno-abc", + }, + }, + ) + assert r.status_code == 200 + tracks = db.get_all_tracks() + assert any(t["title"] == "My Song" for t in tracks)