fix(dashboard): address code review — auth, DB stability, idempotency, UI fixes

C1: /api/reset에 API key 인증 추가 (DASHBOARD_RESET_KEY 환경변수)
C2: /proc 스캐닝 제거, PID file + SIGHUP 기반 파서 재파싱으로 교체
C3: daily_pnl 업데이트를 trades 테이블에서 재계산하여 idempotent하게 변경
I1: CORS origins를 CORS_ORIGINS 환경변수로 설정 가능하게 변경
I2: offset 파라미터에 ge=0 검증 추가
I3: 매 줄 commit → 파일 단위 배치 commit으로 성능 개선
I4: _pending_candles 크기 제한으로 메모리 누적 방지
I5: bot.log glob 중복 파싱 제거 (sorted(set(...)))
I6: /api/health 에러 메시지에서 내부 경로 미노출
I7: RSI 차트(데이터 없음)를 OI 변화율 차트로 교체
M1: pnlColor 변수 shadowing 수정 (posPnlColor)
M2: 거래 목록에 API total 필드 사용
M3: dashboard/ui/.dockerignore 추가
M4: API Dockerfile Python 3.11→3.12
M5: 테스트 fixture에서 temp DB cleanup 추가
M6: 누락 테스트 9건 추가 (health, daily, reset 인증, offset, pagination)
M7: 파서 SIGTERM graceful shutdown + entrypoint.sh signal forwarding
DB: 양쪽 busy_timeout=5000 + WAL pragma 설정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
21in7
2026-03-20 00:00:16 +09:00
parent f14c521302
commit 9f0057e29d
7 changed files with 253 additions and 66 deletions

View File

@@ -6,10 +6,11 @@ import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "dashboard", "api"))
# DB_PATH를 테스트용 임시 파일로 설정 (import 전에)
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
os.environ["DB_PATH"] = _tmp_db.name
_tmp_db.close()
# DB_PATH와 DASHBOARD_RESET_KEY를 테스트용으로 설정 (import 전에)
_tmp_dir = tempfile.mkdtemp()
_tmp_db_path = os.path.join(_tmp_dir, "test_dashboard.db")
os.environ["DB_PATH"] = _tmp_db_path
os.environ["DASHBOARD_RESET_KEY"] = "test-reset-key"
import dashboard_api # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
@@ -90,9 +91,18 @@ def setup_db():
"INSERT INTO candles(symbol,ts,price,signal) VALUES(?,?,?,?)",
("TRXUSDT", "2026-03-06 00:00:00", 0.23, "SHORT"),
)
conn.execute(
"INSERT INTO daily_pnl(symbol,date,cumulative_pnl,trade_count,wins,losses) VALUES(?,?,?,?,?,?)",
("TRXUSDT", "2026-03-05", 10.0, 1, 1, 0),
)
conn.commit()
conn.close()
yield
# cleanup
try:
os.unlink(db_path)
except OSError:
pass
client = TestClient(dashboard_api.app)
@@ -122,8 +132,10 @@ def test_get_position_by_symbol():
def test_get_trades_by_symbol():
r = client.get("/api/trades?symbol=TRXUSDT")
assert r.status_code == 200
assert len(r.json()["trades"]) == 1
assert r.json()["trades"][0]["symbol"] == "TRXUSDT"
data = r.json()
assert len(data["trades"]) == 1
assert data["trades"][0]["symbol"] == "TRXUSDT"
assert data["total"] == 1
def test_get_candles_by_symbol():
@@ -142,3 +154,77 @@ def test_get_stats_by_symbol():
r = client.get("/api/stats?symbol=TRXUSDT")
assert r.status_code == 200
assert r.json()["total_trades"] == 1
# ── M6: 누락된 테스트 추가 ──────────────────────────────────────
def test_health():
r = client.get("/api/health")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
assert data["candles_count"] >= 0
def test_daily():
r = client.get("/api/daily?symbol=TRXUSDT")
assert r.status_code == 200
data = r.json()
assert len(data["daily"]) == 1
assert data["daily"][0]["net_pnl"] == 10.0
def test_daily_all():
r = client.get("/api/daily")
assert r.status_code == 200
assert "daily" in r.json()
def test_reset_requires_api_key():
"""C1: API key 없이 reset 호출 시 403."""
r = client.post("/api/reset")
assert r.status_code == 403
def test_reset_wrong_api_key():
"""C1: 잘못된 API key로 reset 호출 시 403."""
r = client.post("/api/reset", headers={"X-API-Key": "wrong-key"})
assert r.status_code == 403
def test_reset_with_valid_key():
"""C1+C2: 올바른 API key로 reset 호출 시 성공."""
r = client.post("/api/reset", headers={"X-API-Key": "test-reset-key"})
assert r.status_code == 200
assert r.json()["status"] == "ok"
# DB가 비워졌는지 확인
r2 = client.get("/api/trades")
assert r2.json()["total"] == 0
def test_trades_offset_validation():
"""I2: 음수 offset은 422 에러."""
r = client.get("/api/trades?offset=-1")
assert r.status_code == 422
def test_trades_pagination():
"""M6: 페이지네이션 동작 확인."""
r = client.get("/api/trades?limit=1&offset=0")
assert r.status_code == 200
data = r.json()
assert len(data["trades"]) <= 1
assert "total" in data
def test_health_error_no_detail_leak():
"""I6: health에서 에러 시 내부 경로 미노출."""
# 일시적으로 DB 경로를 존재하지 않는 곳으로 설정
original = dashboard_api.DB_PATH
dashboard_api.DB_PATH = "/nonexistent/path/db.sqlite"
r = client.get("/api/health")
dashboard_api.DB_PATH = original
data = r.json()
assert data["status"] == "error"
assert "/nonexistent" not in data.get("detail", "")