sirenamc/stackline

Stackline, a team task board you run from the web or from Claude/ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix sirenamc/stackline

tests/test_doors.py

"""Both doors call the same use cases, so the same rules hold for the web page and for Claude."""
import json

from fastapi.testclient import TestClient

from boards.main import create_app
from conftest import MEMBER, OWNER, STRANGER, VIEWER

MCP_ACCEPT = {"accept": "application/json, text/event-stream"}


def _h(p):
    return {"X-Dodil-User": json.dumps({"sub": p.email, "email": p.email})}


def _mcp(c, person, tool, **arguments):
    h = {**_h(person), **MCP_ACCEPT}
    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=h).status_code == 200
    call = {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
            "params": {"name": tool, "arguments": arguments}}
    return c.post("/mcp", json=call, headers=h).json()["result"]


def _data(result):
    if "structuredContent" in result:
        sc = result["structuredContent"]
        return sc.get("result", sc)
    return json.loads(result["content"][0]["text"])


def test_web_refuses_without_sign_in_and_viewer_cannot_edit(svc):
    s, bid = svc
    with TestClient(create_app(s)) as c:
        assert c.get("/api/boards").status_code == 401
        front = c.get("/", follow_redirects=False)
        assert front.status_code == 200 and "/.dodil/auth/login" in front.text
        assert "api/boards" not in front.text          # the front page carries no board data
        assert c.get("/static/paper.css").status_code == 200
        assert c.get("/api/boards", headers=_h(VIEWER)).json()[0]["role"] == "viewer"
        assert c.post(f"/api/boards/{bid}/cards", json={"title": "t"},
                      headers=_h(VIEWER)).status_code == 403
        assert c.get(f"/api/boards/{bid}", headers=_h(STRANGER)).status_code == 404
        r = c.post(f"/api/boards/{bid}/cards", json={"title": "t", "list_name": "Doing"},
                   headers=_h(MEMBER))
        assert r.status_code == 201
        n = r.json()["number"]
        assert c.post(f"/api/cards/{n}/move", json={"list_name": "Done", "position": 0},
                      headers=_h(MEMBER)).json()["list"] == "Done"
        assert c.get("/", headers=_h(MEMBER)).text.startswith("<!doctype html>")


def test_mcp_needs_sign_in_and_lists_action_tools(svc):
    s, _ = svc
    with TestClient(create_app(s)) as c:
        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"]
        h = {**_h(MEMBER), **MCP_ACCEPT}
        tools = c.post("/mcp", json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"},
                       headers=h).json()["result"]["tools"]
        names = {t["name"] for t in tools}
        assert {"my_plate", "overdue_cards", "add_card", "move_card", "assign_card"} <= names
        assert not any(n.startswith(("select", "query", "sql")) for n in names)


def test_the_four_brief_sentences_work_over_mcp(svc):
    """'add a card Send invoice to Doing on the Ops board', 'move card 1 to Done',
    'what's on my plate this week?', 'what's overdue?'"""
    s, _ = svc
    with TestClient(create_app(s)) as c:
        added = _data(_mcp(c, MEMBER, "add_card", board="Ops", title="Send invoice",
                           list_name="Doing", assignee="me", due_date="2026-11-12"))
        assert added["list"] == "Doing" and added["number"] == 1
        late = _data(_mcp(c, MEMBER, "add_card", board="Ops", title="Old report",
                          assignee="me", due_date="2026-11-01"))
        plate = _data(_mcp(c, MEMBER, "my_plate"))
        assert [x["title"] for x in plate["due_soon"]] == ["Send invoice"]
        assert [x["number"] for x in _data(_mcp(c, VIEWER, "overdue_cards"))] == [late["number"]]
        moved = _data(_mcp(c, MEMBER, "move_card", card="1", list_name="Done"))
        assert moved["list"] == "Done"


def test_mcp_keeps_the_same_rules(svc):
    s, bid = svc
    with TestClient(create_app(s)) as c:
        _mcp(c, MEMBER, "add_card", board=bid, title="x")
        refused = _mcp(c, VIEWER, "move_card", card="1", list_name="Done")
        assert refused.get("isError") and "viewer" in refused["content"][0]["text"]
        hidden = _mcp(c, STRANGER, "show_board", board="Ops")
        assert hidden.get("isError")
        assert _mcp(c, MEMBER, "invite_member", board=bid, email="z@x.io").get("isError")


def _rpc(c, person, method, params=None):
    h = {**_h(person), **MCP_ACCEPT}
    body = {"jsonrpc": "2.0", "id": 5, "method": method, "params": params or {}}
    return c.post("/mcp", json=body, headers=h).json()["result"]


READ_ONLY = {"my_boards", "show_board", "my_plate", "overdue_cards", "find_cards", "card_details",
             "board_activity", "what_is_blocking", "suggest_checklist", "similar_cards",
             "board_summary", "board_members"}


def test_every_tool_says_what_it_does_to_the_board(svc):
    s, _ = svc
    with TestClient(create_app(s)) as c:
        tools = _rpc(c, MEMBER, "tools/list")["tools"]
        hints = {t["name"]: t.get("annotations") or {} for t in tools}
        assert all(hints.values()), [n for n, a in hints.items() if not a]
        assert {n for n, a in hints.items() if a.get("readOnlyHint")} == READ_ONLY
        assert {n for n, a in hints.items() if a.get("destructiveHint")} == {
            "delete_card", "remove_member"}
        assert all(len(t["description"]) > 20 for t in tools)


def test_prompts_show_up_as_slash_commands(svc):
    s, _ = svc
    with TestClient(create_app(s)) as c:
        names = {p["name"] for p in _rpc(c, MEMBER, "prompts/list")["prompts"]}
        assert names == {"standup", "plan_my_week", "unblock"}
        got = _rpc(c, MEMBER, "prompts/get", {"name": "standup", "arguments": {"board": "Ops"}})
        text = got["messages"][0]["content"]["text"]
        assert "board_summary" in text and "'Ops'" in text


def test_set_up_board_over_mcp_then_ask_what_blocks_the_launch(svc):
    s, _ = svc
    with TestClient(create_app(s)) as c:
        board = _data(_mcp(c, MEMBER, "set_up_board", title="Site", deadline="2026-11-20",
                           steps=[{"title": "Copy", "days_before": 5},
                                  {"title": "Launch", "after": [1]}]))
        launch = next(x for lst in board["lists"] for x in lst["cards"] if x["title"] == "Launch")
        chain = _data(_mcp(c, MEMBER, "what_is_blocking", card=str(launch["number"])))
        assert [x["title"] for x in chain["blocking"]] == ["Copy"]
        bad = _mcp(c, MEMBER, "set_up_board", title="Bad", steps=[{"title": "x", "after": [3]}])
        assert bad.get("isError") and "earlier steps" in bad["content"][0]["text"]
        assert "Bad" not in {b["title"] for b in _data(_mcp(c, MEMBER, "my_boards"))}


def test_a_viewer_is_refused_every_change_over_mcp(svc):
    s, bid = svc
    with TestClient(create_app(s)) as c:
        n = str(_data(_mcp(c, MEMBER, "add_card", board=bid, title="Pay rent"))["number"])
        for tool, args in [("add_card", {"board": bid, "title": "x"}),
                           ("assign_card", {"card": n, "assignee": "me"}),
                           ("update_card", {"card": n, "title": "y"}),
                           ("delete_card", {"card": n}),
                           ("add_list", {"board": bid, "name": "Later"}),
                           ("invite_member", {"board": bid, "email": "z@x.io"})]:
            r = _mcp(c, VIEWER, tool, **args)
            assert r.get("isError"), tool
        assert _data(_mcp(c, VIEWER, "card_details", card=n))["title"] == "Pay rent"


def test_a_stranger_finds_nothing_over_mcp(svc):
    s, bid = svc
    with TestClient(create_app(s)) as c:
        n = str(_data(_mcp(c, MEMBER, "add_card", board=bid, title="Send invoice"))["number"])
        assert _data(_mcp(c, STRANGER, "my_boards")) == []
        assert "no card" in _mcp(c, STRANGER, "card_details", card=n)["content"][0]["text"]
        assert _mcp(c, STRANGER, "similar_cards", card=n).get("isError")
        assert _data(_mcp(c, STRANGER, "find_cards", text="invoice")) == []
        assert _data(_mcp(c, STRANGER, "overdue_cards")) == []


def test_example_and_demo_reset_doors(svc):
    s, _ = svc
    with TestClient(create_app(s)) as c:
        assert c.get("/api/me", headers=_h(STRANGER)).json() == {"email": STRANGER.email,
                                                                 "demo": False}
        assert c.post("/api/demo/reset", headers=_h(OWNER)).status_code == 403
        r = c.post("/api/example", headers=_h(STRANGER))
        assert r.status_code == 201 and len(r.json()["created_cards"]) == 9