Files
web-page-backend/packs-lab/tests/test_routes.py
gahusb b4dd21e67a feat(packs-lab): chunked resumable upload (offset-based) 추가
기존 single-shot POST /upload는 그대로 유지하고, 5GB+ 안정성을 위한
chunk upload 5-endpoint를 추가했다.

- POST /upload/init — mint-token jti consume + 세션 디렉토리 생성
- PUT /upload/{sid}/chunk?offset=N — offset 매칭 후 .part 파일 append
  · 불일치 시 409 + X-Current-Offset 헤더로 재개 지점 통보
- GET /upload/{sid}/status — 현재 written / expected_size 조회
- POST /upload/{sid}/complete — atomic rename + Supabase INSERT
- DELETE /upload/{sid} — 세션 중단 + 부분파일 정리

auth.py: verify_upload_token_no_consume() 추가 — chunk/complete/abort/status
는 동일 mint-token을 재사용해야 하므로 jti consume 없이 시그니처+만료만 검증.

models.py: InitUploadResponse, ChunkUploadResponse 추가.

세션 state: PACK_BASE_DIR/.uploads/{jti}/meta.json + data.part (파일시스템
영속, 단일 컨테이너 가정).

chunk 크기 상한: PACK_CHUNK_MAX_SIZE env (기본 64MB).

tests: chunk upload 시나리오 8종 — full-flow / offset mismatch / status /
abort / wrong token / incomplete complete / filename collision / host path
저장. 전체 37 테스트 pass.

CLAUDE.md: packs-lab API 표에 chunk 5-endpoint + 사용 패턴 보강.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 02:36:20 +09:00

536 lines
19 KiB
Python

"""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"]