9 task TDD 분할: - Task 1: tests/conftest.py — autouse HMAC secret - Task 2: admin mint-token (스키마 + 라우트 + 통합 테스트 3건) - Task 3: 기존 4 라우트 회귀 테스트 (sign-link/upload/list/delete, 8건) - Task 4: test_dsm_client.py — DSM 7.x mock (4건) - Task 5: routes 모듈 docstring 정리 - Task 6: Supabase pack_files DDL - Task 7: 인프라 통합 (compose 18950 + nginx 5GB streaming + env 7개) - Task 8: CLAUDE.md 5곳 + workspace 1줄 - Task 9: 회귀 검증 + NAS 디렉토리 가이드 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
977 lines
32 KiB
Markdown
977 lines
32 KiB
Markdown
# packs-lab 인프라 통합 + admin mint-token Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** packs-lab을 운영 가능 상태로 만든다 — admin upload 토큰 발급 endpoint + Supabase 스키마 + docker-compose/nginx/env 통합 + 통합 테스트 + 문서 갱신.
|
||
|
||
**Architecture:** 기존 코드(HMAC + DSM client + 4 라우트)는 그대로 유지하고, 신규 라우트 1개(`POST /api/packs/admin/mint-token`)를 routes.py에 추가한다. Supabase `pack_files` DDL 파일과 인프라(docker-compose 18950, nginx 5GB streaming, .env.example 6+1 환경변수)를 신설하고, 통합 테스트(routes + dsm_client mock)와 CLAUDE.md 5+1곳을 갱신한다.
|
||
|
||
**Tech Stack:** Python 3.12 / FastAPI / pytest + unittest.mock / Supabase(PostgreSQL) / Synology DSM 7.x API / nginx / Docker Compose
|
||
|
||
**스펙 참조:** `docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md`
|
||
|
||
**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-backend` (기존 web-backend repo)
|
||
|
||
---
|
||
|
||
## Task 1: 테스트 인프라 — `tests/conftest.py`
|
||
|
||
기존 `tests/test_auth.py`는 `BACKEND_HMAC_SECRET=secret` 같은 fixture가 없어 환경변수 의존. 모든 테스트가 동일한 secret으로 동작하도록 autouse fixture를 conftest에 정리.
|
||
|
||
**Files:**
|
||
- Create: `packs-lab/tests/conftest.py`
|
||
|
||
- [ ] **Step 1: conftest.py 생성**
|
||
|
||
`packs-lab/tests/conftest.py`:
|
||
|
||
```python
|
||
"""packs-lab 테스트 공통 fixture."""
|
||
import pytest
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _hmac_secret(monkeypatch):
|
||
"""모든 테스트에서 동일한 HMAC secret 사용. auth._SECRET 모듈 캐시까지 갱신."""
|
||
monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
|
||
# auth.py 모듈은 import 시점에 _SECRET을 캐시하므로 monkeypatch로 함께 갱신
|
||
from app import auth
|
||
monkeypatch.setattr(auth, "_SECRET", "test-secret-do-not-use-in-prod")
|
||
```
|
||
|
||
- [ ] **Step 2: 기존 test_auth.py 회귀 검증**
|
||
|
||
```bash
|
||
cd C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab
|
||
python -m pytest tests/test_auth.py -v
|
||
```
|
||
|
||
Expected: 기존 테스트 모두 PASS (conftest 영향 없거나 PASS 그대로 유지). 만약 secret 인코딩 차이로 실패 시 해당 테스트의 secret 사용 부분을 conftest 값과 일치시킨다.
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
git add packs-lab/tests/conftest.py
|
||
git commit -m "test(packs-lab): conftest로 HMAC secret 통일"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: admin mint-token 라우트 (스키마 + 구현 + 테스트)
|
||
|
||
`POST /api/packs/admin/mint-token` 신규. Pydantic 스키마 추가 + 라우트 구현 + 통합 테스트.
|
||
|
||
**Files:**
|
||
- Modify: `packs-lab/app/models.py` (스키마 2개 추가)
|
||
- Modify: `packs-lab/app/routes.py` (import 보강 + 라우트 추가)
|
||
- Create: `packs-lab/tests/test_routes.py` (mint-token 관련 테스트만 우선)
|
||
|
||
- [ ] **Step 1: failing 테스트 작성**
|
||
|
||
`packs-lab/tests/test_routes.py`:
|
||
|
||
```python
|
||
"""packs-lab 라우트 통합 테스트.
|
||
|
||
DSM·Supabase는 mock. HMAC 검증·토큰 발급·검증은 실제 코드 사용.
|
||
"""
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import time
|
||
from unittest.mock import patch, MagicMock
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.main import app
|
||
|
||
SECRET = "test-secret-do-not-use-in-prod"
|
||
|
||
|
||
def _hmac_headers(body_bytes: bytes) -> dict:
|
||
"""body에 대한 X-Timestamp + X-Signature 헤더 생성."""
|
||
ts = str(int(time.time()))
|
||
sig = hmac.new(SECRET.encode(), ts.encode() + b"." + body_bytes, hashlib.sha256).hexdigest()
|
||
return {"X-Timestamp": ts, "X-Signature": sig}
|
||
|
||
|
||
def test_mint_token_hmac_required():
|
||
"""HMAC 헤더 누락 → 401."""
|
||
client = TestClient(app)
|
||
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}
|
||
body_bytes = json.dumps(body).encode()
|
||
headers = _hmac_headers(body_bytes)
|
||
headers["Content-Type"] = "application/json"
|
||
|
||
client = TestClient(app)
|
||
resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
|
||
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}
|
||
body_bytes = json.dumps(body).encode()
|
||
headers = _hmac_headers(body_bytes)
|
||
headers["Content-Type"] = "application/json"
|
||
|
||
client = TestClient(app)
|
||
resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
|
||
assert resp.status_code == 400
|
||
```
|
||
|
||
- [ ] **Step 2: 실패 확인**
|
||
|
||
```bash
|
||
cd packs-lab
|
||
python -m pytest tests/test_routes.py -v
|
||
```
|
||
|
||
Expected: 모든 테스트 FAIL — `/api/packs/admin/mint-token` 라우트 없음 (404 또는 405).
|
||
|
||
- [ ] **Step 3: models.py에 스키마 추가**
|
||
|
||
`packs-lab/app/models.py` 끝부분에 추가:
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
- [ ] **Step 4: routes.py에 mint-token 라우트 추가**
|
||
|
||
`packs-lab/app/routes.py` 상단 import 블록에 다음을 추가:
|
||
|
||
```python
|
||
import time
|
||
from datetime import timezone
|
||
```
|
||
|
||
(이미 `import uuid`, `from datetime import datetime`은 있음)
|
||
|
||
`from .auth import` 라인을 다음과 같이 확장:
|
||
|
||
```python
|
||
from .auth import mint_upload_token, verify_request_hmac, verify_upload_token
|
||
```
|
||
|
||
`from .models import` 라인을 다음과 같이 확장:
|
||
|
||
```python
|
||
from .models import (
|
||
MintTokenRequest,
|
||
MintTokenResponse,
|
||
PackFileItem,
|
||
SignLinkRequest,
|
||
SignLinkResponse,
|
||
UploadResponse,
|
||
)
|
||
```
|
||
|
||
상수 추가 (`MAX_BYTES` 다음 줄에):
|
||
|
||
```python
|
||
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
|
||
```
|
||
|
||
라우트 추가 (`sign_link` 함수 다음, `upload` 함수 앞):
|
||
|
||
```python
|
||
@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,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 5: 테스트 통과 확인**
|
||
|
||
```bash
|
||
cd packs-lab
|
||
python -m pytest tests/test_routes.py -v
|
||
```
|
||
|
||
Expected: 3 passed.
|
||
|
||
- [ ] **Step 6: 커밋**
|
||
|
||
```bash
|
||
git add packs-lab/app/models.py packs-lab/app/routes.py packs-lab/tests/test_routes.py
|
||
git commit -m "feat(packs-lab): POST /api/packs/admin/mint-token 라우트 + 통합 테스트"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 기존 4 라우트 통합 테스트 (sign-link / upload / list / delete)
|
||
|
||
기존 라우트는 변경 없음. 테스트만 추가해 회귀 안전망 확보.
|
||
|
||
**Files:**
|
||
- Modify: `packs-lab/tests/test_routes.py` (테스트 8개 추가)
|
||
|
||
- [ ] **Step 1: sign-link 테스트 추가**
|
||
|
||
`tests/test_routes.py` 끝에 추가:
|
||
|
||
```python
|
||
def test_sign_link_hmac_required():
|
||
"""HMAC 헤더 없으면 401."""
|
||
client = TestClient(app)
|
||
body = {"file_path": "/volume1/docker/webpage/media/packs/pro/x.zip"}
|
||
resp = client.post("/api/packs/sign-link", json=body)
|
||
assert resp.status_code == 401
|
||
|
||
|
||
def test_sign_link_outside_base_dir():
|
||
"""PACK_BASE_DIR 외부 경로 → 400."""
|
||
body = {"file_path": "/etc/passwd"}
|
||
body_bytes = json.dumps(body).encode()
|
||
headers = _hmac_headers(body_bytes)
|
||
headers["Content-Type"] = "application/json"
|
||
|
||
client = TestClient(app)
|
||
resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
|
||
assert resp.status_code == 400
|
||
|
||
|
||
def test_sign_link_calls_dsm():
|
||
"""DSM client 호출되고 응답 URL 반환."""
|
||
from datetime import datetime, timezone
|
||
from unittest.mock import AsyncMock
|
||
|
||
body = {"file_path": "/volume1/docker/webpage/media/packs/pro/sample.zip"}
|
||
body_bytes = json.dumps(body).encode()
|
||
headers = _hmac_headers(body_bytes)
|
||
headers["Content-Type"] = "application/json"
|
||
|
||
fake_url = "https://gahusb.synology.me:5001/sharing/abc123"
|
||
fake_expires = datetime(2026, 5, 5, 13, 0, tzinfo=timezone.utc)
|
||
|
||
with patch("app.routes.create_share_link", new=AsyncMock(return_value=(fake_url, fake_expires))) as mock:
|
||
client = TestClient(app)
|
||
resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
|
||
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["url"] == fake_url
|
||
mock.assert_awaited_once()
|
||
```
|
||
|
||
- [ ] **Step 2: upload 테스트 추가**
|
||
|
||
```python
|
||
def _make_upload_token(tier="pro", label="샘플", filename="test.zip", size_bytes=1024, jti=None, ttl=1800):
|
||
"""테스트용 upload token 생성. mint_token endpoint 거치지 않고 직접."""
|
||
import uuid
|
||
from app.auth import mint_upload_token
|
||
return mint_upload_token({
|
||
"tier": tier,
|
||
"label": label,
|
||
"filename": filename,
|
||
"size_bytes": size_bytes,
|
||
"jti": jti or str(uuid.uuid4()),
|
||
"expires_at": int(time.time()) + ttl,
|
||
})
|
||
|
||
|
||
def test_upload_token_required():
|
||
"""Authorization Bearer 누락 → 401."""
|
||
client = TestClient(app)
|
||
resp = client.post("/api/packs/upload", files={"file": ("x.zip", b"hello")})
|
||
assert resp.status_code == 401
|
||
|
||
|
||
def test_upload_size_mismatch(tmp_path, monkeypatch):
|
||
"""토큰 size_bytes ≠ 실제 → 400 + 파일 정리됨."""
|
||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||
token = _make_upload_token(size_bytes=999) # 실제 5바이트지만 토큰엔 999
|
||
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/packs/upload",
|
||
files={"file": ("test.zip", b"hello")},
|
||
headers={"Authorization": f"Bearer {token}"},
|
||
)
|
||
assert resp.status_code == 400
|
||
assert "크기" in resp.json()["detail"]
|
||
|
||
|
||
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"}]
|
||
)
|
||
|
||
token = _make_upload_token(filename="replay.zip", size_bytes=5, jti="replay-jti-1")
|
||
|
||
with patch("app.routes._supabase", return_value=fake_supabase):
|
||
client = TestClient(app)
|
||
|
||
# 1차: 성공
|
||
resp1 = client.post(
|
||
"/api/packs/upload",
|
||
files={"file": ("replay.zip", b"hello")},
|
||
headers={"Authorization": f"Bearer {token}"},
|
||
)
|
||
assert resp1.status_code == 200
|
||
|
||
# 2차: 동일 토큰 재사용 — 두 번째 파일은 다른 이름으로 보내 파일명 충돌 회피
|
||
resp2 = client.post(
|
||
"/api/packs/upload",
|
||
files={"file": ("replay.zip", b"world")},
|
||
headers={"Authorization": f"Bearer {token}"},
|
||
)
|
||
assert resp2.status_code == 409
|
||
```
|
||
|
||
- [ ] **Step 3: list / delete 테스트 추가**
|
||
|
||
```python
|
||
def test_list_returns_active_only():
|
||
"""mock supabase가 deleted_at IS 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 = _hmac_headers(body_bytes)
|
||
|
||
with patch("app.routes._supabase", return_value=fake_supabase):
|
||
client = TestClient(app)
|
||
resp = client.get("/api/packs/list", headers=headers)
|
||
|
||
assert resp.status_code == 200
|
||
items = resp.json()
|
||
assert len(items) == 1
|
||
assert items[0]["filename"] == "a.zip"
|
||
fake_supabase.table.return_value.select.return_value.is_.assert_called_with("deleted_at", "null")
|
||
|
||
|
||
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 = _hmac_headers(body_bytes)
|
||
|
||
with patch("app.routes._supabase", return_value=fake_supabase):
|
||
client = TestClient(app)
|
||
resp = 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
|
||
# ISO 8601 timestamp 형식 검증 (예: 2026-05-05T12:00:00+00:00)
|
||
assert "T" in update_kwargs["deleted_at"]
|
||
```
|
||
|
||
- [ ] **Step 4: 테스트 실행**
|
||
|
||
```bash
|
||
cd packs-lab
|
||
python -m pytest tests/test_routes.py -v
|
||
```
|
||
|
||
Expected: 11 passed (3 from Task 2 + 3 sign-link + 3 upload + 2 list/delete).
|
||
|
||
- [ ] **Step 5: 커밋**
|
||
|
||
```bash
|
||
git add packs-lab/tests/test_routes.py
|
||
git commit -m "test(packs-lab): 기존 4 라우트 통합 테스트 (sign-link, upload, list, delete)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `tests/test_dsm_client.py` — DSM client mock 테스트
|
||
|
||
**Files:**
|
||
- Create: `packs-lab/tests/test_dsm_client.py`
|
||
|
||
- [ ] **Step 1: DSM client 테스트 작성**
|
||
|
||
`packs-lab/tests/test_dsm_client.py`:
|
||
|
||
```python
|
||
"""DSM 7.x API client 테스트 — httpx mock으로 외부 호출 차단."""
|
||
import asyncio
|
||
from unittest.mock import patch, MagicMock
|
||
|
||
import pytest
|
||
import httpx
|
||
|
||
from app.dsm_client import create_share_link, DSMError, _login, _logout
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _dsm_env(monkeypatch):
|
||
monkeypatch.setenv("DSM_HOST", "https://test-nas:5001")
|
||
monkeypatch.setenv("DSM_USER", "test-user")
|
||
monkeypatch.setenv("DSM_PASS", "test-pass")
|
||
# 모듈 캐시도 갱신
|
||
from app import dsm_client
|
||
monkeypatch.setattr(dsm_client, "DSM_HOST", "https://test-nas:5001")
|
||
monkeypatch.setattr(dsm_client, "DSM_USER", "test-user")
|
||
monkeypatch.setattr(dsm_client, "DSM_PASS", "test-pass")
|
||
|
||
|
||
def _make_response(json_data, status_code=200):
|
||
"""httpx.Response mock."""
|
||
mock = MagicMock(spec=httpx.Response)
|
||
mock.json.return_value = json_data
|
||
mock.status_code = status_code
|
||
mock.raise_for_status = MagicMock()
|
||
return mock
|
||
|
||
|
||
def test_create_share_link_login_logout():
|
||
"""login → Sharing.create → logout 순서가 보장되어야 한다."""
|
||
call_order = []
|
||
|
||
async def fake_get(self, url, *, params=None, **kw):
|
||
api = (params or {}).get("api", "")
|
||
method = (params or {}).get("method", "")
|
||
call_order.append(f"{api}.{method}")
|
||
if api == "SYNO.API.Auth" and method == "login":
|
||
return _make_response({"success": True, "data": {"sid": "fake-sid"}})
|
||
if api == "SYNO.API.Auth" and method == "logout":
|
||
return _make_response({"success": True})
|
||
if api == "SYNO.FileStation.Sharing" and method == "create":
|
||
return _make_response({
|
||
"success": True,
|
||
"data": {"links": [{"url": "https://test-nas:5001/sharing/abc"}]},
|
||
})
|
||
return _make_response({"success": False, "error": "unexpected"})
|
||
|
||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||
url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=3600))
|
||
|
||
assert url == "https://test-nas:5001/sharing/abc"
|
||
assert call_order == [
|
||
"SYNO.API.Auth.login",
|
||
"SYNO.FileStation.Sharing.create",
|
||
"SYNO.API.Auth.logout",
|
||
]
|
||
|
||
|
||
def test_create_share_link_returns_url_and_expiry():
|
||
"""응답 파싱 — links[0].url 사용."""
|
||
async def fake_get(self, url, *, params=None, **kw):
|
||
method = (params or {}).get("method", "")
|
||
if method == "login":
|
||
return _make_response({"success": True, "data": {"sid": "sid"}})
|
||
if method == "create":
|
||
return _make_response({
|
||
"success": True,
|
||
"data": {"links": [{"url": "https://nas/sharing/xyz"}]},
|
||
})
|
||
return _make_response({"success": True})
|
||
|
||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||
url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=7200))
|
||
|
||
assert url == "https://nas/sharing/xyz"
|
||
assert expires_at is not None
|
||
|
||
|
||
def test_dsm_login_failure_raises():
|
||
"""login API success=False → DSMError."""
|
||
async def fake_get(self, url, *, params=None, **kw):
|
||
return _make_response({"success": False, "error": {"code": 400}})
|
||
|
||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||
with pytest.raises(DSMError, match="login 실패"):
|
||
asyncio.run(create_share_link("/volume1/test/file.zip"))
|
||
|
||
|
||
def test_dsm_share_failure_logs_out():
|
||
"""Sharing.create 실패해도 logout 호출 (try/finally)."""
|
||
call_order = []
|
||
|
||
async def fake_get(self, url, *, params=None, **kw):
|
||
method = (params or {}).get("method", "")
|
||
call_order.append(method)
|
||
if method == "login":
|
||
return _make_response({"success": True, "data": {"sid": "sid"}})
|
||
if method == "create":
|
||
return _make_response({"success": False, "error": {"code": 401}})
|
||
if method == "logout":
|
||
return _make_response({"success": True})
|
||
return _make_response({"success": False})
|
||
|
||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||
with pytest.raises(DSMError, match="Sharing.create 실패"):
|
||
asyncio.run(create_share_link("/volume1/test/file.zip"))
|
||
|
||
assert "login" in call_order
|
||
assert "logout" in call_order, "logout이 호출되지 않음 (finally 누락 의심)"
|
||
```
|
||
|
||
- [ ] **Step 2: 테스트 실행**
|
||
|
||
```bash
|
||
cd packs-lab
|
||
python -m pytest tests/test_dsm_client.py -v
|
||
```
|
||
|
||
Expected: 4 passed.
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
git add packs-lab/tests/test_dsm_client.py
|
||
git commit -m "test(packs-lab): DSM client mock 테스트 (login/share/logout 순서)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: DELETE 라우트 docstring 수정
|
||
|
||
`routes.py` 모듈 docstring의 한 줄 변경.
|
||
|
||
**Files:**
|
||
- Modify: `packs-lab/app/routes.py:1-7` (모듈 docstring)
|
||
|
||
- [ ] **Step 1: docstring 수정**
|
||
|
||
`packs-lab/app/routes.py` 첫 docstring을 다음으로 변경:
|
||
|
||
```python
|
||
"""packs-lab API 엔드포인트.
|
||
|
||
- POST /api/packs/sign-link — Vercel HMAC 인증 → DSM 공유 링크
|
||
- POST /api/packs/admin/mint-token — Vercel HMAC 인증 → 일회성 upload 토큰
|
||
- 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 공유는 자동 만료)
|
||
"""
|
||
```
|
||
|
||
(변경: `정리` → `자동 만료`, mint-token 줄 추가)
|
||
|
||
- [ ] **Step 2: 회귀 검증**
|
||
|
||
```bash
|
||
cd packs-lab
|
||
python -m pytest tests/ -v
|
||
```
|
||
|
||
Expected: 모든 테스트 그대로 통과 (15 passed).
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
git add packs-lab/app/routes.py
|
||
git commit -m "docs(packs-lab): routes 모듈 docstring 정리 (mint-token 추가, DSM 자동 만료 명시)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Supabase `pack_files` DDL
|
||
|
||
운영 적용 시 Supabase SQL editor에서 실행할 SQL 파일.
|
||
|
||
**Files:**
|
||
- Create: `packs-lab/supabase/pack_files.sql`
|
||
|
||
- [ ] **Step 1: SQL 파일 생성**
|
||
|
||
`packs-lab/supabase/pack_files.sql`:
|
||
|
||
```sql
|
||
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
|
||
-- 운영 적용: Supabase Dashboard → SQL editor에서 실행
|
||
create table if not exists public.pack_files (
|
||
id uuid primary key default gen_random_uuid(),
|
||
min_tier text not null check (min_tier in ('starter','pro','master')),
|
||
label text not null,
|
||
file_path text not null unique,
|
||
filename text not null,
|
||
size_bytes bigint not null check (size_bytes > 0),
|
||
sort_order integer not null default 0,
|
||
uploaded_at timestamptz not null default now(),
|
||
deleted_at timestamptz
|
||
);
|
||
|
||
-- list 라우트 hot path: deleted_at IS NULL + tier/order 정렬
|
||
create index if not exists pack_files_active_idx
|
||
on public.pack_files (min_tier, sort_order)
|
||
where deleted_at is null;
|
||
|
||
-- soft-deleted 통계 / cleanup 잡 대비
|
||
create index if not exists pack_files_deleted_at_idx
|
||
on public.pack_files (deleted_at)
|
||
where deleted_at is not null;
|
||
```
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
git add packs-lab/supabase/pack_files.sql
|
||
git commit -m "feat(packs-lab): Supabase pack_files DDL + 활성/삭제 인덱스"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: 인프라 통합 — docker-compose / nginx / .env.example
|
||
|
||
**Files:**
|
||
- Modify: `docker-compose.yml` (packs-lab 서비스 추가)
|
||
- Modify: `nginx/default.conf` (`/api/packs/` 라우팅)
|
||
- Modify: `.env.example` (6+1 환경변수)
|
||
|
||
- [ ] **Step 1: docker-compose.yml — packs-lab 서비스 추가**
|
||
|
||
`docker-compose.yml`에서 다른 lab 서비스(예: `realestate-lab`) 정의 다음에 추가:
|
||
|
||
```yaml
|
||
packs-lab:
|
||
build:
|
||
context: ./packs-lab
|
||
dockerfile: Dockerfile
|
||
container_name: packs-lab
|
||
restart: unless-stopped
|
||
ports:
|
||
- "18950:8000"
|
||
environment:
|
||
TZ: Asia/Seoul
|
||
DSM_HOST: ${DSM_HOST}
|
||
DSM_USER: ${DSM_USER}
|
||
DSM_PASS: ${DSM_PASS}
|
||
BACKEND_HMAC_SECRET: ${BACKEND_HMAC_SECRET}
|
||
SUPABASE_URL: ${SUPABASE_URL}
|
||
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}
|
||
UPLOAD_TOKEN_TTL_SEC: ${UPLOAD_TOKEN_TTL_SEC:-1800}
|
||
volumes:
|
||
- ${PACK_DATA_PATH:-./data/packs}:/volume1/docker/webpage/media/packs
|
||
```
|
||
|
||
- [ ] **Step 2: nginx/default.conf — /api/packs/ 라우팅**
|
||
|
||
기존 `location /api/agent-office/ { ... }` 다음(또는 다른 `/api/...` 라우트들 근처)에 추가:
|
||
|
||
```nginx
|
||
location /api/packs/ {
|
||
proxy_pass http://packs-lab:8000;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
|
||
# 5GB 멀티파트 업로드 대응
|
||
client_max_body_size 5G;
|
||
proxy_request_buffering off;
|
||
proxy_read_timeout 1800s;
|
||
proxy_send_timeout 1800s;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: .env.example — 6+1 환경변수 추가**
|
||
|
||
`.env.example` 끝에 추가:
|
||
|
||
```bash
|
||
|
||
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
|
||
# Synology DSM 7.x 인증 (공유 링크 발급용)
|
||
DSM_HOST=https://gahusb.synology.me:5001
|
||
DSM_USER=
|
||
DSM_PASS=
|
||
|
||
# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
|
||
BACKEND_HMAC_SECRET=
|
||
|
||
# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
|
||
SUPABASE_URL=https://<project>.supabase.co
|
||
SUPABASE_SERVICE_KEY=
|
||
|
||
# admin upload 토큰 TTL (초). default 1800 = 30분
|
||
UPLOAD_TOKEN_TTL_SEC=1800
|
||
|
||
# 로컬 개발: ./data/packs / NAS 운영: /volume1/docker/webpage/media/packs
|
||
PACK_DATA_PATH=./data/packs
|
||
```
|
||
|
||
- [ ] **Step 4: docker compose config 검증**
|
||
|
||
```bash
|
||
cd C:\Users\jaeoh\Desktop\workspace\web-backend
|
||
docker compose config 2>&1 | grep -A 10 "packs-lab:"
|
||
```
|
||
|
||
Expected: packs-lab 서비스 정의가 정상 출력 (port mapping, environment 변수, volumes 모두 보임). 환경변수가 비어있어도 docker compose config는 통과.
|
||
|
||
> ⚠️ Docker가 로컬에 설치되어 있어야 검증 가능. 실제 실행은 NAS에서. 로컬 docker가 없으면 step skip하고 nginx config 문법만 별도 검증.
|
||
|
||
- [ ] **Step 5: 커밋**
|
||
|
||
```bash
|
||
git add docker-compose.yml nginx/default.conf .env.example
|
||
git commit -m "chore(infra): packs-lab 서비스 통합 (compose 18950 + nginx 5GB streaming + env 7개)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: NAS 디렉토리 준비 가이드 + 문서 갱신
|
||
|
||
**Files:**
|
||
- Modify: `web-backend/CLAUDE.md` (5곳 갱신)
|
||
- Modify: `workspace/CLAUDE.md` (1줄 추가)
|
||
|
||
- [ ] **Step 1: web-backend/CLAUDE.md — 1.프로젝트 개요**
|
||
|
||
찾을 위치 (1.프로젝트 개요 섹션):
|
||
|
||
```
|
||
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
|
||
```
|
||
|
||
다음으로 수정:
|
||
|
||
```
|
||
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||
```
|
||
|
||
같은 섹션의 인프라 줄도:
|
||
|
||
```
|
||
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||
```
|
||
|
||
- [ ] **Step 2: web-backend/CLAUDE.md — 4.Docker 서비스 표**
|
||
|
||
표 마지막에 신규 행 추가 (deployer 행 직전 또는 personal 행 다음 — 알파벳 순):
|
||
|
||
```
|
||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||
```
|
||
|
||
- [ ] **Step 3: web-backend/CLAUDE.md — 5.Nginx 라우팅 표**
|
||
|
||
표 적절한 위치에 신규 행 추가:
|
||
|
||
```
|
||
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
|
||
```
|
||
|
||
- [ ] **Step 4: web-backend/CLAUDE.md — 8.로컬 개발 표**
|
||
|
||
표 끝에 신규 행 추가:
|
||
|
||
```
|
||
| Packs Lab | http://localhost:18950 |
|
||
```
|
||
|
||
- [ ] **Step 5: web-backend/CLAUDE.md — 9.서비스별 packs-lab 신규 섹션**
|
||
|
||
`### deployer (deployer/)` 섹션 직전에 추가 (또는 personal 다음):
|
||
|
||
```
|
||
### packs-lab (packs-lab/)
|
||
- NAS 자료 다운로드 자동화 — Synology DSM 공유링크 발급 + 5GB 멀티파트 업로드 수신
|
||
- Vercel SaaS와 HMAC 인증으로 통신, 사용자 인증은 Vercel이 Supabase로 처리 (본 서비스는 외부 인증 없음)
|
||
- DB: 외부 Supabase `pack_files` 테이블 (DDL: `packs-lab/supabase/pack_files.sql`)
|
||
- 파일 구조: `app/main.py`, `app/auth.py`, `app/dsm_client.py`, `app/routes.py`, `app/models.py`
|
||
- 운영 디렉토리: `/volume1/docker/webpage/media/packs/{starter,pro,master}/` (NAS PUID:PGID 권한 필요)
|
||
|
||
**환경변수**
|
||
- `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용)
|
||
- `BACKEND_HMAC_SECRET`: Vercel SaaS와 양쪽 공유 시크릿 (HMAC SHA256)
|
||
- `SUPABASE_URL` / `SUPABASE_SERVICE_KEY`: Supabase pack_files 테이블 접근 (service_role, RLS 우회)
|
||
- `UPLOAD_TOKEN_TTL_SEC`: admin upload 토큰 TTL (기본 1800초 = 30분)
|
||
- `PACK_DATA_PATH`: 호스트 마운트 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/packs`)
|
||
|
||
**HMAC 인증 패턴**
|
||
- Vercel → backend 요청: `X-Timestamp` (UNIX 초) + `X-Signature` (HMAC_SHA256(timestamp + "." + body, secret))
|
||
- Replay 방어: 타임스탬프 ±5분 윈도우
|
||
- admin browser → backend upload: `Authorization: Bearer <token>` (jti 단발성)
|
||
|
||
**packs-lab API 목록**
|
||
|
||
| 메서드 | 경로 | 설명 |
|
||
|--------|------|------|
|
||
| POST | `/api/packs/sign-link` | Vercel HMAC → DSM Sharing.create로 4시간 유효 다운로드 URL 발급 |
|
||
| POST | `/api/packs/admin/mint-token` | Vercel HMAC → 일회성 upload 토큰 발급 (기본 30분 TTL) |
|
||
| POST | `/api/packs/upload` | Bearer token → multipart 5GB 저장 + Supabase INSERT |
|
||
| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
|
||
| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
|
||
```
|
||
|
||
- [ ] **Step 6: workspace/CLAUDE.md — 컨테이너 표 한 줄 추가**
|
||
|
||
`workspace/CLAUDE.md`의 "Docker 서비스 & 포트" 표에 추가:
|
||
|
||
```
|
||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
|
||
```
|
||
|
||
(personal 행 다음 또는 적절한 위치)
|
||
|
||
- [ ] **Step 7: 커밋 (web-backend repo의 CLAUDE.md만)**
|
||
|
||
작업 디렉토리는 `C:\Users\jaeoh\Desktop\workspace\web-backend`. 그 안의 `CLAUDE.md`만 git 추적 대상.
|
||
|
||
```bash
|
||
git add CLAUDE.md
|
||
git commit -m "docs(claude): packs-lab 10번째 서비스로 등록 (포트/라우팅/API 표 + 신규 섹션)"
|
||
```
|
||
|
||
> ℹ️ `workspace/CLAUDE.md`(상위 디렉토리의 워크스페이스 메모)는 git repo가 아님. 텍스트 편집만 하고 commit 대상에서 제외.
|
||
|
||
---
|
||
|
||
## Task 9: 회귀 검증 + NAS 디렉토리 가이드
|
||
|
||
전체 테스트 + docker compose config + NAS 배포 전 가이드.
|
||
|
||
**Files:**
|
||
- (검증만)
|
||
|
||
- [ ] **Step 1: 전체 pytest**
|
||
|
||
```bash
|
||
cd packs-lab
|
||
python -m pytest tests/ -v
|
||
```
|
||
|
||
Expected: 모든 테스트 통과 (test_auth + test_routes + test_dsm_client = 약 15+ tests).
|
||
|
||
- [ ] **Step 2: docker compose config 검증**
|
||
|
||
```bash
|
||
cd C:\Users\jaeoh\Desktop\workspace\web-backend
|
||
docker compose config 2>&1 | tail -30
|
||
```
|
||
|
||
Expected: error 없이 packs-lab 포함된 전체 config 출력.
|
||
|
||
> ⚠️ Docker 미설치 시 skip. NAS에서 git push 후 webhook 배포 시점에 검증됨.
|
||
|
||
- [ ] **Step 3: NAS 배포 전 가이드 출력**
|
||
|
||
배포 전 NAS에서 SSH로 1회 실행할 명령들을 README 또는 NAS 배포 노트로 정리. 본 task에서는 명령만 제시 (실행은 사용자):
|
||
|
||
```bash
|
||
# NAS SSH로 접속 후
|
||
mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
|
||
chown -R PUID:PGID /volume1/docker/webpage/media/packs # PUID/PGID는 .env 값 사용
|
||
|
||
# .env에 신규 환경변수 추가 (DSM_*, BACKEND_HMAC_SECRET, SUPABASE_*, UPLOAD_TOKEN_TTL_SEC, PACK_DATA_PATH=/volume1/docker/webpage/media/packs)
|
||
|
||
# Supabase에서 packs-lab/supabase/pack_files.sql 실행
|
||
|
||
# git push 후 webhook이 자동 배포
|
||
```
|
||
|
||
- [ ] **Step 4: 최종 commit (검증 결과 빈 commit으로 마일스톤 표시 — 선택)**
|
||
|
||
```bash
|
||
# 만약 위 step에서 어떤 자동 수정이 있었으면 commit. 없으면 skip.
|
||
git status
|
||
```
|
||
|
||
회귀 검증으로 변경 사항 없으면 별도 commit 없이 종료.
|
||
|
||
---
|
||
|
||
## 완료 기준
|
||
|
||
- 모든 task의 step 통과 (체크박스 모두 체크)
|
||
- `cd packs-lab && python -m pytest tests/ -v` — 통과 (test_auth + test_routes + test_dsm_client)
|
||
- `docker compose config` — packs-lab 포함된 전체 config 정상
|
||
- web-backend/CLAUDE.md 5곳 갱신 + workspace/CLAUDE.md 1줄
|
||
- Supabase DDL 파일 존재 (운영 적용은 사용자가 NAS에서 SQL editor로)
|
||
- NAS 디렉토리 준비 명령은 사용자가 SSH로 실행 (배포 전 1회)
|
||
|
||
---
|
||
|
||
## 배포
|
||
|
||
git push → Gitea webhook → deployer rsync → docker compose up -d --build (자동).
|
||
|
||
**배포 전 사용자 액션 (1회)**:
|
||
1. Supabase에서 `pack_files` 테이블 생성 (DDL 실행)
|
||
2. NAS SSH로 `/volume1/docker/webpage/media/packs/{starter,pro,master}` 디렉토리 생성 + 권한
|
||
3. NAS `.env`에 신규 7개 환경변수 입력 (DSM 인증, HMAC secret, Supabase 키 등)
|
||
|
||
---
|
||
|
||
## 참고 — 후속 별도 plan (스코프 외)
|
||
|
||
- Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase user 테이블
|
||
- DSM 공유 추적 (즉시 차단 필요 시)
|
||
- deleted_at + N일 후 실제 파일 삭제 cron
|
||
- multi-admin 토큰 발급 권한 분리
|
||
- resumable multipart 업로드 (5GB tus 등)
|
||
- pack_files sort_order 편집 endpoint
|
||
- 모니터링 (업로드 실패율, DSM API latency)
|