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"]'