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

8편까지는 허브가 모듈을 부르는 쪽이었다. 이번에는 반대로 모듈이 먼저 허브에 알려야 할 때를 만든다.
그리고 진짜 모듈이 아직 하나도 없으니, 약속대로만 대답하는 가짜 모듈을 붙여서 약속, 명령, 승인, 이벤트가 다 맞물리는지 테스트한다.
Step 1. 모듈이 먼저 알려야 할 때 (이벤트)
지금까지는 전부 “클라이언트가 시키면 모듈이 한다”였다. 그런데 반대 방향도 있다. 카카오톡 알림이 왔다, 대화 요약이 끝났다. 이런 건 모듈이 먼저 알려야 한다. 그래서 모듈이 허브에 소식을 보내는 길(이벤트 콜백)을 하나 열었다.
"""모듈이 허브에 알려 오는 이벤트 (카카오 알림이 왔다, 요약이 끝났다 ...).
허브는 이벤트를 짧게 보관만 한다. 클라이언트가 GET /v1/events 로 가져간다.제목만 남기고 본문 같은 민감한 내용은 받지 않는다."""import timefrom dataclasses import asdict, dataclass
from .db import Database
KEEP_SECONDS = 7 * 24 * 3600
@dataclassclass 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]"""이벤트: 모듈 → 허브 (POST /internal/events), 허브 → 클라이언트 (GET /v1/events)."""import hmac
from fastapi import APIRouter, Depends, HTTPException, Query, Request, statusfrom pydantic import BaseModel, Field
from ..auth.guard import Caller, requirefrom ..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도 있다.
"""테스트용 가짜 모듈. 허브는 모듈 코드를 모르니, 계약(docs/module-contract.md)대로만 답하면 된다.
demo 모듈 send 승인 필요. to 가 @family.example 이 아니면 422 ping 승인 없이 바로 실행 secret 관리자(admin)만 down 모듈: 항상 500 (꺼진 모듈 흉내)"""from fastapi import FastAPI, Requestfrom 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.86s6편까지 만든 인증 쪽까지 합쳐서 허브 테스트 42개가 전부 통과. 진짜 모듈 없이도, 허브가 약속을 지키는지는 이걸로 확인된다.
정리하면
- 이벤트: 모듈이 먼저 알려야 할 일은
/internal/events로. 바깥에서는 막혀 있다 - 테스트: 진짜 모듈 없이, 약속대로만 대답하는 가짜 모듈로. 허브 테스트 42개 전부 통과
이걸로 허브의 코드는 다 만들었다. 다음 10편에서는 NAS에 올리기 위한 Dockerfile, docker-compose.yml, Jenkinsfile을 만들고, 이 소스를 다른 집에서도 쓸 수 있게 정리한다.
그럼 안녕!
꼬리말을 불러오는 중...