stubborn-otter/otter-boards
Otter Boards, a team task board you can run from the web or from Claude and ChatGPT. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 1 remixes · Remix stubborn-otter/otter-boards
tests/test_doors.py
"""The two doors themselves: the brief's own sentences over MCP, the web routes, the page."""
import json
import re
from pathlib import Path
import pytest
from conftest import MEMBER, OWNER, TODAY, VIEWER, header
from fastapi.testclient import TestClient
from boards.application.use_cases import Boards
from boards.doors.identity import person_from
from boards.doors.web import STATIC
from boards.main import create_app
def test_web_and_mcp_doors_share_the_rules(svc):
s, bid = svc
with TestClient(create_app(s)) as c:
assert c.get("/api/boards").status_code == 401
assert c.get("/api/boards", headers=header(VIEWER)).json()[0]["role"] == "viewer"
assert c.post(f"/api/boards/{bid}/cards", json={"title": "t"},
headers=header(VIEWER)).status_code == 403
r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert r.status_code == 401 and "resource_metadata" in r.headers["www-authenticate"]
mcp_h = {**header(MEMBER), "accept": "application/json, text/event-stream"}
init = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "t", "version": "0"}}}
assert c.post("/mcp", json=init, headers=mcp_h).status_code == 200
call = {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "add_card", "arguments": {"board": bid, "title": "via mcp"}}}
out = c.post("/mcp", json=call, headers=mcp_h).json()
assert not out["result"].get("isError"), out
lists = s.show_board(OWNER, bid)["lists"]
assert any(card["title"] == "via mcp" for card in lists[0]["cards"])
def test_the_brief_examples_over_mcp(svc, mcp_as):
s, bid = svc
me = mcp_as(MEMBER)
ops = mcp_as(OWNER).call("create_board", title="Ops")["id"]
s.add_member(OWNER, ops, MEMBER.email, "member")
# "add a card 'Send invoice' to Doing on the Ops board"
card = me.call("add_card", board="Ops", title="Send invoice", list_name="Doing",
assignee="me", due_date="2026-06-12")
assert (card["board"], card["list"], card["number"]) == ("Ops", "Doing", 1)
# "what's on my plate this week?"
plate = me.call("my_cards")
assert [c["title"] for c in plate["due_soon"]] == ["Send invoice"]
# "what's overdue?"
assert me.call("overdue_cards")["count"] == 0
me.call("add_card", board="Launch", title="Old task", due_date="2026-06-01")
overdue = me.call("overdue_cards")
assert overdue["today"] == TODAY and overdue["cards"][0]["title"] == "Old task"
# "move card 12 to Done": number 1 is on both boards, and the tool says so.
assert "more than one board" in me.error("move_card", card=1, list_name="Done")
assert me.call("move_card", card=1, list_name="Done", board="Ops")["list"] == "Done"
assert me.call("move_card", card="#1", list_name="done", board="launch")["list"] == "Done"
history = me.call("card_history", card=1, board="Ops")
assert history[-1]["action"] == "moved from Doing to Done" and history[-1]["who"] == MEMBER.email
def test_mcp_extras_end_to_end(svc, mcp_as):
s, bid = svc
me = mcp_as(OWNER)
me.call("add_card", board="Launch", title="The launch")
me.call("add_card", board="Launch", title="Build website")
me.call("add_blocker", card="The launch", blocked_by=2)
report = me.call("what_is_blocking", card="the launch")
assert report["open_blockers"][0]["title"] == "Build website"
steps = me.call("suggest_checklist", card=1)["suggested_checklist"]
saved = me.call("save_checklist", card=1, items=steps)
assert len(saved["checklist"]) == 2
assert me.call("tick_checklist_item", card=1, item=1)["checklist"][0]["done"] is True
assert me.call("find_similar_cards", card=1)["method"] == "meaning"
assert me.call("board_summary", board="Launch")["source"] == "ai"
def test_mcp_tools_are_marked_read_or_write_and_are_documented(mcp_as):
tools = {t["name"]: t for t in mcp_as(OWNER).tools()}
assert len(tools) >= 25
assert all(t.get("description") for t in tools.values())
assert tools["my_cards"]["annotations"]["readOnlyHint"] is True
assert tools["move_card"]["annotations"]["readOnlyHint"] is False
assert tools["archive_card"]["annotations"]["destructiveHint"] is True
def test_mcp_failures_do_not_leak_internals(svc, mcp_as, monkeypatch):
s, _ = svc
caller = mcp_as(OWNER)
def boom(*a):
raise RuntimeError("password=hunter2 at pg.uk-lon-1")
monkeypatch.setattr(s.repo, "boards_for", boom)
assert "hunter2" not in caller.error("my_boards")
def test_mcp_needs_a_person(web):
r = web.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
headers={"X-Dodil-User": "not json"})
assert r.status_code == 401
def test_web_card_lifecycle(svc, web):
s, bid = svc
h = header(MEMBER)
lists = web.get(f"/api/boards/{bid}", headers=h).json()["lists"]
made = web.post(f"/api/boards/{bid}/cards", headers=h, json={
"title": "Web card", "list_name": lists[1]["id"], "due_date": "2026-06-30",
"labels": ["a"], "assignee": "me"}).json()
assert made["list"] == "Doing" and made["assignee"] == MEMBER.email
edited = web.patch(f"/api/cards/{made['id']}", headers=h,
json={"due_date": None, "description": "Words"}).json()
assert edited["due_date"] is None and edited["description"] == "Words"
assert edited["assignee"] == MEMBER.email
moved = web.post(f"/api/cards/{made['id']}/move", headers=h,
json={"list_name": lists[2]["id"]}).json()
assert moved["list"] == "Done"
assert web.post(f"/api/cards/{made['id']}/checklist/suggest",
headers=h).json()["suggested_checklist"]
detail = web.get(f"/api/cards/{made['id']}", headers=h).json()
assert [e["action"] for e in detail["history"]][-1] == "moved from Doing to Done"
def test_web_errors_are_clear_status_codes(svc, ai, web):
s, bid = svc
h = header(OWNER)
assert web.get("/api/boards/nope", headers=h).status_code == 404
assert web.post(f"/api/boards/{bid}/cards", headers=h, json={"title": " "}).status_code == 422
assert web.post(f"/api/boards/{bid}/members", headers=h,
json={"email": "a@b.io", "role": "boss"}).status_code == 422
card = s.add_card(OWNER, bid, "x")["id"]
s.add_card(OWNER, bid, "y")
ai.down = True
assert web.post(f"/api/cards/{card}/checklist/suggest", headers=h).status_code == 503
assert web.get(f"/api/cards/{card}/similar", headers=h).json()["method"] == "keywords"
def test_page_needs_sign_in_and_only_healthz_and_metadata_are_public(web):
r = web.get("/", follow_redirects=False)
assert r.status_code == 303 and "/.dodil/auth/login" in r.headers["location"]
assert web.get("/healthz").status_code == 200
assert web.get("/.well-known/oauth-protected-resource").status_code == 200
assert web.get("/static/app.js").status_code == 401
for path in ("/docs", "/redoc", "/openapi.json"):
assert web.get(path).status_code == 404
def test_page_and_assets_are_served_with_a_strict_policy(web):
h = header(OWNER)
page = web.get("/", headers=h)
assert page.status_code == 200 and "Otter Boards" in page.text
assert "default-src 'self'" in page.headers["content-security-policy"]
assert web.get("/static/app.js", headers=h).headers["content-type"].startswith("text/javasc")
assert web.get("/static/app.css", headers=h).headers["content-type"].startswith("text/css")
assert web.get("/static/secrets.txt", headers=h).status_code == 404
assert web.get("/static/..%2fweb.py", headers=h).status_code == 404
def test_page_needs_no_build_step_and_no_outside_host():
texts = "\n".join(p.read_text() for p in Path(STATIC).iterdir())
hosts = set(re.findall(r"(?:https?:)?//[a-z0-9.-]+\.[a-z]{2,}", texts))
assert hosts <= {"http://www.w3.org"}, hosts # the SVG namespace is not a request
def test_page_never_builds_html_from_strings():
"""Card text is written by other people, so it may only ever reach the page as text."""
js = (Path(STATIC) / "app.js").read_text()
for risky in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "eval("):
assert risky not in js
def test_no_competitor_name_in_the_product():
app = Path(__file__).parent.parent / "app" / "boards"
for path in app.rglob("*"):
if path.suffix in (".py", ".html", ".js", ".css"):
assert "trello" not in path.read_text().lower(), path
@pytest.mark.parametrize("raw,expected", [
('{"email": "A@X.io", "sub": "1"}', "a@x.io"), ('{"sub": "abc"}', "abc"),
("not json", None), ("[1]", None), ('{"email": ""}', None), ('"x"', None),
])
def test_identity_comes_from_the_gateway_header(raw, expected):
person = person_from({"x-dodil-user": raw})
assert (person.email if person else None) == expected
def test_create_app_is_the_same_wiring_for_any_repository(repo):
"""The production wiring builds without touching the network."""
import boards.main
assert boards.main.app.title == "Otter Boards"
assert create_app(Boards(repo)).title == "Otter Boards"
def test_metadata_points_claude_at_the_pool(web):
body = web.get("/.well-known/oauth-protected-resource/mcp",
headers={"x-forwarded-host": "b.example", "x-forwarded-proto": "https"}).json()
assert body["resource"] == "https://b.example/mcp"
assert json.dumps(body["bearer_methods_supported"]) == '["header"]'