Site Icon THEREiSLatest Posts · reading #015한국어

Title :

Building a Home Secretary #9 - Server: Events and Fake Module Tests

Author
[Yahum]
Date
Tags

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.

services/hub/app/events.py
"""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 time
from dataclasses import asdict, dataclass
from .db import Database
KEEP_SECONDS = 7 * 24 * 3600
@dataclass
class 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]
services/hub/app/routers/events.py
"""Events: module → hub (POST /internal/events), hub → client (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):
"""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/events with 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.

services/hub/tests/fake_module.py
"""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, Request
from 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, executed

The 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) == 1

Family 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.86s

Together 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!
Good Bye

[ Comments ]Shown after approval

Loading comments...