feat(cafe24): 상품 상세페이지 관리 모듈 Phase 1
카페24 관리자에 직접 접속하지 않고 상품 상세페이지(description HTML)를 편집·예약 적용·복원하기 위한 모듈의 기반을 만든다. Phase 1 은 공통 Integration 계층, cafe24_db, OAuth 연결 화면까지다. 카페24 OAuth/API 클라이언트를 상품관리 모듈 안에 두지 않고 app/integrations/cafe24/ 로 분리했다. 향후 추가할 주문관리(주문 조회·송장 일괄등록·취소/반품/교환)가 같은 토큰과 클라이언트를 그대로 재사용해야 하기 때문이다. 라우터에서 httpx 를 직접 부르지 않고 Cafe24Client 만 쓰게 해서 재시도·rate limit·API 로그·토큰 갱신을 한 곳에 모았다. 토큰은 Fernet 으로 암호화해 저장한다(CAFE24_TOKEN_SECRET). DB 덤프가 유출돼도 access/refresh token 이 평문으로 남지 않게 하기 위함이며, API 로그와 연결 상태 화면에는 토큰·시크릿을 일절 기록/표시하지 않는다. 토큰 갱신은 행 잠금(SELECT ... FOR UPDATE) 안에서 한다. 카페24는 refresh token 을 회전시키므로, 이후 추가될 예약 worker 컨테이너와 web 컨테이너가 동시에 갱신하면 한쪽 토큰이 무효화된다. 기존 파일 변경은 목록에 한 줄씩 추가하는 형태로 44줄뿐이며 기존 라우트· 테이블·인증 로직은 건드리지 않았다. CAFE24_DB_URL 미설정 시 store 가 None 이라 앱은 정상 기동하고 모듈만 "설정 필요" 안내를 표시한다. 가드 헬퍼를 common.py 로 분리한 것은 router.py 가 routes_system.py 를 include 하는 구조에서 순환 import 가 생기기 때문이다. 검증: 신규 테스트 16개 통과(암호화 왕복, 토큰 만료·자동갱신, 상태 노출 시 토큰 미유출, 재시도 예산, 예약 상태 전이). dispatch 기존 테스트 9개 통과. cafe24_db_init.sql 은 로컬에 Docker 가 없어 미실행 — 서버 적용 시 확인 필요. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
"""카페24 상품 상세페이지 관리 모듈.
|
||||
|
||||
라우터/저장소/순수로직/템플릿을 한 디렉토리에서 관리한다.
|
||||
- 라우터: `router.py` (FastAPI APIRouter, prefix=/cafe24) + `routes_*.py`
|
||||
- 저장소: `db.py` (cafe24_db / PostgreSQL 전용)
|
||||
- 순수 로직: `store.py` (버전/예약 상수, 재시도 규칙, 검증)
|
||||
- 템플릿: `templates/cafe24/`
|
||||
|
||||
카페24 API 호출은 이 모듈에 두지 않는다. 향후 주문관리 모듈과 공유하기 위해
|
||||
`app/integrations/cafe24/` 공통 계층을 쓴다.
|
||||
|
||||
데이터 저장은 cafe24_db 전용이다. CAFE24_DB_URL 미설정 시
|
||||
build_cafe24_store 는 None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를
|
||||
보여준다(앱은 죽지 않음).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import store
|
||||
from .router import router
|
||||
from .store import (
|
||||
REVISION_LABELS,
|
||||
REVISION_TYPES,
|
||||
SCHEDULE_STATUS_LABELS,
|
||||
SCHEDULE_STATUSES,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"store",
|
||||
"REVISION_TYPES",
|
||||
"REVISION_LABELS",
|
||||
"SCHEDULE_STATUSES",
|
||||
"SCHEDULE_STATUS_LABELS",
|
||||
"build_cafe24_store",
|
||||
]
|
||||
|
||||
|
||||
def build_cafe24_store(*, dsn: str | None) -> Any:
|
||||
"""CAFE24_DB_URL 이 있으면 Cafe24Store, 없으면 None.
|
||||
|
||||
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 표시.
|
||||
"""
|
||||
if not dsn:
|
||||
return None
|
||||
from .db import Cafe24Store # 지연 import (개발 환경 deps 없을 수 있음)
|
||||
|
||||
return Cafe24Store(dsn)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""카페24 모듈 공용 가드/컨텍스트 헬퍼.
|
||||
|
||||
router.py 와 routes_*.py 가 함께 쓴다(순환 import 방지를 위해 분리).
|
||||
다른 모듈과 동일한 규칙:
|
||||
- JSON API → require_user() : 401/403 HTTPException
|
||||
- HTML 페이지 → guard() : 리다이렉트 / denied.html 응답 반환
|
||||
app.main 은 함수 안에서 지연 import 한다(순환 import 방지).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
MODULE_KEY = "cafe24"
|
||||
MODULE_NAME = "카페24 상품관리"
|
||||
|
||||
CONFIG_HELP = (
|
||||
"카페24 모듈이 아직 설정되지 않았습니다. "
|
||||
"CAFE24_DB_URL 환경변수를 설정하고 "
|
||||
"scripts/sql/cafe24_db_init.sql 로 cafe24_db 를 초기화한 뒤 "
|
||||
"컨테이너를 재기동하세요."
|
||||
)
|
||||
|
||||
|
||||
def get_store(request: Request) -> Any:
|
||||
return getattr(request.app.state, "cafe24_store", None)
|
||||
|
||||
|
||||
def require_user(request: Request) -> dict[str, Any]:
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
from app.store import has_module # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not has_module(user, MODULE_KEY):
|
||||
raise HTTPException(status_code=403, detail=f"{MODULE_NAME} 모듈 권한이 없습니다.")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(request: Request) -> dict[str, Any]:
|
||||
"""카페24 연결(OAuth)·연결 해제는 관리자만."""
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
user = require_user(request)
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="관리자만 카페24 연결을 변경할 수 있습니다.")
|
||||
return user
|
||||
|
||||
|
||||
def require_store(request: Request) -> tuple[Any, dict[str, Any]]:
|
||||
"""JSON API 용 — store 미설정이면 503."""
|
||||
user = require_user(request)
|
||||
st = get_store(request)
|
||||
if st is None:
|
||||
raise HTTPException(status_code=503, detail=CONFIG_HELP)
|
||||
return st, user
|
||||
|
||||
|
||||
def render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{
|
||||
"reason": CONFIG_HELP,
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active=MODULE_KEY),
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
|
||||
def guard(request: Request):
|
||||
"""로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용.
|
||||
|
||||
반환이 tuple 이면 (store, user), 아니면 그대로 응답으로 돌려준다.
|
||||
"""
|
||||
from app.main import get_current_user_record, render_template # noqa: WPS433
|
||||
from app.store import has_module, is_admin # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not has_module(user, MODULE_KEY):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{
|
||||
"reason": f"{MODULE_NAME} 접근 권한이 없습니다.",
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
st = get_store(request)
|
||||
if st is None:
|
||||
return render_config_needed(request, user)
|
||||
return st, user
|
||||
|
||||
|
||||
def base_ctx(request: Request, user: dict[str, Any], *, active_tab: str = "") -> dict[str, Any]:
|
||||
from app.main import build_erp_nav # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_super": bool(user.get("is_super_admin")),
|
||||
"nav_items": build_erp_nav(user, active=MODULE_KEY),
|
||||
"active_tab": active_tab,
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"""cafe24_db PostgreSQL 저장소.
|
||||
|
||||
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — 다른 모듈과 동일 패턴.
|
||||
- 연결 정보: 환경변수 `CAFE24_DB_URL`
|
||||
(예: postgresql://cafe24_app:<pwd>@postgres-db:5432/cafe24_db)
|
||||
- 스키마는 앱이 만들지 않는다. `scripts/sql/cafe24_db_init.sql` 을 superuser 가
|
||||
사전 적용한다. 앱 계정(cafe24_app)은 CRUD 권한만 받는다.
|
||||
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||
|
||||
토큰 값은 이 계층에 도달하기 전 이미 Fernet 암호문이다(평문 취급 금지).
|
||||
API 로그에는 토큰/시크릿을 넣지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Iterator
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from app.timezone import KST
|
||||
|
||||
from . import store
|
||||
|
||||
logger = logging.getLogger("cafe24.db")
|
||||
|
||||
# save_token_row / TokenLock.save 에서 부분 갱신을 허용하는 컬럼 화이트리스트.
|
||||
# 여기 없는 키는 무시한다(임의 컬럼 주입 방지).
|
||||
_TOKEN_FIELDS: tuple[str, ...] = (
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"access_token_expires_at",
|
||||
"refresh_token_expires_at",
|
||||
"scopes",
|
||||
"last_refreshed_at",
|
||||
"last_error",
|
||||
"connected_by",
|
||||
)
|
||||
|
||||
|
||||
class TokenLock:
|
||||
"""token_lock() 이 넘겨주는 핸들. 잠긴 행 조회 + 같은 트랜잭션 안 저장."""
|
||||
|
||||
def __init__(self, conn: Any, mall_id: str, row: dict[str, Any] | None):
|
||||
self._conn = conn
|
||||
self._mall_id = mall_id
|
||||
self.row = row
|
||||
|
||||
def save(self, **fields: Any) -> None:
|
||||
_update_token_row(self._conn, self._mall_id, fields)
|
||||
|
||||
|
||||
def _update_token_row(conn: Any, mall_id: str, fields: dict[str, Any]) -> None:
|
||||
"""UPSERT. 주어진 컬럼만 갱신한다(부분 갱신)."""
|
||||
allowed = {k: v for k, v in fields.items() if k in _TOKEN_FIELDS}
|
||||
if not allowed:
|
||||
return
|
||||
columns = list(allowed.keys())
|
||||
placeholders = ", ".join(["%s"] * len(columns))
|
||||
assignments = ", ".join(f"{col} = EXCLUDED.{col}" for col in columns)
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO cafe24_oauth_tokens (mall_id, {", ".join(columns)})
|
||||
VALUES (%s, {placeholders})
|
||||
ON CONFLICT (mall_id) DO UPDATE SET {assignments}
|
||||
""",
|
||||
(mall_id, *[allowed[col] for col in columns]),
|
||||
)
|
||||
|
||||
|
||||
class Cafe24Store:
|
||||
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||
open=False,
|
||||
)
|
||||
self._pool.open(wait=False)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# OAuth 토큰 — app/integrations/cafe24/tokens.py 가 요구하는 3개 메서드
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def get_token_row(self, mall_id: str) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM cafe24_oauth_tokens WHERE mall_id = %s",
|
||||
(mall_id,),
|
||||
).fetchone()
|
||||
|
||||
def save_token_row(self, *, mall_id: str, **fields: Any) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
_update_token_row(conn, mall_id, fields)
|
||||
|
||||
@contextmanager
|
||||
def token_lock(self, mall_id: str) -> Iterator[TokenLock]:
|
||||
"""토큰 행을 FOR UPDATE 로 잠근 채 작업.
|
||||
|
||||
web 컨테이너와 worker 컨테이너가 동시에 refresh 하는 것을 막는다
|
||||
(카페24는 refresh token 을 회전시키므로 동시 갱신 시 한쪽이 무효화됨).
|
||||
행이 아직 없으면 row=None 으로 넘어간다.
|
||||
"""
|
||||
with self._pool.connection() as conn:
|
||||
with conn.transaction():
|
||||
row = conn.execute(
|
||||
"SELECT * FROM cafe24_oauth_tokens WHERE mall_id = %s FOR UPDATE",
|
||||
(mall_id,),
|
||||
).fetchone()
|
||||
yield TokenLock(conn, mall_id, row)
|
||||
|
||||
def disconnect(self, mall_id: str) -> None:
|
||||
"""연결 해제 — 토큰만 지운다(이력/예약은 보존)."""
|
||||
with self._pool.connection() as conn:
|
||||
conn.execute("DELETE FROM cafe24_oauth_tokens WHERE mall_id = %s", (mall_id,))
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# API 호출 로그 (Cafe24Client 가 주입받아 호출)
|
||||
# ⚠️ Authorization/토큰/시크릿은 절대 기록하지 않는다.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def log_api_call(
|
||||
self,
|
||||
*,
|
||||
endpoint: str,
|
||||
method: str,
|
||||
product_no: int | None,
|
||||
http_status: int | None,
|
||||
result: str,
|
||||
error_message: str,
|
||||
duration_ms: int,
|
||||
) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO cafe24_api_logs
|
||||
(endpoint, method, product_no, http_status, result, error_message, duration_ms)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""",
|
||||
(endpoint, method, product_no, http_status, result, error_message, duration_ms),
|
||||
)
|
||||
|
||||
def list_api_logs(self, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM cafe24_api_logs
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(max(1, min(int(limit), 500)),),
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 작업 감사 로그
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def log_audit(
|
||||
self,
|
||||
*,
|
||||
actor: str,
|
||||
action: str,
|
||||
product_no: int | None = None,
|
||||
revision_id: int | None = None,
|
||||
schedule_id: int | None = None,
|
||||
result: str = "",
|
||||
detail: str = "",
|
||||
) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
self._insert_audit(
|
||||
conn,
|
||||
actor=actor,
|
||||
action=action,
|
||||
product_no=product_no,
|
||||
revision_id=revision_id,
|
||||
schedule_id=schedule_id,
|
||||
result=result,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _insert_audit(
|
||||
conn: Any,
|
||||
*,
|
||||
actor: str,
|
||||
action: str,
|
||||
product_no: int | None = None,
|
||||
revision_id: int | None = None,
|
||||
schedule_id: int | None = None,
|
||||
result: str = "",
|
||||
detail: str = "",
|
||||
) -> None:
|
||||
"""호출자의 트랜잭션에 합류시키기 위해 conn 을 받는 정적 헬퍼."""
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO cafe24_audit_logs
|
||||
(actor, action, product_no, revision_id, schedule_id, result, detail)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""",
|
||||
(actor, action, product_no, revision_id, schedule_id, result, detail[:1000]),
|
||||
)
|
||||
|
||||
def list_audit_logs(self, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM cafe24_audit_logs
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(max(1, min(int(limit), 500)),),
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 직렬화 — datetime → KST ISO, date → ISO (다른 모듈과 동일)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@staticmethod
|
||||
def _serialize(row: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not row:
|
||||
return {}
|
||||
out = dict(row)
|
||||
for key, value in list(out.items()):
|
||||
if isinstance(value, datetime):
|
||||
aware = value if value.tzinfo else value.replace(tzinfo=KST)
|
||||
out[key] = aware.astimezone(KST).isoformat(timespec="seconds")
|
||||
elif isinstance(value, date):
|
||||
out[key] = value.isoformat()
|
||||
return out
|
||||
|
||||
|
||||
__all__ = ["Cafe24Store", "TokenLock", "store"]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""카페24 상품 상세페이지 관리 모듈 라우터.
|
||||
|
||||
- 경로: /cafe24
|
||||
- 권한: 로그인 + `cafe24` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
|
||||
카페24 연결(OAuth) 변경은 `is_admin` 만.
|
||||
- 데이터: Cafe24Store (cafe24_db / PostgreSQL) 전용.
|
||||
CAFE24_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
|
||||
- 카페24 API 호출은 app/integrations/cafe24 공통 계층을 통해서만 한다.
|
||||
|
||||
라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에
|
||||
확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다.
|
||||
routes_system 연결(OAuth)·상태·API 로그·작업 로그
|
||||
(Phase 2~) routes_products / routes_schedules
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from .common import base_ctx, guard
|
||||
from .routes_system import system_router
|
||||
|
||||
logger = logging.getLogger("cafe24.router")
|
||||
|
||||
router = APIRouter(prefix="/cafe24", tags=["cafe24"])
|
||||
|
||||
router.include_router(system_router)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
"""포털 카드의 상태 점(dot) 용. 인증 불필요 — 상태 문자열만 반환."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request) -> HTMLResponse:
|
||||
"""상품 목록 (Phase 2 에서 구현). 지금은 연결 상태 안내만."""
|
||||
from app.main import render_template # noqa: WPS433
|
||||
|
||||
checked = guard(request)
|
||||
if not isinstance(checked, tuple):
|
||||
return checked
|
||||
_st, user = checked
|
||||
|
||||
ctx = base_ctx(request, user, active_tab="products")
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "카페24 상품관리",
|
||||
"page_subtitle": "상품 상세페이지 조회·편집·예약",
|
||||
}
|
||||
)
|
||||
return render_template(request, "cafe24/index.html", ctx)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""카페24 시스템 화면 — 연결(OAuth) / 연결 상태 / API 로그 / 작업 로그.
|
||||
|
||||
OAuth 흐름
|
||||
1) 관리자가 [카페24 연결] → GET /cafe24/system/oauth/start
|
||||
state 를 만들어 세션에 넣고 카페24 인증 페이지로 302.
|
||||
2) 카페24가 GET /cafe24/oauth/callback?code=&state= 로 되돌려보냄.
|
||||
세션 state 와 대조(CSRF 방어) 후 code → 토큰 교환, 암호화 저장.
|
||||
|
||||
핸들러는 `def`(동기)로 선언한다. 카페24 API·DB 호출이 블로킹이므로 FastAPI 의
|
||||
스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.integrations.cafe24 import (
|
||||
Cafe24AuthError,
|
||||
Cafe24ConfigError,
|
||||
Cafe24Error,
|
||||
build_authorize_url,
|
||||
build_cafe24_api,
|
||||
exchange_code,
|
||||
load_config,
|
||||
new_state,
|
||||
)
|
||||
|
||||
from .common import base_ctx, guard, render_config_needed, require_admin
|
||||
|
||||
logger = logging.getLogger("cafe24.system")
|
||||
|
||||
system_router = APIRouter()
|
||||
|
||||
# 세션에 state 를 담는 키
|
||||
_STATE_KEY = "cafe24_oauth_state"
|
||||
|
||||
|
||||
@system_router.get("/system", response_class=HTMLResponse)
|
||||
def system_page(request: Request) -> HTMLResponse:
|
||||
from app.main import render_template # noqa: WPS433
|
||||
|
||||
checked = guard(request)
|
||||
if not isinstance(checked, tuple):
|
||||
return checked
|
||||
st, user = checked
|
||||
|
||||
api = build_cafe24_api(st)
|
||||
try:
|
||||
status = api.tokens.status()
|
||||
except Cafe24Error as exc:
|
||||
status = {
|
||||
"connected": False,
|
||||
"mall_id": api.config.mall_id,
|
||||
"missing": api.config.missing,
|
||||
"needs_reauth": True,
|
||||
"reason": str(exc),
|
||||
}
|
||||
|
||||
ctx = base_ctx(request, user, active_tab="system")
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "카페24 — 시스템",
|
||||
"page_subtitle": "연결 상태 · API 로그 · 작업 로그",
|
||||
"status": status,
|
||||
"api_version": api.config.api_version,
|
||||
"scopes": api.config.scope_param,
|
||||
"redirect_uri": api.config.redirect_uri,
|
||||
"api_logs": st.list_api_logs(limit=50),
|
||||
"audit_logs": st.list_audit_logs(limit=50),
|
||||
"flash": request.query_params.get("msg", ""),
|
||||
"flash_error": request.query_params.get("err", ""),
|
||||
}
|
||||
)
|
||||
return render_template(request, "cafe24/system.html", ctx)
|
||||
|
||||
|
||||
@system_router.get("/system/oauth/start")
|
||||
def oauth_start(request: Request):
|
||||
"""카페24 인증 시작 (관리자 전용)."""
|
||||
user = require_admin(request)
|
||||
st = getattr(request.app.state, "cafe24_store", None)
|
||||
if st is None:
|
||||
return render_config_needed(request, user)
|
||||
|
||||
config = load_config()
|
||||
try:
|
||||
state = new_state()
|
||||
url = build_authorize_url(config, state=state)
|
||||
except Cafe24ConfigError as exc:
|
||||
return RedirectResponse(url=f"/cafe24/system?err={exc}", status_code=303)
|
||||
|
||||
request.session[_STATE_KEY] = state
|
||||
return RedirectResponse(url=url, status_code=303)
|
||||
|
||||
|
||||
@system_router.get("/oauth/callback")
|
||||
def oauth_callback(request: Request):
|
||||
"""카페24 콜백 — code → 토큰 교환 후 암호화 저장."""
|
||||
user = require_admin(request)
|
||||
st = getattr(request.app.state, "cafe24_store", None)
|
||||
if st is None:
|
||||
return render_config_needed(request, user)
|
||||
|
||||
expected = request.session.pop(_STATE_KEY, "")
|
||||
received = request.query_params.get("state", "")
|
||||
error = request.query_params.get("error", "")
|
||||
code = request.query_params.get("code", "")
|
||||
|
||||
if error:
|
||||
return RedirectResponse(url=f"/cafe24/system?err=카페24 인증이 취소되었습니다. ({error})", status_code=303)
|
||||
if not expected or expected != received:
|
||||
# state 불일치 = 위조된 콜백일 수 있다. 토큰 교환하지 않는다.
|
||||
logger.warning("카페24 OAuth state 불일치 — 콜백 거부")
|
||||
return RedirectResponse(
|
||||
url="/cafe24/system?err=인증 state 가 일치하지 않습니다. 다시 시도하세요.",
|
||||
status_code=303,
|
||||
)
|
||||
if not code:
|
||||
return RedirectResponse(url="/cafe24/system?err=인증 코드가 없습니다.", status_code=303)
|
||||
|
||||
api = build_cafe24_api(st)
|
||||
try:
|
||||
bundle = exchange_code(api.config, code=code)
|
||||
api.tokens.save_bundle(bundle, connected_by=str(user.get("email") or ""))
|
||||
except (Cafe24AuthError, Cafe24ConfigError) as exc:
|
||||
st.log_audit(
|
||||
actor=str(user.get("email") or ""),
|
||||
action="oauth_connect",
|
||||
result="FAIL",
|
||||
detail=str(exc),
|
||||
)
|
||||
return RedirectResponse(url=f"/cafe24/system?err={exc}", status_code=303)
|
||||
|
||||
st.log_audit(
|
||||
actor=str(user.get("email") or ""),
|
||||
action="oauth_connect",
|
||||
result="SUCCESS",
|
||||
detail=f"scopes={bundle.scopes}",
|
||||
)
|
||||
logger.info("카페24 연결 완료 (mall_id=%s)", api.config.mall_id)
|
||||
return RedirectResponse(url="/cafe24/system?msg=카페24에 연결되었습니다.", status_code=303)
|
||||
|
||||
|
||||
@system_router.post("/system/oauth/disconnect")
|
||||
def oauth_disconnect(request: Request):
|
||||
"""저장된 토큰 삭제 (관리자 전용). 이력/예약 데이터는 지우지 않는다."""
|
||||
user = require_admin(request)
|
||||
st = getattr(request.app.state, "cafe24_store", None)
|
||||
if st is None:
|
||||
return render_config_needed(request, user)
|
||||
|
||||
config = load_config()
|
||||
st.disconnect(config.mall_id)
|
||||
st.log_audit(
|
||||
actor=str(user.get("email") or ""),
|
||||
action="oauth_disconnect",
|
||||
result="SUCCESS",
|
||||
)
|
||||
return RedirectResponse(url="/cafe24/system?msg=카페24 연결을 해제했습니다.", status_code=303)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""카페24 모듈 순수 로직 — DB/네트워크 I/O 없음(유닛테스트 대상).
|
||||
|
||||
상수, 상태 전이 규칙, HTML 치환/검증처럼 부수효과 없는 함수만 둔다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ── 상세페이지 버전 종류 (cafe24_product_revisions.revision_type) ──
|
||||
REVISION_SYNC = "SYNC" # 카페24 현재값 스냅샷
|
||||
REVISION_DRAFT = "DRAFT" # 저장만 한 초안
|
||||
REVISION_BACKUP = "BACKUP" # 쓰기 직전 자동 백업 ← 복원 기준
|
||||
REVISION_MANUAL = "MANUAL" # 즉시 적용
|
||||
REVISION_SCHEDULED = "SCHEDULED" # 예약 적용
|
||||
REVISION_ROLLBACK = "ROLLBACK" # 과거 버전 되돌림
|
||||
|
||||
REVISION_TYPES: tuple[str, ...] = (
|
||||
REVISION_SYNC,
|
||||
REVISION_DRAFT,
|
||||
REVISION_BACKUP,
|
||||
REVISION_MANUAL,
|
||||
REVISION_SCHEDULED,
|
||||
REVISION_ROLLBACK,
|
||||
)
|
||||
|
||||
REVISION_LABELS: dict[str, str] = {
|
||||
REVISION_SYNC: "현재값 동기화",
|
||||
REVISION_DRAFT: "초안",
|
||||
REVISION_BACKUP: "적용 직전 자동백업",
|
||||
REVISION_MANUAL: "즉시 적용",
|
||||
REVISION_SCHEDULED: "예약 적용",
|
||||
REVISION_ROLLBACK: "복원",
|
||||
}
|
||||
|
||||
# ── 예약 상태 (cafe24_product_schedules.status) ──
|
||||
STATUS_PENDING = "PENDING"
|
||||
STATUS_PROCESSING = "PROCESSING"
|
||||
STATUS_SUCCESS = "SUCCESS"
|
||||
STATUS_FAILED = "FAILED"
|
||||
STATUS_CANCELLED = "CANCELLED"
|
||||
|
||||
SCHEDULE_STATUSES: tuple[str, ...] = (
|
||||
STATUS_PENDING,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
STATUS_FAILED,
|
||||
STATUS_CANCELLED,
|
||||
)
|
||||
|
||||
SCHEDULE_STATUS_LABELS: dict[str, str] = {
|
||||
STATUS_PENDING: "대기",
|
||||
STATUS_PROCESSING: "실행중",
|
||||
STATUS_SUCCESS: "완료",
|
||||
STATUS_FAILED: "실패",
|
||||
STATUS_CANCELLED: "취소",
|
||||
}
|
||||
|
||||
# 사용자가 손댈 수 있는 상태 — PROCESSING/SUCCESS 는 임의 변경 금지
|
||||
EDITABLE_STATUSES: tuple[str, ...] = (STATUS_PENDING,)
|
||||
|
||||
# 예약 실패 시 최대 재시도 횟수
|
||||
MAX_RETRY = 3
|
||||
|
||||
# 종료 후 동작 (cafe24_product_schedules.end_action)
|
||||
END_NONE = ""
|
||||
END_RESTORE = "restore" # 적용 직전 BACKUP 으로 복원
|
||||
END_REVISION = "revision" # 지정한 버전 적용
|
||||
END_ACTIONS: tuple[str, ...] = (END_NONE, END_RESTORE, END_REVISION)
|
||||
|
||||
|
||||
def is_editable(status: str) -> bool:
|
||||
"""예약을 수정/취소할 수 있는 상태인지."""
|
||||
return (status or "").strip().upper() in EDITABLE_STATUSES
|
||||
|
||||
|
||||
def can_retry(retry_count: int) -> bool:
|
||||
"""재시도 여지가 남았는지. 소진되면 FAILED 로 확정한다."""
|
||||
try:
|
||||
return int(retry_count) < MAX_RETRY
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def retry_backoff_seconds(retry_count: int) -> int:
|
||||
"""재시도 간격(초). 1분 → 5분 → 15분. 무한 재시도는 하지 않는다."""
|
||||
table = (60, 300, 900)
|
||||
try:
|
||||
index = max(0, int(retry_count))
|
||||
except (TypeError, ValueError):
|
||||
index = 0
|
||||
return table[min(index, len(table) - 1)]
|
||||
|
||||
|
||||
def normalize_revision_type(value: str) -> str:
|
||||
text = (value or "").strip().upper()
|
||||
return text if text in REVISION_TYPES else REVISION_DRAFT
|
||||
|
||||
|
||||
def parse_product_no(value: object) -> int:
|
||||
"""상품번호 정규화. 잘못된 값이면 ValueError."""
|
||||
try:
|
||||
number = int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("상품번호는 숫자여야 합니다.") from None
|
||||
if number <= 0:
|
||||
raise ValueError("상품번호는 1 이상이어야 합니다.")
|
||||
return number
|
||||
@@ -0,0 +1,9 @@
|
||||
{# 카페24 모듈 공용 상단 탭. active_tab: products | schedules | system #}
|
||||
<div class="erp-page-actions" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<a class="erp-btn {% if active_tab=='products' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
||||
href="/cafe24/">상품관리</a>
|
||||
<a class="erp-btn {% if active_tab=='schedules' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
||||
href="/cafe24/schedules">예약관리</a>
|
||||
<a class="erp-btn {% if active_tab=='system' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
||||
href="/cafe24/system">시스템</a>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814a" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "cafe24/_nav.html" %}
|
||||
|
||||
<div class="erp-card cf24-empty">
|
||||
<h3>상품 목록은 Phase 2 에서 열립니다.</h3>
|
||||
<p>
|
||||
먼저 <a href="/cafe24/system">시스템 → 카페24 연결</a>에서 인증을 완료하세요.
|
||||
연결이 끝나면 이 화면에서 상품 조회·검색·상세페이지 편집을 할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,137 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814a" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "cafe24/_nav.html" %}
|
||||
|
||||
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
|
||||
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
|
||||
|
||||
{# ── 연결 상태 ───────────────────────────────────────────── #}
|
||||
<div class="erp-card cf24-card">
|
||||
<div class="cf24-card-head">
|
||||
<h3>카페24 연결</h3>
|
||||
{% if status.connected %}
|
||||
<span class="erp-badge cf24-badge-ok">연결됨</span>
|
||||
{% else %}
|
||||
<span class="erp-badge cf24-badge-off">연결 안 됨</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if status.missing %}
|
||||
<div class="cf24-flash cf24-flash-err">
|
||||
다음 환경변수가 설정되지 않았습니다:
|
||||
<code>{{ status.missing | join(', ') }}</code><br />
|
||||
<code>.env</code> 에 추가한 뒤 컨테이너를 재기동하세요.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if status.reason %}<p class="cf24-muted">{{ status.reason }}</p>{% endif %}
|
||||
|
||||
<table class="erp-table cf24-kv">
|
||||
<tbody>
|
||||
<tr><th>쇼핑몰 ID</th><td>{{ status.mall_id or '—' }}</td></tr>
|
||||
<tr><th>API 버전</th><td>{{ api_version }}</td></tr>
|
||||
<tr><th>요청 권한(scope)</th><td><code>{{ scopes }}</code></td></tr>
|
||||
<tr><th>Redirect URI</th><td><code>{{ redirect_uri or '—' }}</code></td></tr>
|
||||
<tr>
|
||||
<th>승인된 권한</th>
|
||||
<td>{% if status.scopes %}<code>{{ status.scopes }}</code>{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Access Token 만료</th>
|
||||
<td>
|
||||
{{ status.access_token_expires_at or '—' }}
|
||||
{% if status.access_expired %}<span class="cf24-muted">(만료 — 다음 호출 시 자동 갱신)</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th>Refresh Token 만료</th><td>{{ status.refresh_token_expires_at or '—' }}</td></tr>
|
||||
<tr><th>마지막 갱신</th><td>{{ status.last_refreshed_at or '—' }}</td></tr>
|
||||
<tr><th>연결한 사람</th><td>{{ status.connected_by or '—' }}</td></tr>
|
||||
{% if status.last_error %}
|
||||
<tr><th>마지막 오류</th><td class="cf24-err">{{ status.last_error }}</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if is_admin %}
|
||||
<div class="cf24-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/cafe24/system/oauth/start">
|
||||
{% if status.connected %}카페24 재연결{% else %}카페24 연결{% endif %}
|
||||
</a>
|
||||
{% if status.connected or status.needs_reauth %}
|
||||
<form method="post" action="/cafe24/system/oauth/disconnect" style="display:inline;"
|
||||
onsubmit="return confirm('저장된 카페24 토큰을 삭제합니다. 계속할까요?\n(변경 이력·예약 데이터는 지워지지 않습니다)');">
|
||||
<button type="submit" class="erp-btn erp-btn-outline">연결 해제</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="cf24-muted">카페24 연결 변경은 관리자만 할 수 있습니다.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── 작업 로그 ───────────────────────────────────────────── #}
|
||||
<div class="erp-card cf24-card">
|
||||
<div class="cf24-card-head"><h3>작업 로그</h3><span class="cf24-muted">최근 50건</span></div>
|
||||
{% if audit_logs %}
|
||||
<div class="cf24-scroll">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr><th>시각</th><th>작업자</th><th>작업</th><th>상품</th><th>결과</th><th>내용</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in audit_logs %}
|
||||
<tr>
|
||||
<td class="cf24-nowrap">{{ log.created_at }}</td>
|
||||
<td>{{ log.actor or '—' }}</td>
|
||||
<td>{{ log.action }}</td>
|
||||
<td>{{ log.product_no or '—' }}</td>
|
||||
<td class="{% if log.result == 'FAIL' %}cf24-err{% endif %}">{{ log.result or '—' }}</td>
|
||||
<td>{{ log.detail or '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="cf24-muted">아직 기록된 작업이 없습니다.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── API 로그 ────────────────────────────────────────────── #}
|
||||
<div class="erp-card cf24-card">
|
||||
<div class="cf24-card-head">
|
||||
<h3>카페24 API 로그</h3>
|
||||
<span class="cf24-muted">최근 50건 · 토큰/시크릿은 기록하지 않습니다</span>
|
||||
</div>
|
||||
{% if api_logs %}
|
||||
<div class="cf24-scroll">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr><th>시각</th><th>메서드</th><th>엔드포인트</th><th>상품</th><th>상태</th><th>결과</th><th>소요</th><th>오류</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in api_logs %}
|
||||
<tr>
|
||||
<td class="cf24-nowrap">{{ log.created_at }}</td>
|
||||
<td>{{ log.method }}</td>
|
||||
<td><code>{{ log.endpoint }}</code></td>
|
||||
<td>{{ log.product_no or '—' }}</td>
|
||||
<td>{{ log.http_status or '—' }}</td>
|
||||
<td class="{% if log.result != 'SUCCESS' %}cf24-err{% endif %}">{{ log.result }}</td>
|
||||
<td class="cf24-nowrap">{{ log.duration_ms }}ms</td>
|
||||
<td>{{ log.error_message or '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="cf24-muted">아직 API 호출 기록이 없습니다.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,252 @@
|
||||
"""카페24 모듈 순수 로직 + 토큰/암호화 테스트.
|
||||
|
||||
DB/네트워크 없이 검증한다(가짜 저장소 + refresh 함수 주입).
|
||||
python -m app.modules.cafe24.tests.test_cafe24
|
||||
또는 pytest 로 실행 가능.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
|
||||
from app.integrations.cafe24 import config as cfgmod
|
||||
from app.integrations.cafe24 import crypto, oauth, tokens
|
||||
from app.integrations.cafe24.errors import Cafe24AuthError, Cafe24ConfigError
|
||||
from app.modules.cafe24 import store
|
||||
from app.timezone import now_kst
|
||||
|
||||
SECRET = "unit-test-secret"
|
||||
|
||||
_TEST_ENV = {
|
||||
"CAFE24_MALL_ID": "testmall",
|
||||
"CAFE24_CLIENT_ID": "cid",
|
||||
"CAFE24_CLIENT_SECRET": "csecret",
|
||||
"CAFE24_REDIRECT_URI": "http://localhost:8080/cafe24/oauth/callback",
|
||||
"CAFE24_TOKEN_SECRET": SECRET,
|
||||
}
|
||||
|
||||
|
||||
def _config():
|
||||
"""환경변수에 의존하지 않도록 테스트용 값을 주입해 설정을 만든다."""
|
||||
saved = {k: os.environ.get(k) for k in _TEST_ENV}
|
||||
os.environ.update(_TEST_ENV)
|
||||
try:
|
||||
return cfgmod.load_config()
|
||||
finally:
|
||||
for key, value in saved.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
class _FakeRepo:
|
||||
"""Cafe24Store 의 토큰 3개 메서드만 흉내낸다."""
|
||||
|
||||
def __init__(self, row=None):
|
||||
self.row = row
|
||||
self.saves: list[dict] = []
|
||||
|
||||
def get_token_row(self, mall_id):
|
||||
return self.row
|
||||
|
||||
def save_token_row(self, *, mall_id, **fields):
|
||||
self.saves.append(fields)
|
||||
if self.row is None:
|
||||
self.row = {"mall_id": mall_id}
|
||||
self.row.update(fields)
|
||||
|
||||
@contextmanager
|
||||
def token_lock(self, mall_id):
|
||||
outer = self
|
||||
|
||||
class Handle:
|
||||
row = outer.row
|
||||
|
||||
def save(self, **fields):
|
||||
outer.save_token_row(mall_id=mall_id, **fields)
|
||||
|
||||
yield Handle()
|
||||
|
||||
|
||||
def _row(**overrides):
|
||||
row = {
|
||||
"mall_id": "testmall",
|
||||
"access_token": crypto.encrypt("AT", secret=SECRET),
|
||||
"refresh_token": crypto.encrypt("RT", secret=SECRET),
|
||||
"access_token_expires_at": now_kst() + timedelta(hours=1),
|
||||
"refresh_token_expires_at": now_kst() + timedelta(days=13),
|
||||
"scopes": "mall.read_product,mall.write_product",
|
||||
"last_refreshed_at": now_kst(),
|
||||
"last_error": "",
|
||||
"connected_by": "king@dbxcorp.co.kr",
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 암호화
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def test_crypto_roundtrip():
|
||||
token = "ACCESS-TOKEN-한글-123"
|
||||
encrypted = crypto.encrypt(token, secret=SECRET)
|
||||
assert encrypted != token and token not in encrypted
|
||||
assert crypto.decrypt(encrypted, secret=SECRET) == token
|
||||
|
||||
|
||||
def test_crypto_empty_passthrough():
|
||||
assert crypto.encrypt("", secret=SECRET) == ""
|
||||
assert crypto.decrypt("", secret=SECRET) == ""
|
||||
|
||||
|
||||
def test_crypto_wrong_secret_raises():
|
||||
encrypted = crypto.encrypt("AT", secret=SECRET)
|
||||
try:
|
||||
crypto.decrypt(encrypted, secret="다른키")
|
||||
except Cafe24ConfigError:
|
||||
return
|
||||
raise AssertionError("키가 바뀌면 Cafe24ConfigError 가 나야 한다")
|
||||
|
||||
|
||||
def test_crypto_requires_secret():
|
||||
try:
|
||||
crypto.encrypt("x", secret="")
|
||||
except Cafe24ConfigError:
|
||||
return
|
||||
raise AssertionError("CAFE24_TOKEN_SECRET 없으면 예외여야 한다")
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 설정 / 인증 URL
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def test_config_basics():
|
||||
config = _config()
|
||||
assert config.configured
|
||||
assert config.missing == []
|
||||
assert config.api_base == "https://testmall.cafe24api.com/api/v2"
|
||||
assert config.scope_param == "mall.read_product,mall.write_product"
|
||||
|
||||
|
||||
def test_authorize_url_has_state_and_no_secret():
|
||||
url = oauth.build_authorize_url(_config(), state="STATE123")
|
||||
assert url.startswith("https://testmall.cafe24api.com/api/v2/oauth/authorize?")
|
||||
assert "state=STATE123" in url
|
||||
# client_secret 은 authorize 단계에 절대 실리면 안 된다.
|
||||
assert "csecret" not in url
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 토큰 상태 / 자동 갱신
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def test_status_without_token():
|
||||
status = tokens.TokenService(_FakeRepo(None), _config()).status()
|
||||
assert status["connected"] is False
|
||||
assert status["needs_reauth"] is True
|
||||
|
||||
|
||||
def test_status_never_leaks_token_values():
|
||||
service = tokens.TokenService(_FakeRepo(_row()), _config())
|
||||
status = service.status()
|
||||
assert status["connected"] is True
|
||||
assert "AT" not in str(status) and "RT" not in str(status)
|
||||
|
||||
|
||||
def test_valid_token_returned_without_refresh():
|
||||
service = tokens.TokenService(_FakeRepo(_row()), _config())
|
||||
assert service.get_access_token() == "AT"
|
||||
|
||||
|
||||
def test_expired_refresh_token_needs_reauth():
|
||||
row = _row(refresh_token_expires_at=now_kst() - timedelta(days=1))
|
||||
assert tokens.TokenService(_FakeRepo(row), _config()).status()["needs_reauth"] is True
|
||||
|
||||
|
||||
def test_expired_access_token_triggers_refresh():
|
||||
"""만료된 access token 은 refresh 후 새 값을 돌려주고, 저장은 암호문으로 한다."""
|
||||
repo = _FakeRepo(_row(access_token_expires_at=now_kst() - timedelta(minutes=5)))
|
||||
service = tokens.TokenService(repo, _config())
|
||||
seen: list[str] = []
|
||||
|
||||
def fake_refresh(config, *, refresh_token):
|
||||
seen.append(refresh_token)
|
||||
return oauth.TokenBundle(
|
||||
access_token="NEW-AT",
|
||||
refresh_token="NEW-RT",
|
||||
access_token_expires_at=now_kst() + timedelta(hours=2),
|
||||
refresh_token_expires_at=now_kst() + timedelta(days=14),
|
||||
scopes="mall.read_product,mall.write_product",
|
||||
)
|
||||
|
||||
original = tokens.refresh_tokens
|
||||
tokens.refresh_tokens = fake_refresh
|
||||
try:
|
||||
assert service.get_access_token() == "NEW-AT"
|
||||
finally:
|
||||
tokens.refresh_tokens = original
|
||||
|
||||
assert seen == ["RT"] # 복호화된 refresh token 이 전달돼야 한다
|
||||
saved = repo.saves[-1]
|
||||
assert saved["access_token"] != "NEW-AT" # 평문 저장 금지
|
||||
assert crypto.decrypt(saved["access_token"], secret=SECRET) == "NEW-AT"
|
||||
assert saved["last_error"] == ""
|
||||
|
||||
|
||||
def test_dead_refresh_token_raises_auth_error():
|
||||
row = _row(
|
||||
access_token_expires_at=now_kst() - timedelta(minutes=1),
|
||||
refresh_token_expires_at=now_kst() - timedelta(days=1),
|
||||
)
|
||||
service = tokens.TokenService(_FakeRepo(row), _config())
|
||||
try:
|
||||
service.get_access_token()
|
||||
except Cafe24AuthError as exc:
|
||||
assert exc.needs_reauth is True
|
||||
return
|
||||
raise AssertionError("refresh token 만료 시 Cafe24AuthError 여야 한다")
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# store.py 순수 로직
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def test_schedule_editable_only_when_pending():
|
||||
assert store.is_editable("PENDING") is True
|
||||
for locked in ("PROCESSING", "SUCCESS", "FAILED", "CANCELLED"):
|
||||
assert store.is_editable(locked) is False, locked
|
||||
|
||||
|
||||
def test_retry_budget_and_backoff():
|
||||
assert all(store.can_retry(i) for i in range(store.MAX_RETRY))
|
||||
assert store.can_retry(store.MAX_RETRY) is False
|
||||
# 무한 재시도 방지 — 간격은 증가하되 상한이 있다.
|
||||
assert store.retry_backoff_seconds(0) < store.retry_backoff_seconds(1)
|
||||
assert store.retry_backoff_seconds(99) == store.retry_backoff_seconds(2)
|
||||
|
||||
|
||||
def test_normalize_revision_type():
|
||||
assert store.normalize_revision_type("backup") == store.REVISION_BACKUP
|
||||
assert store.normalize_revision_type("nope") == store.REVISION_DRAFT
|
||||
|
||||
|
||||
def test_parse_product_no():
|
||||
assert store.parse_product_no(" 123 ") == 123
|
||||
for bad in ("abc", "0", "-3", None, ""):
|
||||
try:
|
||||
store.parse_product_no(bad)
|
||||
except ValueError:
|
||||
continue
|
||||
raise AssertionError(f"{bad!r} 는 거부해야 한다")
|
||||
|
||||
|
||||
def _run_all():
|
||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
for fn in fns:
|
||||
fn()
|
||||
print("PASS", fn.__name__)
|
||||
print(f"\n{len(fns)} tests passed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_run_all()
|
||||
Reference in New Issue
Block a user