"""routes.py 통합 테스트 (DSM, supabase는 mock).""" import os import time import uuid from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient # 테스트용 환경변수 (auth import 전) os.environ["BACKEND_HMAC_SECRET"] = "test-secret-32-bytes-XXXXXXXXXXXX" os.environ["DSM_HOST"] = "https://test.synology.me:5001" os.environ["DSM_USER"] = "test" os.environ["DSM_PASS"] = "test" os.environ["SUPABASE_URL"] = "https://placeholder.supabase.co" os.environ["SUPABASE_SERVICE_KEY"] = "placeholder-key" from app import auth # noqa: E402 from app.main import app # noqa: E402 client = TestClient(app) def _signed(body: bytes) -> dict: ts = str(int(time.time())) sig = auth._sign(ts.encode() + b"." + body) return {"X-Timestamp": ts, "X-Signature": sig, "Content-Type": "application/json"} def test_health(): r = client.get("/health") assert r.status_code == 200 assert r.json()["service"] == "packs-lab" @patch("app.routes.create_share_link", new_callable=AsyncMock) def test_sign_link_success(mock_share): mock_share.return_value = ("https://test.synology.me:5001/d/s/abc", datetime.now(timezone.utc)) # Windows에서는 절대경로 resolve 결과가 C:\... 로 prefix되므로 PACK_HOST_DIR도 동일하게 패치 # sign-link는 PACK_HOST_DIR(NAS 호스트경로) 기준으로 검증함. from pathlib import Path abs_resolved = Path("/volume1/docker/webpage/media/packs/master/x.mp4").resolve() base_resolved = Path(str(abs_resolved).rsplit("master", 1)[0].rstrip("\\/")) with patch("app.routes.PACK_HOST_DIR", base_resolved): body = b'{"file_path":"/volume1/docker/webpage/media/packs/master/x.mp4","expires_in_seconds":14400}' r = client.post("/api/packs/sign-link", content=body, headers=_signed(body)) assert r.status_code == 200 assert "url" in r.json() def test_sign_link_no_hmac(): r = client.post("/api/packs/sign-link", json={"file_path": "/x"}) assert r.status_code == 401 def test_sign_link_path_outside_base(): body = b'{"file_path":"/etc/passwd","expires_in_seconds":14400}' r = client.post("/api/packs/sign-link", content=body, headers=_signed(body)) assert r.status_code == 400 def test_upload_invalid_token(): r = client.post( "/api/packs/upload", files={"file": ("x.pdf", b"abc", "application/pdf")}, headers={"Authorization": "Bearer invalid"}, ) assert r.status_code == 401 def test_upload_no_auth(): r = client.post( "/api/packs/upload", files={"file": ("x.pdf", b"abc", "application/pdf")}, ) assert r.status_code == 401 @patch("app.routes._supabase") def test_list_success(mock_sb): mock_table = MagicMock() mock_table.select.return_value = mock_table mock_table.is_.return_value = mock_table mock_table.order.return_value = mock_table mock_table.execute.return_value = MagicMock(data=[ { "id": str(uuid.uuid4()), "min_tier": "starter", "label": "테스트", "file_path": "/volume1/.../x.pdf", "filename": "x.pdf", "size_bytes": 100, "sort_order": 0, "uploaded_at": "2026-05-02T12:00:00+00:00", } ]) mock_sb.return_value.table.return_value = mock_table body = b'' r = client.get("/api/packs/list", headers=_signed(body)) assert r.status_code == 200 assert len(r.json()) == 1 def test_mint_token_hmac_required(): """HMAC 헤더 누락 → 401.""" body = {"tier": "pro", "label": "샘플", "filename": "x.zip", "size_bytes": 1024} resp = client.post("/api/packs/admin/mint-token", json=body) assert resp.status_code == 401 def test_mint_token_returns_valid_token(): """발급된 token이 verify_upload_token으로 통과해야 한다.""" from app.auth import verify_upload_token body = {"tier": "pro", "label": "샘플", "filename": "test.zip", "size_bytes": 2048} import json as _json body_bytes = _json.dumps(body).encode() resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=_signed(body_bytes)) assert resp.status_code == 200 data = resp.json() assert "token" in data and "expires_at" in data and "jti" in data payload = verify_upload_token(data["token"]) assert payload["tier"] == "pro" assert payload["label"] == "샘플" assert payload["filename"] == "test.zip" assert payload["size_bytes"] == 2048 assert payload["jti"] == data["jti"] def test_mint_token_invalid_filename(): """허용 외 확장자 → 400.""" body = {"tier": "pro", "label": "샘플", "filename": "x.exe", "size_bytes": 1024} import json as _json body_bytes = _json.dumps(body).encode() resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=_signed(body_bytes)) assert resp.status_code == 400 def test_upload_size_mismatch(tmp_path, monkeypatch): """토큰 size_bytes ≠ 실제 파일 크기 → 400 + 파일 정리됨.""" monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path) token = auth.mint_upload_token({ "tier": "pro", "label": "샘플", "filename": "size_mismatch_test.zip", "size_bytes": 999, "jti": str(uuid.uuid4()), "expires_at": int(time.time()) + 1800, }) test_client = TestClient(app) resp = test_client.post( "/api/packs/upload", files={"file": ("size_mismatch_test.zip", b"hello")}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 400 assert "크기" in resp.json()["detail"] # 파일이 정리되었는지 확인 (평면 구조) assert not (tmp_path / "size_mismatch_test.zip").exists() def test_upload_jti_replay(tmp_path, monkeypatch): """같은 jti 토큰 두 번 → 두 번째 409.""" monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path) fake_supabase = MagicMock() fake_supabase.table.return_value.insert.return_value.execute.return_value = MagicMock( data=[{"uploaded_at": "2026-05-05T12:00:00+00:00"}] ) unique_jti = f"replay-jti-unique-{uuid.uuid4()}" token = auth.mint_upload_token({ "tier": "pro", "label": "샘플", "filename": "replay_test.zip", "size_bytes": 5, "jti": unique_jti, "expires_at": int(time.time()) + 1800, }) with patch("app.routes._supabase", return_value=fake_supabase): test_client = TestClient(app) resp1 = test_client.post( "/api/packs/upload", files={"file": ("replay_test.zip", b"hello")}, headers={"Authorization": f"Bearer {token}"}, ) assert resp1.status_code == 200 # 2차 — 동일 토큰 재사용 → 409 resp2 = test_client.post( "/api/packs/upload", files={"file": ("replay_test.zip", b"world")}, headers={"Authorization": f"Bearer {token}"}, ) assert resp2.status_code == 409 def test_delete_soft_deletes(): """DELETE 시 supabase update에 deleted_at ISO timestamp 들어가야 한다.""" fake_supabase = MagicMock() fake_supabase.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock( data=[{"id": "abc"}] ) body_bytes = b"" headers = _signed(body_bytes) with patch("app.routes._supabase", return_value=fake_supabase): test_client = TestClient(app) resp = test_client.delete("/api/packs/abc", headers=headers) assert resp.status_code == 200 update_call = fake_supabase.table.return_value.update.call_args update_kwargs = update_call.args[0] assert "deleted_at" in update_kwargs assert "T" in update_kwargs["deleted_at"] # ISO 8601 def test_list_filters_deleted(): """list 라우트가 supabase에 is_(deleted_at, null) 필터를 적용하는지 검증.""" fake_rows = [{ "id": "11111111-1111-1111-1111-111111111111", "min_tier": "pro", "label": "샘플", "file_path": "/volume1/docker/webpage/media/packs/pro/a.zip", "filename": "a.zip", "size_bytes": 1024, "sort_order": 0, "uploaded_at": "2026-05-05T12:00:00+00:00", }] fake_supabase = MagicMock() chain = fake_supabase.table.return_value.select.return_value chain.is_.return_value.order.return_value.order.return_value.execute.return_value = MagicMock(data=fake_rows) body_bytes = b"" headers = _signed(body_bytes) with patch("app.routes._supabase", return_value=fake_supabase): test_client = TestClient(app) resp = test_client.get("/api/packs/list", headers=headers) assert resp.status_code == 200 fake_supabase.table.return_value.select.return_value.is_.assert_called_with("deleted_at", "null") def _mint(filename: str, size: int, jti: str = None) -> str: return auth.mint_upload_token({ "tier": "pro", "label": "샘플", "filename": filename, "size_bytes": size, "jti": jti or str(uuid.uuid4()), "expires_at": int(time.time()) + 1800, }) def test_chunk_upload_full_flow(tmp_path, monkeypatch): """init → chunk(0) → chunk(N) → complete 정상 흐름.""" monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path) from pathlib import Path monkeypatch.setattr("app.routes.PACK_HOST_DIR", Path("/volume1/host")) fake_supabase = MagicMock() fake_supabase.table.return_value.insert.return_value.execute.return_value = MagicMock( data=[{"uploaded_at": "2026-05-12T00:00:00+00:00"}] ) payload = b"a" * 100 + b"b" * 50 # 150 bytes total chunk1 = payload[:100] chunk2 = payload[100:] jti = str(uuid.uuid4()) token = _mint("chunk_full.zip", len(payload), jti=jti) headers = {"Authorization": f"Bearer {token}"} with patch("app.routes._supabase", return_value=fake_supabase): test_client = TestClient(app) # init r = test_client.post("/api/packs/upload/init", headers=headers) assert r.status_code == 200, r.text sid = r.json()["session_id"] assert sid == jti assert r.json()["expected_size"] == 150 # chunk 1 (offset=0) r = test_client.put( f"/api/packs/upload/{sid}/chunk?offset=0", content=chunk1, headers=headers, ) assert r.status_code == 200, r.text assert r.json()["written"] == 100 # chunk 2 (offset=100) r = test_client.put( f"/api/packs/upload/{sid}/chunk?offset=100", content=chunk2, headers=headers, ) assert r.status_code == 200 assert r.json()["written"] == 150 # complete r = test_client.post(f"/api/packs/upload/{sid}/complete", headers=headers) assert r.status_code == 200, r.text body = r.json() assert body["filename"] == "chunk_full.zip" assert body["size_bytes"] == 150 assert body["file_path"] == "/volume1/host/chunk_full.zip" or body["file_path"].endswith("chunk_full.zip") # 파일이 최종 위치로 이동했고 session은 정리됨 assert (tmp_path / "chunk_full.zip").read_bytes() == payload assert not (tmp_path / ".uploads" / sid).exists() def test_chunk_upload_offset_mismatch(tmp_path, monkeypatch): """잘못된 offset → 409 + X-Current-Offset 헤더.""" monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path) jti = str(uuid.uuid4()) token = _mint("offset_mismatch.zip", 100, jti=jti) headers = {"Authorization": f"Bearer {token}"} test_client = TestClient(app) r = test_client.post("/api/packs/upload/init", headers=headers) assert r.status_code == 200 sid = r.json()["session_id"] # 잘못된 offset (10인데 0이어야 함) r = test_client.put( f"/api/packs/upload/{sid}/chunk?offset=10", content=b"x" * 10, headers=headers, ) assert r.status_code == 409 assert r.headers.get("X-Current-Offset") == "0" def test_chunk_upload_status(tmp_path, monkeypatch): """status로 현재 written 조회.""" monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path) jti = str(uuid.uuid4()) token = _mint("status_check.zip", 50, jti=jti) headers = {"Authorization": f"Bearer {token}"} test_client = TestClient(app) r = test_client.post("/api/packs/upload/init", headers=headers) sid = r.json()["session_id"] # 빈 상태 r = test_client.get(f"/api/packs/upload/{sid}/status", headers=headers) assert r.status_code == 200 assert r.json()["written"] == 0 assert r.json()["expected_size"] == 50 # 일부 업로드 후 test_client.put( f"/api/packs/upload/{sid}/chunk?offset=0", content=b"x" * 20, headers=headers, ) r = test_client.get(f"/api/packs/upload/{sid}/status", headers=headers) assert r.json()["written"] == 20 def test_chunk_upload_abort(tmp_path, monkeypatch): """DELETE → session 디렉토리 정리.""" monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path) jti = str(uuid.uuid4()) token = _mint("abort_test.zip", 30, jti=jti) headers = {"Authorization": f"Bearer {token}"} test_client = TestClient(app) test_client.post("/api/packs/upload/init", headers=headers) test_client.put( f"/api/packs/upload/{jti}/chunk?offset=0", content=b"y" * 10, headers=headers, ) assert (tmp_path / ".uploads" / jti).exists() r = test_client.delete(f"/api/packs/upload/{jti}", headers=headers) assert r.status_code == 200 assert not (tmp_path / ".uploads" / jti).exists() def test_chunk_upload_wrong_token(tmp_path, monkeypatch): """다른 jti의 token으로 chunk 호출 → 403.""" monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path) # session A 시작 jti_a = str(uuid.uuid4()) token_a = _mint("wrong_token_a.zip", 30, jti=jti_a) headers_a = {"Authorization": f"Bearer {token_a}"} test_client = TestClient(app) test_client.post("/api/packs/upload/init", headers=headers_a) # session B의 token으로 session A의 chunk 호출 jti_b = str(uuid.uuid4()) token_b = _mint("wrong_token_b.zip", 30, jti=jti_b) headers_b = {"Authorization": f"Bearer {token_b}"} r = test_client.put( f"/api/packs/upload/{jti_a}/chunk?offset=0", content=b"z" * 10, headers=headers_b, ) assert r.status_code == 403 def test_chunk_upload_complete_incomplete(tmp_path, monkeypatch): """expected_size 미달 상태에서 complete 호출 → 400.""" monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path) jti = str(uuid.uuid4()) token = _mint("incomplete.zip", 100, jti=jti) headers = {"Authorization": f"Bearer {token}"} test_client = TestClient(app) test_client.post("/api/packs/upload/init", headers=headers) test_client.put( f"/api/packs/upload/{jti}/chunk?offset=0", content=b"q" * 50, headers=headers, ) r = test_client.post(f"/api/packs/upload/{jti}/complete", headers=headers) assert r.status_code == 400 assert "미완료" in r.json()["detail"] def test_chunk_init_filename_collision(tmp_path, monkeypatch): """init 시 동일 파일명이 PACK_BASE_DIR에 이미 있으면 409.""" monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path) (tmp_path / "existing.zip").write_bytes(b"already here") token = _mint("existing.zip", 100) r = TestClient(app).post( "/api/packs/upload/init", headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 409 def test_chunk_upload_stores_host_path(tmp_path, monkeypatch): """complete 시 Supabase에 저장되는 file_path는 PACK_HOST_DIR 기준.""" from pathlib import Path container_base = tmp_path / "container" host_base = Path("/volume1/host/packs") monkeypatch.setattr("app.routes.PACK_BASE_DIR", container_base) monkeypatch.setattr("app.routes.PACK_HOST_DIR", host_base) captured = {} fake_supabase = MagicMock() def capture_insert(payload): captured.update(payload) m = MagicMock() m.execute.return_value = MagicMock(data=[{"uploaded_at": "2026-05-12T00:00:00+00:00"}]) return m fake_supabase.table.return_value.insert.side_effect = capture_insert jti = str(uuid.uuid4()) token = _mint("hostpath_chunk.zip", 5, jti=jti) headers = {"Authorization": f"Bearer {token}"} with patch("app.routes._supabase", return_value=fake_supabase): c = TestClient(app) c.post("/api/packs/upload/init", headers=headers) c.put(f"/api/packs/upload/{jti}/chunk?offset=0", content=b"hello", headers=headers) r = c.post(f"/api/packs/upload/{jti}/complete", headers=headers) assert r.status_code == 200 assert captured["file_path"] == str(host_base / "hostpath_chunk.zip") def test_upload_stores_host_path_not_container_path(tmp_path, monkeypatch): """upload 시 Supabase에 저장되는 file_path는 PACK_BASE_DIR(컨테이너) 가 아닌 PACK_HOST_DIR(NAS 호스트) 절대경로여야 한다. DSM API는 NAS 호스트 절대경로 기준이라 컨테이너 내부 경로(/app/data/packs/...)를 Supabase에 저장하면 sign-link 시 DSM이 파일을 못 찾는다. """ from pathlib import Path container_base = tmp_path / "container" host_base = Path("/volume1/docker/webpage/media/packs") monkeypatch.setattr("app.routes.PACK_BASE_DIR", container_base) monkeypatch.setattr("app.routes.PACK_HOST_DIR", host_base) captured_insert = {} fake_supabase = MagicMock() def capture_insert(payload): captured_insert.update(payload) m = MagicMock() m.execute.return_value = MagicMock(data=[{"uploaded_at": "2026-05-11T00:00:00+00:00"}]) return m fake_supabase.table.return_value.insert.side_effect = capture_insert token = auth.mint_upload_token({ "tier": "pro", "label": "샘플", "filename": "host_path_check.zip", "size_bytes": 5, "jti": str(uuid.uuid4()), "expires_at": int(time.time()) + 1800, }) with patch("app.routes._supabase", return_value=fake_supabase): test_client = TestClient(app) resp = test_client.post( "/api/packs/upload", files={"file": ("host_path_check.zip", b"hello")}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 # Supabase에 저장된 file_path는 호스트 경로 expected_host = str(host_base / "host_path_check.zip") assert captured_insert["file_path"] == expected_host # 응답의 file_path도 호스트 경로 assert resp.json()["file_path"] == expected_host # 컨테이너 경로(tmp_path 하위)와 다름 assert str(container_base) not in captured_insert["file_path"]