운영 번들 자동 로그인 자격증명 유출, 온보딩 COPY 잡이 Gemini 429 로 죽던 것, 크롤링 실패가 로그에만 남던 것을 한 번에 정리한다. 실측(2026-09-15 밤, 킹서버): 사진분석 배치가 Gemini 분당 쿼터를 다 써서 같은 키를 쓰는 온보딩 COPY 잡도 같이 429 를 맞고 DEAD 로 갔다 — 확인된 fact 만으로도 편집·발행이 되는데 잡을 죽일 이유가 없었다. - solution/frontend: `VITE_AUTO_LOGIN_ID`·`PW` 를 운영 진입점에 안 넘긴다(자동 로그인은 dev 서버 전용) + `Step5Generating` 겉모습을 이전 카드 스타일로, 데이터는 실제 잡 진행(useGenerationJob) 그대로 - solution/backend: copy_service — Gemini 호출 실패해도 잡을 안 죽이고 fact 만으로 계속. db_session_manager — 유니크 제약 충돌(정상 경로) 로그를 ERROR → WARN. worker/runner + alert_service + teams_webhook — 잡 dead-letter·발행 실패·큐 정체를 Teams 로 알림(영구 저장 + 재시도 + dedupe). `/readyz` 추가. collect_diagnostics(신규) — 크롤링 채널별 실패를 jobs.result 에 구조화해서 싣는다. - postgres-init: 0015(users token_version) · 0016(alert_outbox) 마이그레이션 검증: 백엔드 pytest 759 passed. tsc(solution/frontend) 통과. Teams 알림 실채널 수신 확인.
207 lines
9.3 KiB
Python
207 lines
9.3 KiB
Python
"""알림 발송함 — 적재(dedupe) · 발송(재시도/소진) · 복구 · 비밀 마스킹.
|
|
|
|
★ 이 파일이 절대 하면 안 되는 것 확인:
|
|
- 재시도마다 중복 스팸을 내는 것 (dedupe)
|
|
- webhook URL·비밀번호·이메일 원문을 detail 에 그대로 남기는 것 (scrub)
|
|
- TEAMS_WEBHOOK_URL 이 비었을 때 예외를 던지는 것 (미설정 시 정상 동작)
|
|
"""
|
|
import json
|
|
|
|
import httpx
|
|
from sqlalchemy import text
|
|
|
|
from services import alert_service, teams_webhook
|
|
|
|
|
|
def _mock_webhook(monkeypatch, handler):
|
|
"""teams_webhook 의 실제 HTTP 호출을 대역으로 바꾼다(네트워크를 타지 않는다)."""
|
|
real_client = httpx.AsyncClient
|
|
|
|
def make_client(**kw):
|
|
return real_client(transport=httpx.MockTransport(handler), **kw)
|
|
|
|
monkeypatch.setattr(teams_webhook.httpx, "AsyncClient", make_client)
|
|
|
|
|
|
async def _rows(db_engine, kind: str | None = None):
|
|
async with db_engine.begin() as c:
|
|
sql = "SELECT kind, dedupe_key, title, detail, status, attempts, resolved_at FROM alert_outbox"
|
|
params = {}
|
|
if kind:
|
|
sql += " WHERE kind = :k"
|
|
params["k"] = kind
|
|
sql += " ORDER BY created_at"
|
|
result = await c.execute(text(sql), params)
|
|
return [dict(r._mapping) for r in result]
|
|
|
|
|
|
# ── 적재 · 중복 억제 ─────────────────────────────────────────────────────────
|
|
async def test_send_alert_creates_pending_row(db_engine):
|
|
await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k1")
|
|
rows = await _rows(db_engine, "job_dead")
|
|
assert len(rows) == 1
|
|
assert rows[0]["status"] == 1 # AlertStatus.PENDING
|
|
assert rows[0]["dedupe_key"] == "k1"
|
|
|
|
|
|
async def test_send_alert_dedupes_within_window(db_engine):
|
|
"""검증: 같은 dedupe_key 로 두 번 연속 보낸다.
|
|
기대결과: ★ 행이 하나만 생긴다 — 재시도마다 중복 스팸을 내면 안 된다."""
|
|
await alert_service.send_alert("build_failed", "발행 실패", "1차", dedupe_key="k2")
|
|
await alert_service.send_alert("build_failed", "발행 실패", "2차", dedupe_key="k2")
|
|
rows = await _rows(db_engine, "build_failed")
|
|
assert len(rows) == 1
|
|
assert rows[0]["detail"] == "1차" # 처음 것만 남는다(두 번째는 만들지 않았다)
|
|
|
|
|
|
async def test_send_alert_without_dedupe_key_always_creates(db_engine):
|
|
"""dedupe_key 가 없으면(예: 복구 알림) 매번 새로 쌓인다."""
|
|
await alert_service.send_alert("recovery", "복구", "a")
|
|
await alert_service.send_alert("recovery", "복구", "b")
|
|
rows = await _rows(db_engine, "recovery")
|
|
assert len(rows) == 2
|
|
|
|
|
|
# ── 복구 ────────────────────────────────────────────────────────────────────
|
|
async def test_resolve_alert_marks_resolved_and_sends_recovery_notice(db_engine):
|
|
await alert_service.send_alert("queue_stuck", "정체", "사유", dedupe_key="k3")
|
|
await alert_service.resolve_alert("k3", "정상으로 돌아옴")
|
|
|
|
original = await _rows(db_engine, "queue_stuck")
|
|
assert original[0]["resolved_at"] is not None
|
|
|
|
recovered = await _rows(db_engine, "recovery")
|
|
assert len(recovered) == 1
|
|
assert recovered[0]["title"] == "정상으로 돌아옴"
|
|
|
|
|
|
async def test_resolve_alert_is_noop_when_nothing_unresolved(db_engine):
|
|
"""검증: 알린 적 없는 dedupe_key 를 resolve.
|
|
기대결과: ★ 아무 행도 안 생긴다 — 정상 상태마다 "복구됨" 을 보내면 그게 새 스팸이다."""
|
|
await alert_service.resolve_alert("never-alerted", "정상")
|
|
rows = await _rows(db_engine, "recovery")
|
|
assert rows == []
|
|
|
|
|
|
async def test_send_alert_after_resolve_creates_new_row(db_engine):
|
|
"""검증: 한 번 풀린(resolved) dedupe_key 로 다시 보낸다.
|
|
기대결과: 새 문제로 보고 새 행을 만든다 — 옛 resolved 행과 헷갈리지 않는다."""
|
|
await alert_service.send_alert("build_failed", "실패1", "x", dedupe_key="k4")
|
|
await alert_service.resolve_alert("k4", "복구1")
|
|
await alert_service.send_alert("build_failed", "실패2", "y", dedupe_key="k4")
|
|
|
|
rows = await _rows(db_engine, "build_failed")
|
|
assert len(rows) == 2
|
|
assert rows[1]["detail"] == "y"
|
|
assert rows[1]["resolved_at"] is None
|
|
|
|
|
|
# ── 비밀·개인정보 마스킹 ──────────────────────────────────────────────────────
|
|
def test_scrub_redacts_query_string_secrets():
|
|
out = alert_service._scrub("https://api.example.com/x?api_key=SECRET123&q=hi")
|
|
assert "SECRET123" not in out
|
|
assert "api_key=***" in out
|
|
|
|
|
|
def test_scrub_redacts_bearer_token():
|
|
out = alert_service._scrub("Authorization: Bearer abcdef1234567890")
|
|
assert "abcdef1234567890" not in out
|
|
assert "Bearer ***" in out
|
|
|
|
|
|
def test_scrub_redacts_password_kv():
|
|
out = alert_service._scrub("login failed password=hunter2hunter2")
|
|
assert "hunter2hunter2" not in out
|
|
|
|
|
|
def test_scrub_masks_email():
|
|
out = alert_service._scrub("owner email: someone@example.com failed")
|
|
assert "someone@example.com" not in out
|
|
assert "@***" in out
|
|
|
|
|
|
def test_scrub_truncates_long_detail():
|
|
out = alert_service._scrub("x" * 5000)
|
|
assert len(out) <= 2000
|
|
|
|
|
|
# ── 발송 · 재시도 · 소진 ──────────────────────────────────────────────────────
|
|
async def test_process_outbox_sends_and_marks_sent(db_engine, monkeypatch):
|
|
monkeypatch.setenv("TEAMS_WEBHOOK_URL", "https://example.test/webhook")
|
|
received = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
received.append(json.loads(request.content))
|
|
return httpx.Response(202)
|
|
|
|
_mock_webhook(monkeypatch, handler)
|
|
|
|
await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k5")
|
|
result = await alert_service.process_outbox()
|
|
|
|
assert result == {"sent": 1, "failed": 0}
|
|
assert len(received) == 1
|
|
rows = await _rows(db_engine, "job_dead")
|
|
assert rows[0]["status"] == 2 # AlertStatus.SENT
|
|
|
|
|
|
async def test_process_outbox_noop_when_webhook_unconfigured(db_engine, monkeypatch):
|
|
"""검증: TEAMS_WEBHOOK_URL 이 비어 있을 때 스윕을 돌린다.
|
|
기대결과: ★ 예외 없이 끝난다 — HTTP 호출 자체를 안 한다(teams_webhook.is_configured)."""
|
|
monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False)
|
|
await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k6")
|
|
|
|
result = await alert_service.process_outbox()
|
|
assert result["sent"] == 0
|
|
# 실패로 잡혀 재시도 카운트가 올라간다(다음 스윕에서 다시 시도) — 예외로 죽지 않았다.
|
|
rows = await _rows(db_engine, "job_dead")
|
|
assert rows[0]["status"] == 1 # 여전히 PENDING(백오프 대기)
|
|
assert rows[0]["attempts"] == 1
|
|
|
|
|
|
async def test_process_outbox_exhausts_after_max_attempts(db_engine, monkeypatch):
|
|
"""검증: 계속 실패하는 webhook 으로 MAX_ATTEMPTS 만큼 스윕한다.
|
|
기대결과: ★ 상한에 닿으면 FAILED 로 남고 더는 재시도 대상이 아니다."""
|
|
monkeypatch.setenv("TEAMS_WEBHOOK_URL", "https://example.test/webhook")
|
|
_mock_webhook(monkeypatch, lambda req: httpx.Response(500))
|
|
|
|
await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k7")
|
|
for _ in range(alert_service.MAX_ATTEMPTS):
|
|
# next_attempt_at 이 미래로 밀려도 여기선 process_outbox 가 직접 대상을 스윕하므로
|
|
# 시간 경과를 흉내 낼 필요 없이 next_attempt_at 을 매번 과거로 되돌린다.
|
|
async with db_engine.begin() as c:
|
|
await c.execute(text("UPDATE alert_outbox SET next_attempt_at = now() - interval '1 second'"))
|
|
await alert_service.process_outbox()
|
|
|
|
rows = await _rows(db_engine, "job_dead")
|
|
assert rows[0]["status"] == 3 # AlertStatus.FAILED(소진)
|
|
assert rows[0]["attempts"] == alert_service.MAX_ATTEMPTS
|
|
|
|
|
|
# ── Teams 카드 모양 ───────────────────────────────────────────────────────────
|
|
async def test_teams_webhook_sends_adaptive_card(monkeypatch):
|
|
monkeypatch.setenv("TEAMS_WEBHOOK_URL", "https://example.test/webhook")
|
|
received = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
received.append(json.loads(request.content))
|
|
return httpx.Response(202)
|
|
|
|
_mock_webhook(monkeypatch, handler)
|
|
ok = await teams_webhook.send("제목", "내용")
|
|
assert ok is True
|
|
card = received[0]["attachments"][0]
|
|
assert card["contentType"] == "application/vnd.microsoft.card.adaptive"
|
|
|
|
|
|
async def test_teams_webhook_refuses_non_https(monkeypatch):
|
|
monkeypatch.setenv("TEAMS_WEBHOOK_URL", "http://not-secure.test/webhook")
|
|
ok = await teams_webhook.send("제목", "내용")
|
|
assert ok is False
|
|
|
|
|
|
async def test_teams_webhook_noop_when_unset(monkeypatch):
|
|
monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False)
|
|
ok = await teams_webhook.send("제목", "내용")
|
|
assert ok is False
|