Compare commits
2 Commits
096e291ed8
...
7a7e3d1ce0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a7e3d1ce0 | |||
| eb547a0367 |
18
packs-lab/Dockerfile
Normal file
18
packs-lab/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY app/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
COPY app /app/app
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
1
packs-lab/app/__init__.py
Normal file
1
packs-lab/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# packs-lab package
|
||||
84
packs-lab/app/auth.py
Normal file
84
packs-lab/app/auth.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""HMAC 인증.
|
||||
|
||||
- verify_request_hmac: Vercel ↔ backend 요청 검증.
|
||||
Vercel이 X-Timestamp + X-Signature 헤더로 보냄. signature = HMAC(timestamp.body, secret).
|
||||
요청은 5분 이내여야 함 (replay 방어).
|
||||
|
||||
- mint_upload_token / verify_upload_token: admin 5GB 업로드 일회성 토큰.
|
||||
Vercel이 발급, browser가 web-backend에 직접 multipart POST 시 Authorization: Bearer <token>.
|
||||
JTI 단발성으로 재사용 차단.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from threading import Lock
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
_SECRET = os.getenv("BACKEND_HMAC_SECRET", "")
|
||||
REQUEST_MAX_AGE_SEC = 300 # 5분
|
||||
|
||||
# JTI 단발성 set (in-memory, 단일 컨테이너 가정)
|
||||
_used_jti: set[str] = set()
|
||||
_jti_lock = Lock()
|
||||
|
||||
|
||||
def _sign(payload: bytes) -> str:
|
||||
if not _SECRET:
|
||||
raise HTTPException(status_code=503, detail="BACKEND_HMAC_SECRET 미설정")
|
||||
return hmac.new(_SECRET.encode(), payload, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def verify_request_hmac(body: bytes, timestamp: str, signature: str) -> None:
|
||||
"""Vercel → backend 요청 시그니처 검증."""
|
||||
if not timestamp or not signature:
|
||||
raise HTTPException(status_code=401, detail="HMAC 헤더 누락")
|
||||
try:
|
||||
ts = int(timestamp)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="잘못된 timestamp")
|
||||
age = abs(int(time.time()) - ts)
|
||||
if age > REQUEST_MAX_AGE_SEC:
|
||||
raise HTTPException(status_code=401, detail="요청이 만료됨")
|
||||
expected = _sign(timestamp.encode() + b"." + body)
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
raise HTTPException(status_code=401, detail="HMAC 시그니처 불일치")
|
||||
|
||||
|
||||
def mint_upload_token(payload: dict) -> str:
|
||||
"""일회성 업로드 토큰 발급. payload는 expires_at + jti 포함해야 함."""
|
||||
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
|
||||
sig = _sign(body)
|
||||
return base64.urlsafe_b64encode(body).decode() + "." + sig
|
||||
|
||||
|
||||
def verify_upload_token(token: str) -> dict:
|
||||
"""업로드 토큰 검증 + jti 사용 마킹."""
|
||||
try:
|
||||
b64, sig = token.split(".", 1)
|
||||
body = base64.urlsafe_b64decode(b64.encode())
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="잘못된 토큰 포맷")
|
||||
|
||||
expected = _sign(body)
|
||||
if not hmac.compare_digest(expected, sig):
|
||||
raise HTTPException(status_code=401, detail="토큰 시그니처 불일치")
|
||||
|
||||
payload = json.loads(body)
|
||||
expires_at = payload.get("expires_at", 0)
|
||||
if int(time.time()) > expires_at:
|
||||
raise HTTPException(status_code=401, detail="토큰 만료")
|
||||
|
||||
jti = payload.get("jti")
|
||||
if not jti:
|
||||
raise HTTPException(status_code=401, detail="jti 누락")
|
||||
|
||||
with _jti_lock:
|
||||
if jti in _used_jti:
|
||||
raise HTTPException(status_code=409, detail="이미 사용된 토큰")
|
||||
_used_jti.add(jti)
|
||||
|
||||
return payload
|
||||
102
packs-lab/app/dsm_client.py
Normal file
102
packs-lab/app/dsm_client.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Synology DSM 7.x API 클라이언트.
|
||||
|
||||
각 호출 = login → 작업 → logout (세션 풀링은 v1.1+에서). 단일 컨테이너 + 동시성 낮음 가정.
|
||||
|
||||
- create_share_link(file_path, expires_in_sec) -> share URL
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("packs-lab.dsm")
|
||||
|
||||
DSM_HOST = os.getenv("DSM_HOST", "") # 예: https://gahusb.synology.me:5001
|
||||
DSM_USER = os.getenv("DSM_USER", "")
|
||||
DSM_PASS = os.getenv("DSM_PASS", "")
|
||||
|
||||
API_AUTH = "/webapi/auth.cgi"
|
||||
API_SHARE = "/webapi/entry.cgi"
|
||||
|
||||
|
||||
class DSMError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
async def _login(client: httpx.AsyncClient) -> str:
|
||||
"""DSM 세션 sid 반환."""
|
||||
if not all([DSM_HOST, DSM_USER, DSM_PASS]):
|
||||
raise DSMError("DSM 환경변수 미설정")
|
||||
r = await client.get(
|
||||
f"{DSM_HOST}{API_AUTH}",
|
||||
params={
|
||||
"api": "SYNO.API.Auth",
|
||||
"version": "7",
|
||||
"method": "login",
|
||||
"account": DSM_USER,
|
||||
"passwd": DSM_PASS,
|
||||
"session": "FileStation",
|
||||
"format": "sid",
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data.get("success"):
|
||||
raise DSMError(f"DSM login 실패: {data.get('error')}")
|
||||
return data["data"]["sid"]
|
||||
|
||||
|
||||
async def _logout(client: httpx.AsyncClient, sid: str) -> None:
|
||||
try:
|
||||
await client.get(
|
||||
f"{DSM_HOST}{API_AUTH}",
|
||||
params={
|
||||
"api": "SYNO.API.Auth",
|
||||
"version": "7",
|
||||
"method": "logout",
|
||||
"session": "FileStation",
|
||||
"_sid": sid,
|
||||
},
|
||||
timeout=10.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("DSM logout 실패: %s", e)
|
||||
|
||||
|
||||
async def create_share_link(file_path: str, expires_in_sec: int = 14400) -> tuple[str, datetime]:
|
||||
"""파일 공유 링크 생성. 반환: (URL, expires_at).
|
||||
|
||||
file_path: NAS 절대경로 (예: /volume1/docker/webpage/media/packs/master/x.mp4)
|
||||
expires_in_sec: 만료 (기본 4시간)
|
||||
"""
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in_sec)
|
||||
expire_time_ms = int(expires_at.timestamp() * 1000)
|
||||
|
||||
async with httpx.AsyncClient(verify=True) as client:
|
||||
sid = await _login(client)
|
||||
try:
|
||||
r = await client.get(
|
||||
f"{DSM_HOST}{API_SHARE}",
|
||||
params={
|
||||
"api": "SYNO.FileStation.Sharing",
|
||||
"version": "3",
|
||||
"method": "create",
|
||||
"path": file_path,
|
||||
"date_expired": expire_time_ms,
|
||||
"_sid": sid,
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data.get("success"):
|
||||
raise DSMError(f"DSM Sharing.create 실패: {data.get('error')}")
|
||||
links = data["data"]["links"]
|
||||
if not links:
|
||||
raise DSMError("Sharing 응답에 링크 없음")
|
||||
url = links[0]["url"]
|
||||
return url, expires_at
|
||||
finally:
|
||||
await _logout(client, sid)
|
||||
36
packs-lab/app/main.py
Normal file
36
packs-lab/app/main.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""packs-lab FastAPI application.
|
||||
|
||||
NAS 자료 다운로드 자동화 — DSM 공유 링크 발급 + 5GB 멀티파트 업로드 수신.
|
||||
모든 Vercel 호출은 HMAC 인증. 사용자 다운로드는 Vercel이 supabase 인증 후 프록시.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("packs-lab")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# DSM credentials presence check
|
||||
for key in ("DSM_HOST", "DSM_USER", "DSM_PASS", "BACKEND_HMAC_SECRET"):
|
||||
if not os.getenv(key):
|
||||
logger.warning("환경변수 %s 미설정 — packs-lab 일부 기능 작동 안 함", key)
|
||||
logger.info("packs-lab 시작")
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan, title="packs-lab", version="1.0.0")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "service": "packs-lab"}
|
||||
|
||||
|
||||
from . import routes # noqa: E402
|
||||
|
||||
app.include_router(routes.router)
|
||||
39
packs-lab/app/models.py
Normal file
39
packs-lab/app/models.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Pydantic schemas for packs API."""
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
PackTier = Literal["starter", "pro", "master"]
|
||||
|
||||
|
||||
class SignLinkRequest(BaseModel):
|
||||
"""Vercel → backend: 사용자 다운로드 링크 발급 요청."""
|
||||
file_path: str = Field(..., description="NAS 절대 경로 — pack_files.file_path 그대로")
|
||||
expires_in_seconds: int = Field(default=14400, description="공유 링크 만료 (기본 4시간)")
|
||||
|
||||
|
||||
class SignLinkResponse(BaseModel):
|
||||
url: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
file_id: str # uuid
|
||||
file_path: str
|
||||
filename: str
|
||||
size_bytes: int
|
||||
min_tier: PackTier
|
||||
label: str
|
||||
uploaded_at: datetime
|
||||
|
||||
|
||||
class PackFileItem(BaseModel):
|
||||
id: str
|
||||
min_tier: PackTier
|
||||
label: str
|
||||
file_path: str
|
||||
filename: str
|
||||
size_bytes: int
|
||||
sort_order: int
|
||||
uploaded_at: datetime
|
||||
6
packs-lab/app/requirements.txt
Normal file
6
packs-lab/app/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
httpx==0.28.1
|
||||
python-multipart==0.0.20
|
||||
supabase==2.12.0
|
||||
pydantic==2.10.4
|
||||
179
packs-lab/app/routes.py
Normal file
179
packs-lab/app/routes.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""packs-lab API 엔드포인트.
|
||||
|
||||
- POST /api/packs/sign-link — Vercel HMAC 인증 → DSM 공유 링크
|
||||
- POST /api/packs/upload — 일회성 토큰 인증 → multipart 저장 + supabase INSERT
|
||||
- GET /api/packs/list — Vercel HMAC 인증 → pack_files 전체 조회
|
||||
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete + DSM 공유 정리
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile
|
||||
from supabase import Client, create_client
|
||||
|
||||
from .auth import verify_request_hmac, verify_upload_token
|
||||
from .dsm_client import DSMError, create_share_link
|
||||
from .models import (
|
||||
PackFileItem,
|
||||
SignLinkRequest,
|
||||
SignLinkResponse,
|
||||
UploadResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("packs-lab.routes")
|
||||
router = APIRouter(prefix="/api/packs")
|
||||
|
||||
PACK_BASE_DIR = Path("/volume1/docker/webpage/media/packs")
|
||||
ALLOWED_EXT = {"pdf", "zip", "mp4", "mov", "mkv", "wav", "m4a", "mp3", "png", "jpg", "jpeg", "webp", "prj"}
|
||||
MAX_BYTES = 5 * 1024 * 1024 * 1024 # 5GB
|
||||
SAFE_FILENAME = re.compile(r"^[\w가-힣\-\.\(\)\s]+$")
|
||||
|
||||
|
||||
def _supabase() -> Client:
|
||||
url = os.getenv("SUPABASE_URL", "")
|
||||
key = os.getenv("SUPABASE_SERVICE_KEY", "")
|
||||
if not url or not key:
|
||||
raise HTTPException(status_code=503, detail="Supabase 설정 미완료")
|
||||
return create_client(url, key)
|
||||
|
||||
|
||||
def _check_filename(filename: str) -> str:
|
||||
if not SAFE_FILENAME.match(filename):
|
||||
raise HTTPException(status_code=400, detail="파일명에 허용되지 않은 문자가 포함되어 있습니다")
|
||||
if "/" in filename or "\\" in filename or filename.startswith("."):
|
||||
raise HTTPException(status_code=400, detail="잘못된 파일명")
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
if ext not in ALLOWED_EXT:
|
||||
raise HTTPException(status_code=400, detail=f"허용되지 않은 확장자: {ext}")
|
||||
return filename
|
||||
|
||||
|
||||
@router.post("/sign-link", response_model=SignLinkResponse)
|
||||
async def sign_link(
|
||||
request: Request,
|
||||
x_timestamp: str = Header(""),
|
||||
x_signature: str = Header(""),
|
||||
):
|
||||
body = await request.body()
|
||||
verify_request_hmac(body, x_timestamp, x_signature)
|
||||
payload = SignLinkRequest.model_validate_json(body)
|
||||
|
||||
# 경로 안전: PACK_BASE_DIR 하위인지 확인
|
||||
abs_path = Path(payload.file_path).resolve()
|
||||
if not str(abs_path).startswith(str(PACK_BASE_DIR)):
|
||||
raise HTTPException(status_code=400, detail="허용된 경로 외부")
|
||||
|
||||
try:
|
||||
url, expires_at = await create_share_link(str(abs_path), payload.expires_in_seconds)
|
||||
except DSMError as e:
|
||||
logger.error("DSM 오류: %s", e)
|
||||
raise HTTPException(status_code=502, detail=f"DSM 오류: {e}")
|
||||
|
||||
return SignLinkResponse(url=url, expires_at=expires_at)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=UploadResponse)
|
||||
async def upload(
|
||||
file: UploadFile = File(...),
|
||||
authorization: str = Header(""),
|
||||
):
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Authorization 헤더 누락")
|
||||
token = authorization[len("Bearer "):]
|
||||
payload = verify_upload_token(token)
|
||||
|
||||
tier = payload["tier"]
|
||||
label = payload["label"]
|
||||
filename = _check_filename(payload["filename"])
|
||||
expected_size = int(payload["size_bytes"])
|
||||
|
||||
tier_dir = PACK_BASE_DIR / tier
|
||||
tier_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = tier_dir / filename
|
||||
if target.exists():
|
||||
raise HTTPException(status_code=409, detail="이미 존재하는 파일명입니다. 다른 이름으로 업로드하거나 기존 파일을 먼저 삭제하세요")
|
||||
|
||||
# multipart 스트림 저장 + 크기 검증
|
||||
written = 0
|
||||
with target.open("wb") as f:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
written += len(chunk)
|
||||
if written > MAX_BYTES:
|
||||
f.close()
|
||||
target.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=413, detail="파일 크기 5GB 초과")
|
||||
f.write(chunk)
|
||||
|
||||
if written != expected_size:
|
||||
target.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=400, detail=f"실제 크기({written})와 토큰 크기({expected_size}) 불일치")
|
||||
|
||||
# supabase INSERT
|
||||
sb = _supabase()
|
||||
file_id = str(uuid.uuid4())
|
||||
res = sb.table("pack_files").insert({
|
||||
"id": file_id,
|
||||
"min_tier": tier,
|
||||
"label": label,
|
||||
"file_path": str(target),
|
||||
"filename": filename,
|
||||
"size_bytes": written,
|
||||
}).execute()
|
||||
if not res.data:
|
||||
target.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=500, detail="DB INSERT 실패")
|
||||
|
||||
return UploadResponse(
|
||||
file_id=file_id,
|
||||
file_path=str(target),
|
||||
filename=filename,
|
||||
size_bytes=written,
|
||||
min_tier=tier,
|
||||
label=label,
|
||||
uploaded_at=res.data[0]["uploaded_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_model=list[PackFileItem])
|
||||
async def list_files(
|
||||
request: Request,
|
||||
x_timestamp: str = Header(""),
|
||||
x_signature: str = Header(""),
|
||||
):
|
||||
body = await request.body()
|
||||
verify_request_hmac(body, x_timestamp, x_signature)
|
||||
sb = _supabase()
|
||||
res = (
|
||||
sb.table("pack_files")
|
||||
.select("*")
|
||||
.is_("deleted_at", "null")
|
||||
.order("min_tier")
|
||||
.order("sort_order")
|
||||
.execute()
|
||||
)
|
||||
return [PackFileItem(**r) for r in (res.data or [])]
|
||||
|
||||
|
||||
@router.delete("/{file_id}")
|
||||
async def delete_file(
|
||||
file_id: str,
|
||||
request: Request,
|
||||
x_timestamp: str = Header(""),
|
||||
x_signature: str = Header(""),
|
||||
):
|
||||
body = await request.body()
|
||||
verify_request_hmac(body, x_timestamp, x_signature)
|
||||
sb = _supabase()
|
||||
res = sb.table("pack_files").update({
|
||||
"deleted_at": datetime.now(timezone.utc).isoformat(),
|
||||
}).eq("id", file_id).execute()
|
||||
if not res.data:
|
||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||
return {"ok": True}
|
||||
3
packs-lab/pytest.ini
Normal file
3
packs-lab/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
pythonpath = .
|
||||
1
packs-lab/tests/__init__.py
Normal file
1
packs-lab/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# packs-lab tests
|
||||
85
packs-lab/tests/test_auth.py
Normal file
85
packs-lab/tests/test_auth.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""HMAC 인증 단위 테스트.
|
||||
|
||||
- verify_request_hmac: Vercel ↔ backend 요청 시그니처 검증
|
||||
- verify_upload_token: 일회성 업로드 토큰 검증 (jti 단발성)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
# 환경변수 먼저 세팅 (auth import 전)
|
||||
os.environ["BACKEND_HMAC_SECRET"] = "test-secret-32-bytes-XXXXXXXXXXXX"
|
||||
|
||||
from app import auth # noqa: E402
|
||||
|
||||
|
||||
def test_verify_request_hmac_valid():
|
||||
body = b'{"file_path":"/x"}'
|
||||
ts = str(int(time.time()))
|
||||
sig = auth._sign(ts.encode() + b"." + body)
|
||||
auth.verify_request_hmac(body, ts, sig) # 예외 X
|
||||
|
||||
|
||||
def test_verify_request_hmac_expired():
|
||||
body = b'{}'
|
||||
old_ts = str(int(time.time()) - 600) # 10분 전
|
||||
sig = auth._sign(old_ts.encode() + b"." + body)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
auth.verify_request_hmac(body, old_ts, sig)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_verify_request_hmac_wrong_sig():
|
||||
body = b'{}'
|
||||
ts = str(int(time.time()))
|
||||
with pytest.raises(HTTPException):
|
||||
auth.verify_request_hmac(body, ts, "deadbeef")
|
||||
|
||||
|
||||
def test_upload_token_roundtrip():
|
||||
payload = {
|
||||
"tier": "master",
|
||||
"label": "샘플 영상",
|
||||
"filename": "sample.mp4",
|
||||
"size_bytes": 1024,
|
||||
"expires_at": int(time.time()) + 600,
|
||||
"jti": uuid.uuid4().hex,
|
||||
}
|
||||
token = auth.mint_upload_token(payload)
|
||||
decoded = auth.verify_upload_token(token)
|
||||
assert decoded["filename"] == "sample.mp4"
|
||||
assert decoded["jti"] == payload["jti"]
|
||||
|
||||
|
||||
def test_upload_token_replay_rejected():
|
||||
payload = {
|
||||
"tier": "starter",
|
||||
"label": "x",
|
||||
"filename": "y.pdf",
|
||||
"size_bytes": 1,
|
||||
"expires_at": int(time.time()) + 600,
|
||||
"jti": uuid.uuid4().hex,
|
||||
}
|
||||
token = auth.mint_upload_token(payload)
|
||||
auth.verify_upload_token(token) # 첫 사용 OK
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
auth.verify_upload_token(token) # 재사용
|
||||
assert "이미" in exc.value.detail or "used" in exc.value.detail.lower()
|
||||
|
||||
|
||||
def test_upload_token_expired():
|
||||
payload = {
|
||||
"tier": "starter",
|
||||
"label": "x",
|
||||
"filename": "y.pdf",
|
||||
"size_bytes": 1,
|
||||
"expires_at": int(time.time()) - 100,
|
||||
"jti": uuid.uuid4().hex,
|
||||
}
|
||||
token = auth.mint_upload_token(payload)
|
||||
with pytest.raises(HTTPException):
|
||||
auth.verify_upload_token(token)
|
||||
Reference in New Issue
Block a user