feat(packs-lab): POST /api/packs/admin/mint-token 라우트 + 통합 테스트

MintTokenRequest/Response 스키마 추가, mint_token 라우트 구현 (HMAC 인증 + 확장자 검증 + JTI 발급), 테스트 3건 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 01:27:43 +09:00
parent ef026e7ac6
commit dc482b32e4
3 changed files with 83 additions and 1 deletions

View File

@@ -37,3 +37,17 @@ class PackFileItem(BaseModel):
size_bytes: int size_bytes: int
sort_order: int sort_order: int
uploaded_at: datetime uploaded_at: datetime
class MintTokenRequest(BaseModel):
"""Vercel → backend: admin upload 토큰 발급 요청."""
tier: PackTier
label: str = Field(..., max_length=200)
filename: str = Field(..., max_length=255)
size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024)
class MintTokenResponse(BaseModel):
token: str
expires_at: datetime
jti: str

View File

@@ -8,6 +8,7 @@
import logging import logging
import os import os
import re import re
import time
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -15,9 +16,11 @@ from pathlib import Path
from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile
from supabase import Client, create_client from supabase import Client, create_client
from .auth import verify_request_hmac, verify_upload_token from .auth import mint_upload_token, verify_request_hmac, verify_upload_token
from .dsm_client import DSMError, create_share_link from .dsm_client import DSMError, create_share_link
from .models import ( from .models import (
MintTokenRequest,
MintTokenResponse,
PackFileItem, PackFileItem,
SignLinkRequest, SignLinkRequest,
SignLinkResponse, SignLinkResponse,
@@ -31,6 +34,7 @@ PACK_BASE_DIR = Path("/volume1/docker/webpage/media/packs")
ALLOWED_EXT = {"pdf", "zip", "mp4", "mov", "mkv", "wav", "m4a", "mp3", "png", "jpg", "jpeg", "webp", "prj"} ALLOWED_EXT = {"pdf", "zip", "mp4", "mov", "mkv", "wav", "m4a", "mp3", "png", "jpg", "jpeg", "webp", "prj"}
MAX_BYTES = 5 * 1024 * 1024 * 1024 # 5GB MAX_BYTES = 5 * 1024 * 1024 * 1024 # 5GB
SAFE_FILENAME = re.compile(r"^[\w가-힣\-\.\(\)\s]+$") SAFE_FILENAME = re.compile(r"^[\w가-힣\-\.\(\)\s]+$")
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
def _supabase() -> Client: def _supabase() -> Client:
@@ -76,6 +80,34 @@ async def sign_link(
return SignLinkResponse(url=url, expires_at=expires_at) return SignLinkResponse(url=url, expires_at=expires_at)
@router.post("/admin/mint-token", response_model=MintTokenResponse)
async def mint_token(
request: Request,
x_timestamp: str = Header(""),
x_signature: str = Header(""),
):
body = await request.body()
verify_request_hmac(body, x_timestamp, x_signature)
payload = MintTokenRequest.model_validate_json(body)
_check_filename(payload.filename)
jti = str(uuid.uuid4())
expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC
token = mint_upload_token({
"tier": payload.tier,
"label": payload.label,
"filename": payload.filename,
"size_bytes": payload.size_bytes,
"jti": jti,
"expires_at": expires_ts,
})
return MintTokenResponse(
token=token,
expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc),
jti=jti,
)
@router.post("/upload", response_model=UploadResponse) @router.post("/upload", response_model=UploadResponse)
async def upload( async def upload(
file: UploadFile = File(...), file: UploadFile = File(...),

View File

@@ -100,3 +100,39 @@ def test_list_success(mock_sb):
r = client.get("/api/packs/list", headers=_signed(body)) r = client.get("/api/packs/list", headers=_signed(body))
assert r.status_code == 200 assert r.status_code == 200
assert len(r.json()) == 1 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