Title :
Building a Home Secretary #9 - Server: Events and Fake Module Tests
This post is translated from the Korean original. Read in Korean →

Up through part 8, the hub was the one calling modules. This time, it’s the reverse: when a module needs to report to the hub first.
And since there isn’t a single real module yet, I attach a fake module that just answers according to the contract, and test whether the contract, commands, approvals, and events all mesh together.
Step 1. When a module needs to report first (events)
So far, everything has been “the client asks, the module does it.” But there’s the reverse direction too. A KakaoTalk notification came in, a conversation summary finished. Things like that need the module to report first. So I opened a path (an event callback) for modules to send news to the hub.
"""Events that modules report to the hub (a Kakao notification came in, a summary finished ...).
The hub only keeps events briefly. Clients pick them up with GET /v1/events.Only titles are kept; sensitive content like message bodies isn't accepted."""import timefrom dataclasses import asdict, dataclass
from .db import Database
KEEP_SECONDS = 7 * 24 * 3600
@dataclassclass Event: """One event. If user is None, it's visible to the whole family."""
id: int module: str type: str user: str | None title: str created_at: int
def public(self) -> dict: """A dict for API responses.""" return asdict(self)
class EventStore: """Event store. Every time something is added, events older than 7 days are deleted."""
def __init__(self, db: Database): """Takes the DB. The table is created by SCHEMA in db.py.""" self.db = db
def add(self, module: str, type_: str, user: str | None, title: str) -> int: """Save an event and return its number.""" 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]: """Return the events visible to this person (their own + those visible to everyone), starting after the given number.
Clients remember the last id they received and pass it as after, so they get only new events. """ 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]"""Events: module → hub (POST /internal/events), hub → client (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): """An event sent by a module. Only the title is accepted (up to 200 characters)."""
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: """Figure out which module sent the request from the internal token.
Requests that came through Cloudflare (with Cf- headers attached) came from outside, so 404 as if the path doesn't exist. 401 if the token matches no module. Tokens are compared in constant time (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="Authentication failed.")
@internal.post("/events", status_code=status.HTTP_201_CREATED)async def receive_event(event: EventIn, request: Request, module: str = Depends(module_from_token)): """Save an event sent by a module. The title isn't written to the audit log (it may be a personal message).""" state = request.app.state if event.user is not None and state.registry.user(event.user) is None: raise HTTPException(422, detail="No such user.") 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))): """Events for me and events for the whole family. Pass the last id you received as after to get only new ones.""" items = request.app.state.events.list_for(caller.user.id, after) return {"items": [e.public() for e in items]}- Modules send to
/internal/eventswith their own token. The hub looks at the token to figure out which module it is. - From outside, this path acts as if it doesn’t exist: 404. Requests that came through Cloudflare carry headers like
Cf-Connecting-Ip. If this header is present, it’s a 404 even with a correct token. It’s meant to be used only on the internal network. - Only titles are accepted. Up to 200 characters. Things like message bodies aren’t accepted, and not even the title is written to the audit log. It might be a personal message.
- Clients pick up only new news with
GET /v1/events?after=<last id>. Events older than 7 days are deleted.
No module sends events yet. But as I build service modules, it’s something that’ll end up being needed. So let’s build it ahead of time.
Step 2. Testing: with a fake module
The hub tests have no real modules. Instead, I made a fake module that just answers according to the contract. All we need to see is whether the hub keeps the contract. It has three actions: send, which needs approval; ping, which runs immediately; and secret, which only admins can use. There’s also down, a dead module that always returns 500.
"""Fake module for tests. The hub doesn't know module code, so it just has to answer per the contract (docs/module-contract.md).
demo module send Needs approval. 422 if to isn't @family.example ping Runs immediately without approval secret Admins only down module: always 500 (imitates a dead module)"""from fastapi import FastAPI, Requestfrom fastapi.responses import JSONResponse
TOKEN = "d" * 40
MANIFEST = { "name": "demo", "title": "Demo", "actions": { "send": {"title": "Send", "approval": True}, "ping": {"title": "Ping"}, "secret": {"title": "Secret", "roles": ["admin"]}, },}
def build(): """Return the fake module app and a list that collects executed requests.""" 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": "Some recipients aren't allowed."}, 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} done"}
@app.api_route("/down/{path:path}", methods=["GET", "POST"]) async def down(path: str): return JSONResponse({"message": "down"}, 500)
return app, executedThe test setup (fixture) is exactly what we saw in part 6. It creates the three family members (Dad the admin, Mom the adult family member, and the eldest kid) and their devices, and attaches the fake module to the hub with httpx.ASGITransport.
Here’s a test that follows the whole approval flow. Dad’s glasses make the request, Dad’s watch approves it, and even if Dad’s phone presses approve once more, it must run only once. It also checks that “who asked for it” is passed to the module correctly.
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"] == "Dad: send → grandma@family.example" assert executed == [] # Not executed yet
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] # Only the summary is exposed
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 done" assert len(executed) == 1 assert executed[0]["params"] == {"to": "grandma@family.example"} assert executed[0]["caller"]["name"] == "Dad"
# Approved twice, executed only once assert call(client, d["dad_phone"], "POST", f"/v1/approvals/{approval['id']}/approve").status_code == 409 assert len(executed) == 1Family members must not see each other’s requests. Even Dad, the admin, can’t see Mom’s requests.
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"] # Even Dad, the admin, can't see Mom's request, and trying to approve it gets the same 404 as a "nonexistent request" 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 # Mom approves with her own watch 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"] == "Mom"Actions a module marks as “admins only” can’t be used by the kid or by Mom.
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 | What it checks |
|---|---|
test_manifest_roles_enforced |
Only admins can execute, per the manifest’s roles |
test_unknown_module_or_action |
Nonexistent modules and actions get 404. Devices without permission get the same 403 |
test_module_down |
A dead module gets 503 |
test_list_modules |
Only usable actions are shown. Admin-only actions aren’t shown to the kid |
test_command_without_approval_runs_now |
Actions without approval run immediately. Secrets aren’t passed to the module |
test_module_validation_error_shown |
If the module rejects it, 422 with that message, and nothing runs |
test_approval_flow |
Request → approval from my other device → runs only once |
test_failed_execution_marked_failed |
If module execution fails, 502, and the approval status is failed |
test_requester_device_cannot_self_approve |
The requesting device can’t approve |
test_family_cannot_see_or_approve_each_other |
Other family members’ requests aren’t visible, 404 |
test_child_request_needs_admin |
Children’s requests can only be approved by an admin |
test_reject_flow |
Rejecting means nothing runs |
test_audit_log_has_no_secrets_or_params |
No Secrets or input values in the audit log, but the person is recorded |
test_module_event_reaches_user |
Only your own events and those sent to everyone are visible |
test_internal_endpoint_rejects_bad_token_and_tunnel |
A wrong token gets 401, requests through the tunnel get 404 |
$ cd services/hub && pytest -q.......................................... [100%]42 passed in 1.86sTogether with the authentication side built through part 6, all 42 hub tests pass. Even without real modules, this confirms the hub keeps the contract.
To sum up
- Events: Things a module needs to report first go to
/internal/events. Blocked from outside - Tests: Without real modules, using a fake module that answers per the contract. All 42 hub tests pass
That’s all the hub’s code. In part 10, I make the Dockerfile, docker-compose.yml, and Jenkinsfile for putting it on the NAS, and tidy up this source so other households can use it too.
Bye for now!

Loading comments...