Compare commits
8 Commits
42bd53ee7b
...
feat/secur
| Author | SHA1 | Date | |
|---|---|---|---|
| 49c5c57be5 | |||
| 6053e69afc | |||
| 1e5e1bcdff | |||
| 64fbbb7958 | |||
| cfbb72051f | |||
| bf5897fc85 | |||
| ad6c744f2c | |||
| aad9bfbe8b |
@@ -51,9 +51,14 @@ PGID=1000
|
|||||||
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||||
|
|
||||||
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
|
# Admin API Key — /api/trade/* 등 민감 엔드포인트 보호.
|
||||||
|
# 운영 .env에는 반드시 값을 채워야 함. 빈 값이면 503 응답으로 거부됨 (CODE_REVIEW F2).
|
||||||
ADMIN_API_KEY=
|
ADMIN_API_KEY=
|
||||||
|
|
||||||
|
# 개발 모드: 위 ADMIN_API_KEY 비워둔 채로 trade/admin 엔드포인트 호출 허용.
|
||||||
|
# 운영 환경에서는 절대 true로 두지 말 것. 기본 false (보호 활성).
|
||||||
|
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||||
|
|
||||||
# Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider)
|
# Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider)
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ services:
|
|||||||
- ANTHROPIC_MODEL_SONNET=${ANTHROPIC_MODEL_SONNET:-claude-sonnet-4-6}
|
- ANTHROPIC_MODEL_SONNET=${ANTHROPIC_MODEL_SONNET:-claude-sonnet-4-6}
|
||||||
- NAVER_CLIENT_ID=${NAVER_CLIENT_ID:-}
|
- NAVER_CLIENT_ID=${NAVER_CLIENT_ID:-}
|
||||||
- NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET:-}
|
- NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET:-}
|
||||||
|
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
|
||||||
- INSTA_DATA_PATH=/app/data
|
- INSTA_DATA_PATH=/app/data
|
||||||
- CARD_TEMPLATE_DIR=/app/app/templates
|
- CARD_TEMPLATE_DIR=/app/app/templates
|
||||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
# --timeout 600 --retries 5: NAS 느린 네트워크/CPU에서 pip 다운로드 timeout 방지
|
||||||
|
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||||
RUN playwright install chromium
|
RUN playwright install chromium
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os
|
|||||||
|
|
||||||
NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
|
NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
|
||||||
NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "")
|
NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "")
|
||||||
|
YOUTUBE_DATA_API_KEY = os.getenv("YOUTUBE_DATA_API_KEY", "")
|
||||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
ANTHROPIC_MODEL_HAIKU = os.getenv("ANTHROPIC_MODEL_HAIKU", "claude-haiku-4-5-20251001")
|
ANTHROPIC_MODEL_HAIKU = os.getenv("ANTHROPIC_MODEL_HAIKU", "claude-haiku-4-5-20251001")
|
||||||
ANTHROPIC_MODEL_SONNET = os.getenv("ANTHROPIC_MODEL_SONNET", "claude-sonnet-4-6")
|
ANTHROPIC_MODEL_SONNET = os.getenv("ANTHROPIC_MODEL_SONNET", "claude-sonnet-4-6")
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ async def _bg_collect_trends(task_id: str, categories: list[str]):
|
|||||||
try:
|
try:
|
||||||
db.update_task(task_id, "processing", 10, "외부 트렌드 수집 중")
|
db.update_task(task_id, "processing", 10, "외부 트렌드 수집 중")
|
||||||
result = trend_collector.collect_all(categories)
|
result = trend_collector.collect_all(categories)
|
||||||
msg = f"naver:{result['naver_popular']}, google:{result['google_trends']}"
|
msg = f"naver:{result['naver_popular']}, youtube:{result['youtube_trending']}"
|
||||||
db.update_task(task_id, "succeeded", 100, msg, result_id=sum(result.values()))
|
db.update_task(task_id, "succeeded", 100, msg, result_id=sum(result.values()))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("trends collect failed")
|
logger.exception("trends collect failed")
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"""외부 트렌드 수집 — NAVER 인기 + Google Trends + LLM 카테고리 분류.
|
"""외부 트렌드 수집 — NAVER 인기 + YouTube 인기 영상 + LLM 카테고리 분류.
|
||||||
|
|
||||||
Phase B Task 3: Google Trends integration via pytrends + Anthropic Haiku 분류 캐시 (24h TTL).
|
NAVER: 카테고리별 시드 키워드로 인기 검색 → 빈도 상위 추출.
|
||||||
|
YouTube: Google Trends 비공식 endpoint(RSS / dailytrends JSON)가 모두 404 폐기되어
|
||||||
|
대체로 YouTube Data API v3 (`videos.list?chart=mostPopular®ionCode=KR`) 사용.
|
||||||
|
무료 일일 quota 10000, 한국 region 지원, 인기 영상 50개 제목에서 트렌드 추출.
|
||||||
|
LLM 분류 결과는 24h in-memory 캐시.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -11,11 +15,10 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
from anthropic import Anthropic
|
from anthropic import Anthropic
|
||||||
from pytrends.request import TrendReq
|
|
||||||
|
|
||||||
from .config import (
|
from .config import (
|
||||||
NAVER_CLIENT_ID, NAVER_CLIENT_SECRET, DEFAULT_CATEGORY_SEEDS,
|
NAVER_CLIENT_ID, NAVER_CLIENT_SECRET, DEFAULT_CATEGORY_SEEDS,
|
||||||
ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU,
|
ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU, YOUTUBE_DATA_API_KEY,
|
||||||
)
|
)
|
||||||
from . import db
|
from . import db
|
||||||
from .news_collector import _clean
|
from .news_collector import _clean
|
||||||
@@ -29,16 +32,46 @@ _NAVER_HEADERS = {
|
|||||||
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET,
|
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
YOUTUBE_TRENDING_URL = "https://www.googleapis.com/youtube/v3/videos"
|
||||||
|
# YouTube 제목 정제: 대괄호·이모지·과도한 길이 제거 후 카드 주제로 적합한 키워드 형태
|
||||||
|
_TITLE_BRACKET_RE = re.compile(r"[\[【「『\(][^\]】」』\)]{0,30}[\]】」』\)]")
|
||||||
|
_EMOJI_RE = re.compile(
|
||||||
|
r"["
|
||||||
|
r"\U0001F300-\U0001FAFF" # symbols & pictographs, etc.
|
||||||
|
r"\U00002600-\U000027BF" # misc symbols, dingbats
|
||||||
|
r"\U0001F1E6-\U0001F1FF" # regional indicator
|
||||||
|
r"]"
|
||||||
|
)
|
||||||
|
_TITLE_MAX_LEN = 60
|
||||||
|
|
||||||
|
_PLACEHOLDER_SEEDS = {"...", "…", "tbd", "todo", "placeholder", "example"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_seed(s: str) -> bool:
|
||||||
|
"""프롬프트 템플릿에 placeholder/빈 값이 들어가 NAVER에 400을 유발하는 일을 막는 가드."""
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
s = s.strip()
|
||||||
|
if len(s) < 2:
|
||||||
|
return False
|
||||||
|
if s.lower() in _PLACEHOLDER_SEEDS:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _seeds_for(category: str) -> List[str]:
|
def _seeds_for(category: str) -> List[str]:
|
||||||
|
"""category_seeds 프롬프트 템플릿이 있으면 사용, 없거나 모두 invalid면 config DEFAULT 폴백."""
|
||||||
pt = db.get_prompt_template("category_seeds")
|
pt = db.get_prompt_template("category_seeds")
|
||||||
if pt and pt.get("template"):
|
if pt and pt.get("template"):
|
||||||
try:
|
try:
|
||||||
data = json.loads(pt["template"])
|
data = json.loads(pt["template"])
|
||||||
if category in data:
|
if category in data:
|
||||||
return list(data[category])
|
filtered = [s for s in (data[category] or []) if _is_valid_seed(s)]
|
||||||
except Exception:
|
if filtered:
|
||||||
pass
|
return filtered
|
||||||
|
logger.warning("category_seeds[%s]에 유효한 시드 없음 → DEFAULT 폴백", category)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("category_seeds JSON 파싱 실패 → DEFAULT 폴백: %s", e)
|
||||||
return list(DEFAULT_CATEGORY_SEEDS.get(category, []))
|
return list(DEFAULT_CATEGORY_SEEDS.get(category, []))
|
||||||
|
|
||||||
|
|
||||||
@@ -142,36 +175,70 @@ def classify_keyword(keyword: str) -> str:
|
|||||||
return cat
|
return cat
|
||||||
|
|
||||||
|
|
||||||
# ── Google Trends ─────────────────────────────────────────────────────────────
|
# ── YouTube Trending ──────────────────────────────────────────────────────────
|
||||||
|
# YouTube Data API v3 videos.list?chart=mostPopular®ionCode=KR
|
||||||
|
# 한국 인기 영상 50개 제목에서 카드 주제로 적합한 키워드 추출.
|
||||||
|
|
||||||
def fetch_google_trends() -> List[Dict[str, Any]]:
|
def _clean_yt_title(title: str) -> str:
|
||||||
"""pytrends 한국 daily trending searches. 실패 시 빈 리스트."""
|
"""[공식]·【속보】·🔥 등 제거 후 60자 이내로 자른다."""
|
||||||
|
if not title:
|
||||||
|
return ""
|
||||||
|
cleaned = _TITLE_BRACKET_RE.sub("", title)
|
||||||
|
cleaned = _EMOJI_RE.sub("", cleaned)
|
||||||
|
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
return cleaned[:_TITLE_MAX_LEN]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_youtube_trending() -> List[Dict[str, Any]]:
|
||||||
|
"""YouTube Data API v3 mostPopular (한국, 50개). API 키 없거나 호출 실패 시 빈 리스트."""
|
||||||
|
if not YOUTUBE_DATA_API_KEY:
|
||||||
|
logger.info("YOUTUBE_DATA_API_KEY 미설정 — youtube_trending skip")
|
||||||
|
return []
|
||||||
try:
|
try:
|
||||||
pytrends = TrendReq(hl="ko-KR", tz=540)
|
resp = requests.get(
|
||||||
df = pytrends.trending_searches(pn="south_korea")
|
YOUTUBE_TRENDING_URL,
|
||||||
|
params={
|
||||||
|
"part": "snippet",
|
||||||
|
"chart": "mostPopular",
|
||||||
|
"regionCode": "KR",
|
||||||
|
"maxResults": 50,
|
||||||
|
"key": YOUTUBE_DATA_API_KEY,
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
videos = resp.json().get("items", []) or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Google Trends fetch failed: %s", e)
|
logger.warning("YouTube trending fetch failed: %s", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
items: List[Dict[str, Any]] = []
|
items: List[Dict[str, Any]] = []
|
||||||
for idx, row in df.iterrows():
|
seen = set()
|
||||||
kw = str(row.iloc[0]).strip()
|
total = max(1, len(videos))
|
||||||
if not kw:
|
for idx, v in enumerate(videos):
|
||||||
|
title = (v.get("snippet") or {}).get("title", "")
|
||||||
|
kw = _clean_yt_title(title)
|
||||||
|
if not kw or kw in seen:
|
||||||
continue
|
continue
|
||||||
cat = classify_keyword(kw)
|
seen.add(kw)
|
||||||
rank_score = round(max(0.0, 1.0 - (idx / max(1, len(df)))), 4)
|
try:
|
||||||
|
cat = classify_keyword(kw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("classify_keyword(%s) 실패: %s", kw, e)
|
||||||
|
cat = "uncategorized"
|
||||||
|
rank_score = round(max(0.0, 1.0 - (idx / total)), 4)
|
||||||
items.append({
|
items.append({
|
||||||
"keyword": kw,
|
"keyword": kw,
|
||||||
"category": cat,
|
"category": cat,
|
||||||
"source": "google_trends",
|
"source": "youtube_trending",
|
||||||
"score": rank_score,
|
"score": rank_score,
|
||||||
"articles_count": 0,
|
"articles_count": 0,
|
||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def collect_google_trends() -> int:
|
def collect_youtube_trending() -> int:
|
||||||
items = fetch_google_trends()
|
items = fetch_youtube_trending()
|
||||||
for it in items:
|
for it in items:
|
||||||
db.add_external_trend(it)
|
db.add_external_trend(it)
|
||||||
return len(items)
|
return len(items)
|
||||||
@@ -179,5 +246,5 @@ def collect_google_trends() -> int:
|
|||||||
|
|
||||||
def collect_all(categories: List[str]) -> Dict[str, int]:
|
def collect_all(categories: List[str]) -> Dict[str, int]:
|
||||||
naver_n = collect_naver_popular_for(categories)
|
naver_n = collect_naver_popular_for(categories)
|
||||||
google_n = collect_google_trends()
|
yt_n = collect_youtube_trending()
|
||||||
return {"naver_popular": naver_n, "google_trends": google_n}
|
return {"naver_popular": naver_n, "youtube_trending": yt_n}
|
||||||
|
|||||||
@@ -7,4 +7,3 @@ jinja2>=3.1.4
|
|||||||
playwright==1.48.0
|
playwright==1.48.0
|
||||||
pytest>=8.0
|
pytest>=8.0
|
||||||
pytest-asyncio>=0.24
|
pytest-asyncio>=0.24
|
||||||
pytrends>=4.9
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def test_collect_trends_kicks_background(client, monkeypatch):
|
|||||||
|
|
||||||
def fake_collect_all(cats):
|
def fake_collect_all(cats):
|
||||||
captured["called"] = True
|
captured["called"] = True
|
||||||
return {"naver_popular": 3, "google_trends": 2}
|
return {"naver_popular": 3, "youtube_trending": 2}
|
||||||
|
|
||||||
monkeypatch.setattr(trend_collector, "collect_all", fake_collect_all)
|
monkeypatch.setattr(trend_collector, "collect_all", fake_collect_all)
|
||||||
resp = client.post("/api/insta/trends/collect", json={})
|
resp = client.post("/api/insta/trends/collect", json={})
|
||||||
|
|||||||
@@ -77,45 +77,84 @@ def test_classify_keyword_with_cache(monkeypatch):
|
|||||||
assert calls["n"] == 1
|
assert calls["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_google_trends_parses_and_classifies(tmp_db, monkeypatch):
|
def test_fetch_youtube_trending_parses_and_cleans_titles(tmp_db, monkeypatch):
|
||||||
class FakePyTrends:
|
"""YouTube Data API mostPopular 응답 → 제목 정제 + 분류."""
|
||||||
def __init__(self, *_a, **_kw):
|
monkeypatch.setattr(trend_collector, "YOUTUBE_DATA_API_KEY", "fake_key")
|
||||||
pass
|
payload = {
|
||||||
|
"items": [
|
||||||
|
{"snippet": {"title": "[속보] 기준금리 인상 단행 🔥"}},
|
||||||
|
{"snippet": {"title": "(공식) BTS 컴백 무대 🎤"}},
|
||||||
|
{"snippet": {"title": "스트레스 관리 5가지 방법"}},
|
||||||
|
# 중복 제목 — 중복 제거 확인
|
||||||
|
{"snippet": {"title": "[속보] 기준금리 인상 단행 🔥"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
fake_resp = MagicMock()
|
||||||
|
fake_resp.json.return_value = payload
|
||||||
|
fake_resp.raise_for_status.return_value = None
|
||||||
|
monkeypatch.setattr(trend_collector.requests, "get", lambda *a, **kw: fake_resp)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
trend_collector, "classify_keyword",
|
||||||
|
lambda kw: ("economy" if "금리" in kw else
|
||||||
|
"celebrity" if "BTS" in kw else
|
||||||
|
"psychology" if "스트레스" in kw else "uncategorized"),
|
||||||
|
)
|
||||||
|
|
||||||
def trending_searches(self, pn="south_korea"):
|
trends = trend_collector.fetch_youtube_trending()
|
||||||
import pandas as pd
|
keywords = [t["keyword"] for t in trends]
|
||||||
return pd.DataFrame({"0": ["기준금리", "BTS 컴백", "스트레스 관리"]})
|
assert "기준금리 인상 단행" in keywords # 대괄호·이모지 제거
|
||||||
|
assert "BTS 컴백 무대" in keywords # 괄호 제거
|
||||||
|
assert "스트레스 관리 5가지 방법" in keywords # 그대로
|
||||||
|
assert len(trends) == 3 # 중복 제거됨
|
||||||
|
assert all(t["source"] == "youtube_trending" for t in trends)
|
||||||
|
|
||||||
monkeypatch.setattr(trend_collector, "TrendReq", FakePyTrends)
|
|
||||||
monkeypatch.setattr(trend_collector, "classify_keyword",
|
|
||||||
lambda kw: {"기준금리": "economy", "BTS 컴백": "celebrity",
|
|
||||||
"스트레스 관리": "psychology"}.get(kw, "uncategorized"))
|
|
||||||
|
|
||||||
trends = trend_collector.fetch_google_trends()
|
def test_fetch_youtube_trending_no_api_key_returns_empty(monkeypatch):
|
||||||
by_kw = {t["keyword"]: t for t in trends}
|
monkeypatch.setattr(trend_collector, "YOUTUBE_DATA_API_KEY", "")
|
||||||
assert by_kw["기준금리"]["category"] == "economy"
|
out = trend_collector.fetch_youtube_trending()
|
||||||
assert by_kw["BTS 컴백"]["category"] == "celebrity"
|
assert out == []
|
||||||
assert by_kw["스트레스 관리"]["category"] == "psychology"
|
|
||||||
assert all(t["source"] == "google_trends" for t in trends)
|
|
||||||
|
def test_fetch_youtube_trending_graceful_on_api_failure(monkeypatch):
|
||||||
|
monkeypatch.setattr(trend_collector, "YOUTUBE_DATA_API_KEY", "fake_key")
|
||||||
|
fake_resp = MagicMock()
|
||||||
|
fake_resp.raise_for_status.side_effect = RuntimeError("quota exceeded")
|
||||||
|
monkeypatch.setattr(trend_collector.requests, "get", lambda *a, **kw: fake_resp)
|
||||||
|
out = trend_collector.fetch_youtube_trending()
|
||||||
|
assert out == []
|
||||||
|
|
||||||
|
|
||||||
def test_collect_all_invokes_both_sources(tmp_db, monkeypatch):
|
def test_collect_all_invokes_both_sources(tmp_db, monkeypatch):
|
||||||
monkeypatch.setattr(trend_collector, "collect_naver_popular_for",
|
monkeypatch.setattr(trend_collector, "collect_naver_popular_for",
|
||||||
lambda cats: 5)
|
lambda cats: 5)
|
||||||
monkeypatch.setattr(trend_collector, "collect_google_trends",
|
monkeypatch.setattr(trend_collector, "collect_youtube_trending",
|
||||||
lambda: 3)
|
lambda: 3)
|
||||||
out = trend_collector.collect_all(["economy"])
|
out = trend_collector.collect_all(["economy"])
|
||||||
assert out == {"naver_popular": 5, "google_trends": 3}
|
assert out == {"naver_popular": 5, "youtube_trending": 3}
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_google_trends_graceful_on_pytrends_failure(monkeypatch):
|
def test_seeds_for_filters_placeholder(tmp_db, monkeypatch):
|
||||||
class FakePyTrends:
|
"""category_seeds 템플릿에 placeholder '...'가 들어가도 DEFAULT 폴백."""
|
||||||
def __init__(self, *_a, **_kw):
|
from app import db as db_module
|
||||||
pass
|
db_module.upsert_prompt_template(
|
||||||
|
"category_seeds",
|
||||||
|
'{"economy": ["...", "…", "a", "real_keyword"]}',
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
out = trend_collector._seeds_for("economy")
|
||||||
|
# '...', '…', 'a'(2자 미만)는 필터링되고 'real_keyword'만 남음
|
||||||
|
assert out == ["real_keyword"]
|
||||||
|
|
||||||
def trending_searches(self, pn="south_korea"):
|
|
||||||
raise RuntimeError("rate limited")
|
|
||||||
|
|
||||||
monkeypatch.setattr(trend_collector, "TrendReq", FakePyTrends)
|
def test_seeds_for_falls_back_when_all_invalid(tmp_db, monkeypatch):
|
||||||
out = trend_collector.fetch_google_trends()
|
"""모든 시드가 invalid면 DEFAULT_CATEGORY_SEEDS 폴백."""
|
||||||
assert out == []
|
from app import db as db_module
|
||||||
|
db_module.upsert_prompt_template(
|
||||||
|
"category_seeds",
|
||||||
|
'{"economy": ["...", "TBD", ""]}',
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
out = trend_collector._seeds_for("economy")
|
||||||
|
# DEFAULT_CATEGORY_SEEDS["economy"] 가 반환되어야 함
|
||||||
|
from app.config import DEFAULT_CATEGORY_SEEDS
|
||||||
|
assert out == list(DEFAULT_CATEGORY_SEEDS["economy"])
|
||||||
|
|||||||
@@ -133,8 +133,12 @@ async def sign_link(
|
|||||||
|
|
||||||
# 경로 안전: PACK_HOST_DIR(NAS 호스트 절대경로) 하위인지 확인.
|
# 경로 안전: PACK_HOST_DIR(NAS 호스트 절대경로) 하위인지 확인.
|
||||||
# file_path는 upload 라우트가 Supabase에 저장한 호스트경로 그대로 전달되어 DSM API에 사용됨.
|
# file_path는 upload 라우트가 Supabase에 저장한 호스트경로 그대로 전달되어 DSM API에 사용됨.
|
||||||
|
# str.startswith는 '/foo/packs' 와 '/foo/packs_evil' 같은 sibling 경로를 통과시키므로
|
||||||
|
# Path.relative_to로 엄격하게 컴포넌트 단위 검증한다 (CODE_REVIEW F1).
|
||||||
abs_path = Path(payload.file_path).resolve()
|
abs_path = Path(payload.file_path).resolve()
|
||||||
if not str(abs_path).startswith(str(PACK_HOST_DIR)):
|
try:
|
||||||
|
abs_path.relative_to(PACK_HOST_DIR.resolve())
|
||||||
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail="허용된 경로 외부")
|
raise HTTPException(status_code=400, detail="허용된 경로 외부")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -60,6 +60,29 @@ def test_sign_link_path_outside_base():
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_link_rejects_sibling_path():
|
||||||
|
"""PACK_HOST_DIR='/foo/packs' 일 때 '/foo/packs_evil/x.mp4' 같이 prefix만
|
||||||
|
통과하는 sibling 경로는 거부해야 한다 (CODE_REVIEW F1, path traversal 변형).
|
||||||
|
|
||||||
|
기존 str.startswith 방식은 trailing slash가 없어 sibling 경로를 통과시킴.
|
||||||
|
relative_to 기반 검증으로 교체되어야 통과한다.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
from pathlib import Path
|
||||||
|
base_resolved = Path("/foo/packs").resolve()
|
||||||
|
# base의 자식이 아닌 sibling 경로 (예: /foo/packs_evil/...)
|
||||||
|
sibling_posix = (base_resolved.parent / f"{base_resolved.name}_evil" / "x.mp4").as_posix()
|
||||||
|
with patch("app.routes.PACK_HOST_DIR", base_resolved):
|
||||||
|
body = _json.dumps(
|
||||||
|
{"file_path": sibling_posix, "expires_in_seconds": 14400}
|
||||||
|
).encode()
|
||||||
|
r = client.post("/api/packs/sign-link", content=body, headers=_signed(body))
|
||||||
|
assert r.status_code == 400, (
|
||||||
|
f"sibling 경로 '{sibling_posix}'가 허용됨 (status={r.status_code}) "
|
||||||
|
f"— path traversal 가능성"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_upload_invalid_token():
|
def test_upload_invalid_token():
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/packs/upload",
|
"/api/packs/upload",
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── docker / compose / buildkit timeout 늘리기 ──
|
||||||
|
# NAS Celeron J4025에서 pip install·chromium 다운로드 등 무거운 RUN step이
|
||||||
|
# 기본 timeout(2분)에 걸려 webhook 자동 배포가 "DeadlineExceeded"로 끝나는 일이
|
||||||
|
# 있어 10분으로 상향. 호스트 셸 + deployer 컨테이너 둘 다에 적용됨.
|
||||||
|
export COMPOSE_HTTP_TIMEOUT=600
|
||||||
|
export DOCKER_CLIENT_TIMEOUT=600
|
||||||
|
export BUILDKIT_STEP_LOG_MAX_SIZE=-1
|
||||||
|
|
||||||
# ── 동시 배포 방지 (flock) ──
|
# ── 동시 배포 방지 (flock) ──
|
||||||
exec 200>/tmp/deploy.lock
|
exec 200>/tmp/deploy.lock
|
||||||
flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
||||||
|
|||||||
@@ -47,13 +47,30 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
|||||||
# Windows AI Server URL (NAS .env에서 설정)
|
# Windows AI Server URL (NAS .env에서 설정)
|
||||||
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
|
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
|
||||||
|
|
||||||
# Admin API Key 인증
|
# Admin API Key 인증 — /api/trade/* 보호 (CODE_REVIEW F2)
|
||||||
|
# 빈 키 + 명시적 dev flag 없으면 503으로 거부. 운영 .env에 ADMIN_API_KEY 누락 시
|
||||||
|
# 무인증 통과되던 버그 차단.
|
||||||
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
|
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
|
||||||
|
|
||||||
def verify_admin(x_admin_key: str = Header(None)):
|
def verify_admin(x_admin_key: str = Header(None)):
|
||||||
"""admin/trade 엔드포인트 보호용 API 키 검증"""
|
"""admin/trade 엔드포인트 보호용 API 키 검증.
|
||||||
|
|
||||||
|
- ADMIN_API_KEY 설정됨 + 키 일치 → 통과
|
||||||
|
- ADMIN_API_KEY 설정됨 + 키 불일치 → 401 Unauthorized
|
||||||
|
- ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (개발 모드)
|
||||||
|
- ADMIN_API_KEY 미설정 + dev flag 없음 → 503 (보호 강화, 운영 .env 누락 차단)
|
||||||
|
"""
|
||||||
if not ADMIN_API_KEY:
|
if not ADMIN_API_KEY:
|
||||||
return # 키 미설정 시 인증 비활성화 (개발 환경)
|
if os.getenv("ALLOW_UNAUTHENTICATED_ADMIN", "false").lower() == "true":
|
||||||
|
return # 개발 환경 명시적 허용
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=(
|
||||||
|
"admin endpoint protected — ADMIN_API_KEY not configured. "
|
||||||
|
"Set ADMIN_API_KEY in .env, or set ALLOW_UNAUTHENTICATED_ADMIN=true "
|
||||||
|
"for development only."
|
||||||
|
),
|
||||||
|
)
|
||||||
if x_admin_key != ADMIN_API_KEY:
|
if x_admin_key != ADMIN_API_KEY:
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
|||||||
3
stock/pytest.ini
Normal file
3
stock/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
asyncio_mode = auto
|
||||||
43
stock/tests/test_admin_auth.py
Normal file
43
stock/tests/test_admin_auth.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""verify_admin 보안 강화 회귀 테스트 (CODE_REVIEW F2).
|
||||||
|
|
||||||
|
운영 .env에서 ADMIN_API_KEY가 누락되면 /api/trade/balance, /api/trade/order
|
||||||
|
인증이 무력화되는 버그를 막기 위한 가드.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app import main as stock_main
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_admin_rejects_when_key_missing_and_no_dev_flag(monkeypatch):
|
||||||
|
"""ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN 미설정 → 503."""
|
||||||
|
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "")
|
||||||
|
monkeypatch.delenv("ALLOW_UNAUTHENTICATED_ADMIN", raising=False)
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
stock_main.verify_admin(x_admin_key=None)
|
||||||
|
assert exc_info.value.status_code == 503
|
||||||
|
assert "ADMIN_API_KEY" in exc_info.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_admin_allows_when_key_missing_with_dev_flag(monkeypatch):
|
||||||
|
"""ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (개발 모드)."""
|
||||||
|
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "")
|
||||||
|
monkeypatch.setenv("ALLOW_UNAUTHENTICATED_ADMIN", "true")
|
||||||
|
stock_main.verify_admin(x_admin_key=None) # 예외 없으면 통과
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_admin_rejects_wrong_key(monkeypatch):
|
||||||
|
"""ADMIN_API_KEY 설정 + 잘못된 키 → 401 (regression)."""
|
||||||
|
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "secret123")
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
stock_main.verify_admin(x_admin_key="wrong")
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_admin_allows_correct_key(monkeypatch):
|
||||||
|
"""ADMIN_API_KEY 설정 + 올바른 키 → 통과 (regression)."""
|
||||||
|
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "secret123")
|
||||||
|
stock_main.verify_admin(x_admin_key="secret123") # 예외 없으면 통과
|
||||||
Reference in New Issue
Block a user