Site Icon THEREiS최신 글 게시판 · 015번 읽는 중

제 목 :

우리집 비서 만들기 프로젝트 #9 - 서버편: 이벤트와 가짜 모듈 테스트

올린이
[야훔]
날짜
태그

8편까지는 허브가 모듈을 부르는 쪽이었다. 이번에는 반대로 모듈이 먼저 허브에 알려야 할 때를 만든다.

그리고 진짜 모듈이 아직 하나도 없으니, 약속대로만 대답하는 가짜 모듈을 붙여서 약속, 명령, 승인, 이벤트가 다 맞물리는지 테스트한다.

Step 1. 모듈이 먼저 알려야 할 때 (이벤트)

지금까지는 전부 “클라이언트가 시키면 모듈이 한다”였다. 그런데 반대 방향도 있다. 카카오톡 알림이 왔다, 대화 요약이 끝났다. 이런 건 모듈이 먼저 알려야 한다. 그래서 모듈이 허브에 소식을 보내는 길(이벤트 콜백)을 하나 열었다.

services/hub/app/events.py
"""모듈이 허브에 알려 오는 이벤트 (카카오 알림이 왔다, 요약이 끝났다 ...).
허브는 이벤트를 짧게 보관만 한다. 클라이언트가 GET /v1/events 로 가져간다.
제목만 남기고 본문 같은 민감한 내용은 받지 않는다.
"""
import time
from dataclasses import asdict, dataclass
from .db import Database
KEEP_SECONDS = 7 * 24 * 3600
@dataclass
class Event:
"""이벤트 하나. user가 None이면 가족 모두에게 보인다."""
id: int
module: str
type: str
user: str | None
title: str
created_at: int
def public(self) -> dict:
"""API 응답용 사전(dict)."""
return asdict(self)
class EventStore:
"""이벤트 보관함. 넣을 때마다 7일 지난 것을 지운다."""
def __init__(self, db: Database):
"""DB를 받는다. 테이블은 db.py의 SCHEMA가 만든다."""
self.db = db
def add(self, module: str, type_: str, user: str | None, title: str) -> int:
"""이벤트를 저장하고 번호를 돌려준다."""
now = int(time.time())
with self.db.tx() as c:
c.execute("DELETE FROM events WHERE created_at < ?", (now - KEEP_SECONDS,))
cur = c.execute(
"INSERT INTO events (module, type, user, title, created_at) VALUES (?, ?, ?, ?, ?)",
(module, type_, user, title, now),
)
return cur.lastrowid
def list_for(self, user_id: str, after: int = 0, limit: int = 50) -> list[Event]:
"""이 사람에게 보이는 이벤트(본인 것 + 모두에게 보이는 것)를 after 번호 다음부터 돌려준다.
클라이언트는 마지막으로 받은 id를 기억했다가 after로 넘기면 새 이벤트만 받는다.
"""
with self.db.tx() as c:
rows = c.execute(
"SELECT * FROM events WHERE (user = ? OR user IS NULL) AND id > ? ORDER BY id LIMIT ?",
(user_id, after, limit),
).fetchall()
return [Event(**dict(r)) for r in rows]
services/hub/app/routers/events.py
"""이벤트: 모듈 → 허브 (POST /internal/events), 허브 → 클라이언트 (GET /v1/events)."""
import hmac
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from ..auth.guard import Caller, require
from ..auth.scopes import SYSTEM_READ
router = APIRouter(prefix="/v1", tags=["events"])
internal = APIRouter(prefix="/internal", include_in_schema=False)
NOT_FOUND = HTTPException(status.HTTP_404_NOT_FOUND, detail="Not Found")
class EventIn(BaseModel):
"""모듈이 보내는 이벤트. 제목만 받는다(200자까지)."""
type: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]{0,39}$")
user: str | None = Field(default=None, pattern=r"^[a-z0-9][a-z0-9-]{1,23}$")
title: str = Field(min_length=1, max_length=200)
def module_from_token(request: Request) -> str:
"""내부 토큰으로 어느 모듈이 보낸 요청인지 알아낸다.
Cloudflare를 거쳐 온 요청(Cf- 헤더가 붙어 있음)은 바깥에서 온 것이라 없는 경로처럼 404.
토큰이 어느 모듈과도 맞지 않으면 401. 토큰 비교는 상수 시간 비교(compare_digest).
"""
if request.headers.get("Cf-Connecting-Ip") or request.headers.get("Cf-Access-Jwt-Assertion"):
raise NOT_FOUND
auth = request.headers.get("Authorization", "")
token = auth[len("Bearer "):] if auth.startswith("Bearer ") else ""
for name, (_, expected) in request.app.state.catalog.endpoints.items():
if token and hmac.compare_digest(expected, token):
return name
request.app.state.audit.write("internal.auth_fail", ip=request.client.host if request.client else None)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="인증에 실패했습니다.")
@internal.post("/events", status_code=status.HTTP_201_CREATED)
async def receive_event(event: EventIn, request: Request, module: str = Depends(module_from_token)):
"""모듈이 보낸 이벤트를 저장한다. 감사 로그에는 제목을 남기지 않는다(개인 메시지일 수 있다)."""
state = request.app.state
if event.user is not None and state.registry.user(event.user) is None:
raise HTTPException(422, detail="없는 사용자입니다.")
event_id = state.events.add(module, event.type, event.user, event.title)
state.audit.write("event.receive", user=event.user, module=module, type=event.type, event_id=event_id)
return {"id": event_id}
@router.get("/events")
async def list_events(request: Request, after: int = Query(0, ge=0),
caller: Caller = Depends(require(SYSTEM_READ))):
"""내게 온 이벤트와 가족 모두에게 온 이벤트. after에 마지막으로 받은 id를 넣으면 새것만 온다."""
items = request.app.state.events.list_for(caller.user.id, after)
return {"items": [e.public() for e in items]}
  • 모듈은 자기 토큰으로 /internal/events에 보낸다. 허브는 토큰을 보고 어느 모듈인지 알아낸다.
  • 바깥에서는 이 길이 없는 것처럼 404. Cloudflare를 거쳐 온 요청에는 Cf-Connecting-Ip 같은 헤더가 붙는다. 이 헤더가 있으면 토큰이 맞아도 404다. 내부 네트워크에서만 쓰라는 뜻이다.
  • 제목만 받는다. 200자까지. 메시지 본문 같은 건 받지 않고, 감사 로그에는 제목도 남기지 않는다. 개인 메시지일 수 있으니까.
  • 클라이언트는 GET /v1/events?after=<마지막 id>로 새 소식만 가져간다. 7일이 지난 이벤트는 지운다.

아직 이벤트를 보내는 모듈은 없다. 하지만 결국 서비스 모듈 들을 만들다보면 결국 필요해질 서비스다. 그러니 미리 만들어 두자.

Step 2. 테스트: 가짜 모듈로

허브 테스트에는 진짜 모듈이 없다. 대신 약속대로만 대답하는 가짜 모듈을 만들었다. 허브가 약속을 잘 지키는지만 보면 되니까. 승인이 필요한 send, 바로 실행되는 ping, 관리자만 쓰는 secret 세 액션이 있고, 늘 500을 돌려주는 꺼진 모듈 down도 있다.

services/hub/tests/fake_module.py
"""테스트용 가짜 모듈. 허브는 모듈 코드를 모르니, 계약(docs/module-contract.md)대로만 답하면 된다.
demo 모듈
send 승인 필요. to 가 @family.example 이 아니면 422
ping 승인 없이 바로 실행
secret 관리자(admin)만
down 모듈: 항상 500 (꺼진 모듈 흉내)
"""
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
TOKEN = "d" * 40
MANIFEST = {
"name": "demo",
"title": "데모",
"actions": {
"send": {"title": "보내기", "approval": True},
"ping": {"title": "핑"},
"secret": {"title": "비밀", "roles": ["admin"]},
},
}
def build():
"""가짜 모듈 앱과, 실행된 요청을 모아 두는 리스트를 돌려준다."""
app = FastAPI()
executed: list[dict] = []
def authorized(request: Request) -> bool:
return request.headers.get("Authorization") == f"Bearer {TOKEN}"
@app.get("/demo/manifest")
async def manifest(request: Request):
if not authorized(request):
return JSONResponse({"message": "unauthorized"}, 401)
return MANIFEST
@app.post("/demo/actions/{action}/validate")
async def validate(action: str, request: Request):
if not authorized(request):
return JSONResponse({"message": "unauthorized"}, 401)
body = await request.json()
to = body["params"].get("to", "")
if action == "send" and not to.endswith("@family.example"):
return JSONResponse({"message": "허용되지 않은 수신자가 있습니다."}, 422)
return {"summary": f"{action} → {to}"}
@app.post("/demo/actions/{action}")
async def execute(action: str, request: Request):
if not authorized(request):
return JSONResponse({"message": "unauthorized"}, 401)
body = await request.json()
if body["params"].get("boom"):
return JSONResponse({"message": "boom"}, 500)
executed.append({"action": action, **body})
return {"result": f"{action} 완료"}
@app.api_route("/down/{path:path}", methods=["GET", "POST"])
async def down(path: str):
return JSONResponse({"message": "down"}, 500)
return app, executed

테스트 준비(fixture)는 6편에서 본 그대로다. 세 식구(관리자 아빠, 어른 가족 엄마, 아이 첫째)와 기기들을 만들고, httpx.ASGITransport로 가짜 모듈을 허브에 붙인다.

승인 흐름 전체를 따라가는 테스트다. 아빠 안경이 요청하고, 아빠 워치가 승인하고, 아빠 폰이 한 번 더 승인을 눌러도 실행은 한 번만 돼야 한다. 모듈에 “누가 시켰는지”가 제대로 넘어가는지도 본다.

def test_approval_flow(env):
client, _, _, d, executed = env
r = request_send(client, d["dad_glasses"])
assert r.status_code == 202
approval = r.json()["approval"]
assert approval["rule"] == "same_user" and approval["action"] == "demo.send"
assert approval["summary"] == "아빠: send → grandma@family.example"
assert executed == [] # 아직 실행 안 됨
assert pending_ids(client, d["dad_watch"]) == [approval["id"]]
r = call(client, d["dad_watch"], "GET", "/v1/approvals")
assert "params" not in r.json()["items"][0] # 요약만 노출
r = call(client, d["dad_watch"], "POST", f"/v1/approvals/{approval['id']}/approve")
assert r.status_code == 200
assert r.json()["approval"]["status"] == "approved"
assert r.json()["approval"]["result"] == "send 완료"
assert len(executed) == 1
assert executed[0]["params"] == {"to": "grandma@family.example"}
assert executed[0]["caller"]["name"] == "아빠"
# 두 번 승인해도 한 번만 실행
assert call(client, d["dad_phone"], "POST", f"/v1/approvals/{approval['id']}/approve").status_code == 409
assert len(executed) == 1

가족끼리는 서로의 요청이 보이지 않아야 한다. 관리자인 아빠에게도 엄마의 요청은 보이지 않는다.

def test_family_cannot_see_or_approve_each_other(env):
client, _, _, d, executed = env
mom_req = request_send(client, d["mom_phone"]).json()["approval"]["id"]
# 관리자인 아빠에게도 엄마의 요청은 보이지 않고, 승인하려 하면 "없는 요청"과 똑같이 404
assert mom_req not in pending_ids(client, d["dad_watch"])
assert call(client, d["dad_watch"], "POST", f"/v1/approvals/{mom_req}/approve").status_code == 404
# 엄마는 자기 워치로 승인
assert pending_ids(client, d["mom_watch"]) == [mom_req]
assert call(client, d["mom_watch"], "POST", f"/v1/approvals/{mom_req}/approve").status_code == 200
assert executed[0]["caller"]["name"] == "엄마"

모듈이 “관리자만”이라고 적은 액션은 아이도, 엄마도 못 쓴다.

def test_manifest_roles_enforced(env):
client, _, _, d, executed = env
assert command(client, d["kid_phone"], "secret").status_code == 403
assert command(client, d["mom_phone"], "secret").status_code == 403
assert command(client, d["dad_phone"], "secret").status_code == 200
assert [e["action"] for e in executed] == ["secret"]
테스트 확인하는 것
test_manifest_roles_enforced manifest의 roles대로 관리자만 실행
test_unknown_module_or_action 없는 모듈, 없는 액션은 404. 권한 없는 기기에는 똑같이 403
test_module_down 꺼진 모듈은 503
test_list_modules 쓸 수 있는 액션만 보인다. 아이에게 관리자 전용 액션은 안 보임
test_command_without_approval_runs_now 승인 없는 액션은 바로 실행. 모듈에 Secret은 안 넘어감
test_module_validation_error_shown 모듈이 거절하면 그 메시지로 422, 실행 안 됨
test_approval_flow 요청 → 내 다른 기기 승인 → 한 번만 실행
test_failed_execution_marked_failed 모듈 실행이 실패하면 502, 승인 상태는 failed
test_requester_device_cannot_self_approve 요청한 기기는 승인 못 함
test_family_cannot_see_or_approve_each_other 다른 가족의 요청은 안 보이고 404
test_child_request_needs_admin 아이 요청은 관리자만 승인
test_reject_flow 거절하면 실행 안 됨
test_audit_log_has_no_secrets_or_params 감사 로그에 Secret, 입력값 없음, 사람은 남음
test_module_event_reaches_user 이벤트는 본인과, 모두에게 보낸 것만 보인다
test_internal_endpoint_rejects_bad_token_and_tunnel 틀린 토큰은 401, 터널을 거친 요청은 404
터미널
$ cd services/hub && pytest -q
.......................................... [100%]
42 passed in 1.86s

6편까지 만든 인증 쪽까지 합쳐서 허브 테스트 42개가 전부 통과. 진짜 모듈 없이도, 허브가 약속을 지키는지는 이걸로 확인된다.

정리하면

  • 이벤트: 모듈이 먼저 알려야 할 일은 /internal/events로. 바깥에서는 막혀 있다
  • 테스트: 진짜 모듈 없이, 약속대로만 대답하는 가짜 모듈로. 허브 테스트 42개 전부 통과

이걸로 허브의 코드는 다 만들었다. 다음 10편에서는 NAS에 올리기 위한 Dockerfile, docker-compose.yml, Jenkinsfile을 만들고, 이 소스를 다른 집에서도 쓸 수 있게 정리한다.

그럼 안녕!

[ 꼬리말 ]승인 후 게시됩니다

꼬리말을 불러오는 중...