Files
auto-stock/.claude/hooks/tests/test_hooks.py
shuim ea644a37f3 chore(harness): Claude Code 하네스 구성 — 안전 가드·규칙 검사·배포 스킬
- .claude/settings.json (공유) 신설, hooks 를 settings.local.json 에서 이동
- bash_guard (PreToolUse): 로컬 스택 기동·운영 DB alembic·_test 아닌 pytest·main 강제 push·위험 rm 차단,
  main push/deploy.sh 는 장중 경고와 함께 확인 요청, backend 커밋 전 유닛 스위트 게이트
- rules_check (PostToolUse): py_compile + CLAUDE.md 규칙(json.dumps, except 후 logger.debug,
  except-pass, 실시간 계층 DB I/O) — diff 추가 라인만
- session_context (SessionStart): 브랜치·미커밋·장 상태·alembic head 주입
- stop_check (Stop): frontend 변경 시 tsc
- auto-orchestrate: 서브에이전트 보고·시스템 알림·질문형 프롬프트 오발동 제거
- /deploy 스킬 + deploy.sh / prod_smoke.sh: 테스트→dev push→main 병합→재기동 감시→공개 API 스모크
- 팀 에이전트·orchestrate 스킬 추적 시작, settings.local.json 추적 해제(.gitignore)
- CLAUDE.md §9 하네스
- 훅 테스트 69 passed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:05:35 +09:00

283 lines
11 KiB
Python

"""하네스 훅 테스트 — 샘플 stdin JSON 으로 deny/ask/allow/exit2 를 검증한다. DB·네트워크 불필요."""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
HOOKS = Path(__file__).resolve().parents[1]
ROOT = HOOKS.parents[1]
def run(script, payload, env=None, cwd=None):
e = {**os.environ, "CLAUDE_PROJECT_DIR": str(ROOT), **(env or {})}
p = subprocess.run([sys.executable, str(HOOKS / script)], input=json.dumps(payload) if not isinstance(payload, str) else payload,
capture_output=True, text=True, env=e, cwd=cwd or ROOT, timeout=60)
return p
def bash(cmd, env=None):
p = run("bash_guard.py", {"tool_name": "Bash", "tool_input": {"command": cmd}, "cwd": str(ROOT)},
env={"DATABASE_URL": "", **(env or {})})
assert p.returncode == 0, p.stderr
if not p.stdout.strip():
return None, ""
h = json.loads(p.stdout)["hookSpecificOutput"]
return h.get("permissionDecision"), h.get("permissionDecisionReason", h.get("additionalContext", ""))
TEST_URL = "postgresql+asyncpg://x:x@localhost/autostock_test"
# ── bash_guard: deny ──
@pytest.mark.parametrize("cmd", [
"docker compose up -d --build",
"docker-compose up",
"docker compose -f docker-compose.dev.yml up backend",
"cd backend && uvicorn app.main:app --reload",
"python -m app.main",
"python3 -m uvicorn app.main:app",
"DATABASE_URL=postgresql://u:p@nas.shium.net:15433/autostock alembic upgrade head",
"export DATABASE_URL=postgresql://u:p@localhost:15433/autostock && alembic downgrade -1",
"cd backend && python -m pytest tests/",
"DATABASE_URL=postgresql://u:p@h/autostock python -m pytest tests/",
f"DATABASE_URL={TEST_URL} python -m pytest tests/ --allow-network",
"git push --force origin main",
"git push -f origin HEAD:main",
"git push origin +dev:main",
"rm -rf backend",
"rm -rf .git",
"rm -rf ./*",
"sudo rm -fr /Users/commylife/myproject/java/workspace/auto-stock",
"bash -c 'docker compose up'",
])
def test_deny(cmd):
d, reason = bash(cmd)
assert d == "deny", (cmd, d, reason)
# ── bash_guard: 통과 ──
@pytest.mark.parametrize("cmd", [
"git status",
"docker compose build",
"docker compose logs -f backend",
'git commit -m "docker compose up 금지 문서 추가"',
"echo 'uvicorn app.main:app'",
"grep -rn 'docker compose up' CLAUDE.md",
f"DATABASE_URL={TEST_URL} python -m pytest tests/ -q",
"python3 -m pytest .claude/hooks/tests -q",
"git push origin dev",
"git push --force origin dev",
"rm -rf /tmp/some-dir",
"rm -rf backend/__pycache__",
"ssh -p 2082 nas 'docker compose up -d'",
"cat <<'EOF'\ndocker compose up\nEOF",
"alembic current",
])
def test_allow(cmd):
d, _ = bash(cmd)
assert d is None, (cmd, d)
def test_alembic_test_db_ok_and_unset_asks():
assert bash(f"DATABASE_URL={TEST_URL} alembic upgrade head")[0] is None
assert bash("alembic upgrade head")[0] == "ask"
# ── bash_guard: ask (main 푸시) ──
@pytest.mark.parametrize("cmd", ["git push origin HEAD:main", "git push origin main", "bash .claude/scripts/deploy.sh -m x"])
def test_ask_main_push(cmd):
d, reason = bash(cmd, env={"HARNESS_NOW_KST": "2026-10-02T10:00"}) # 금요일 장중
assert d == "ask"
assert "장중 배포" in reason and "10:00" in reason
def test_ask_off_hours_no_warning():
d, reason = bash("git push origin HEAD:main", env={"HARNESS_NOW_KST": "2026-10-03T10:00"}) # 토요일
assert d == "ask" and "장중 배포" not in reason and "휴장-주말" in reason
def test_watch_only_not_asked():
assert bash("bash .claude/scripts/deploy.sh --watch-only")[0] is None
# ── 커밋 게이트 (pytest 를 HARNESS_PYTEST_CMD 로 모킹) ──
@pytest.fixture
def repo(tmp_path):
def g(*a):
subprocess.run(["git", *a], cwd=tmp_path, check=True, capture_output=True,
env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"})
g("init", "-q")
(tmp_path / "backend").mkdir()
(tmp_path / "backend/a.py").write_text("x=1\n")
(tmp_path / "README.md").write_text("r\n")
g("add", "."); g("commit", "-qm", "init")
return tmp_path, g
def commit_hook(repo_dir, env):
p = run("bash_guard.py", {"tool_name": "Bash", "tool_input": {"command": 'git commit -m "x"'}, "cwd": str(repo_dir)},
env={"CLAUDE_PROJECT_DIR": str(repo_dir), "DATABASE_URL": "", **env}, cwd=repo_dir)
assert p.returncode == 0, p.stderr
return json.loads(p.stdout)["hookSpecificOutput"] if p.stdout.strip() else None
def test_commit_gate_fail_denies_with_tail(repo):
d, g = repo
(d / "backend/a.py").write_text("x=2\n"); g("add", "backend/a.py")
out = commit_hook(d, {"HARNESS_PYTEST_CMD": "echo 'FAILED tests/test_x.py::t - boom'; exit 1"})
assert out["permissionDecision"] == "deny" and "boom" in out["permissionDecisionReason"]
def test_commit_gate_pass(repo):
d, g = repo
(d / "backend/a.py").write_text("x=2\n"); g("add", "backend/a.py")
assert commit_hook(d, {"HARNESS_PYTEST_CMD": "exit 0"}) is None
def test_commit_gate_skips_non_backend(repo):
d, g = repo
(d / "README.md").write_text("changed\n"); g("add", "README.md")
assert commit_hook(d, {"HARNESS_PYTEST_CMD": "exit 1"}) is None # 실행됐다면 deny 였을 것
def test_commit_gate_no_venv_warns(repo):
d, g = repo
(d / "backend/a.py").write_text("x=3\n"); g("add", "backend/a.py")
out = commit_hook(d, {}) # .venv 없음
assert out and "permissionDecision" not in out and ".venv" in out["additionalContext"]
# ── rules_check ──
@pytest.fixture
def proj(tmp_path):
def g(*a):
subprocess.run(["git", *a], cwd=tmp_path, check=True, capture_output=True,
env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"})
g("init", "-q")
for sub in ("backend/app/services/daytrading", "backend/app/services", "backend/tests"):
(tmp_path / sub).mkdir(parents=True, exist_ok=True)
old = tmp_path / "backend/app/services/old.py"
old.write_text("import json\nx = json.dumps({})\n") # 기존 위반
rt = tmp_path / "backend/app/services/daytrading/exit_service.py"
rt.write_text("async def f():\n pass\n")
g("add", "."); g("commit", "-qm", "init")
return tmp_path
def rules(proj, rel, content):
f = proj / rel
f.write_text(content)
return run("rules_check.py", {"tool_name": "Edit", "tool_input": {"file_path": str(f)}, "cwd": str(proj)},
env={"CLAUDE_PROJECT_DIR": str(proj)}, cwd=proj)
def test_rules_pre_existing_not_reported(proj):
p = rules(proj, "backend/app/services/old.py", "import json\nx = json.dumps({})\ny = 1\n")
assert p.returncode == 0, p.stderr
def test_rules_new_json_dumps(proj):
p = rules(proj, "backend/app/services/old.py", "import json\nx = json.dumps({})\ny = json.dumps([])\n")
assert p.returncode == 2 and "old.py:3" in p.stderr and "§4" in p.stderr
def test_rules_compile_error(proj):
p = rules(proj, "backend/app/services/new.py", "def (:\n")
assert p.returncode == 2 and "py_compile" in p.stderr
def test_rules_except_debug_and_pass(proj):
src = "import logging\nlogger = logging.getLogger()\ndef f():\n try:\n 1\n except ValueError as e:\n logger.debug('x')\n try:\n 1\n except Exception:\n pass\n"
p = rules(proj, "backend/app/services/new.py", src)
assert p.returncode == 2 and "logger.debug" in p.stderr and "except Exception: pass" in p.stderr
assert "new.py:7" in p.stderr and "new.py:10" in p.stderr
def test_rules_warning_level_ok(proj):
src = "import logging\nlogger = logging.getLogger()\ndef f():\n try:\n 1\n except ValueError as e:\n logger.warning('x')\n"
assert rules(proj, "backend/app/services/new.py", src).returncode == 0
def test_rules_realtime_db(proj):
rel = "backend/app/services/daytrading/exit_service.py"
bad = "async def f():\n async with AsyncSessionLocal() as s:\n await s.commit()\n"
p = rules(proj, rel, bad)
assert p.returncode == 2 and "AsyncSessionLocal(" in p.stderr and "commit()" in p.stderr
ok = "async def close_stale_positions():\n async with AsyncSessionLocal() as s:\n pass\n"
assert rules(proj, rel, ok).returncode == 0
marker = "async def g():\n # DB-READ-EXCEPTION: SELECT 전용\n async with AsyncSessionLocal() as s:\n pass\n"
assert rules(proj, rel, marker).returncode == 0
def test_rules_ignores_tests_and_non_backend(proj):
assert rules(proj, "backend/tests/test_a.py", "import json\njson.dumps(1)\n").returncode == 0
(proj / "x.py").write_text("json.dumps(1)\n")
p = run("rules_check.py", {"tool_input": {"file_path": str(proj / "x.py")}}, env={"CLAUDE_PROJECT_DIR": str(proj)})
assert p.returncode == 0
def test_hooks_tolerate_garbage_stdin():
for s in ("bash_guard.py", "rules_check.py", "stop_check.py", "auto-orchestrate.py", "session_context.py"):
assert run(s, "not json{{{").returncode == 0
# ── session_context ──
def test_session_context():
p = run("session_context.py", {}, env={"HARNESS_NOW_KST": "2026-10-02T10:00"})
assert p.returncode == 0
ctx = json.loads(p.stdout)["hookSpecificOutput"]["additionalContext"]
assert "장중" in ctx and "alembic head" in ctx and "금지" in ctx and "/deploy" in ctx and "pytest" in ctx
# ── stop_check ──
def test_stop_active_passes():
assert run("stop_check.py", {"stop_hook_active": True}).returncode == 0
def test_stop_tsc_fail_then_stamp(proj, tmp_path):
(proj / "frontend/src").mkdir(parents=True)
(proj / "frontend/src/a.ts").write_text("x\n")
st = tmp_path / "state"; st.mkdir()
env = {"CLAUDE_PROJECT_DIR": str(proj), "HARNESS_STATE_DIR": str(st), "HARNESS_TSC_CMD": "echo 'a.ts(1,1): error TS1'; exit 2"}
p = run("stop_check.py", {}, env=env, cwd=proj)
assert p.returncode == 2 and "TS1" in p.stderr
env["HARNESS_TSC_CMD"] = "exit 0"
assert run("stop_check.py", {}, env=env, cwd=proj).returncode == 0
env["HARNESS_TSC_CMD"] = "exit 2" # stamp 일치 → tsc 재실행 안 함
assert run("stop_check.py", {}, env=env, cwd=proj).returncode == 0
# ── auto-orchestrate ──
def orch(prompt):
p = run("auto-orchestrate.py", {"prompt": prompt})
assert p.returncode == 0
return bool(p.stdout.strip())
@pytest.mark.parametrize("prompt,expected", [
("데이트레이딩 갭 필터 추가해줘", True),
("스윙 청산 버그 고쳐줘", True),
("손절 로직 수정 #직접", False),
("Another Claude session sent a message: 수정 완료", False),
("<agent-message>구현 끝 수정</agent-message>", False),
("[Subagent hand-back] 변경 파일 목록 수정", False),
("<task-notification>build 수정</task-notification>", False),
("[SYSTEM NOTIFICATION] 배포 완료", False),
("BULL 진입 RSI 수정했어?", False),
("이 로그 분석해서 알려줘", False),
("배포 상태 알려줘", False),
("/deploy", False),
])
def test_orchestrate(prompt, expected):
assert orch(prompt) is expected
# ── 스크립트 문법 ──
@pytest.mark.parametrize("sh", ["deploy.sh", "prod_smoke.sh"])
def test_scripts_syntax(sh):
assert subprocess.run(["bash", "-n", str(ROOT / ".claude/scripts" / sh)]).returncode == 0