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_roles.py

"""Roles, through both doors, action by action.

Every route and every tool must appear in the tables below: test_every_action_is_covered fails
when someone adds an action without saying who may use it.
"""
import pytest
from conftest import MEMBER, OWNER, STRANGER, VIEWER, header
from fastapi.routing import APIRoute

# (method, path, body) for each web route that changes something.
WEB_WRITES = [
    ("PATCH", "/api/boards/{bid}", {"title": "x"}),
    ("POST", "/api/boards/{bid}/members", {"email": "new@x.io"}),
    ("DELETE", "/api/boards/{bid}/members/bo@x.io", None),
    ("POST", "/api/boards/{bid}/lists", {"name": "New"}),
    ("PATCH", "/api/boards/{bid}/lists/{list}", {"name": "Renamed"}),
    ("POST", "/api/boards/{bid}/lists/{list}/move", {}),
    ("POST", "/api/boards/{bid}/lists/{list}/archive", None),
    ("POST", "/api/boards/{bid}/cards", {"title": "x"}),
    ("PATCH", "/api/cards/{card}", {"title": "x"}),
    ("POST", "/api/cards/{card}/assign", {"assignee": "bo@x.io"}),
    ("POST", "/api/cards/{card}/move", {"list_name": "Done"}),
    ("POST", "/api/cards/{card}/archive", None),
    ("POST", "/api/cards/{card}/blockers", {"blocked_by": "{target}"}),
    ("DELETE", "/api/cards/{card}/blockers/{target}", None),
    ("POST", "/api/cards/{card}/checklist/suggest", None),
    ("PUT", "/api/cards/{card}/checklist", {"items": [{"text": "a"}]}),
    ("POST", "/api/cards/{card}/checklist/tick", {"item": "1"}),
]
# Writes open to any signed-in person: they touch no board that already exists.
WEB_FOR_ANYONE = [("POST", "/api/boards", {"title": "Mine"})]

# Reads tied to one board or card: a stranger gets 404 from each.
WEB_READS = [
    "/api/boards/{bid}", "/api/boards/{bid}/activity", "/api/boards/{bid}/summary",
    "/api/boards/{bid}/members", "/api/overdue?board={bid}", "/api/cards/{card}",
    "/api/cards/{card}/history", "/api/cards/{card}/blockers", "/api/cards/{card}/similar",
]
# Reads scoped to the person: a stranger gets 200 and nothing of the board's.
WEB_PERSONAL = ["/api/me", "/api/me/cards", "/api/boards", "/api/overdue"]

MCP_WRITES = [
    ("rename_board", {"board": "{bid}", "title": "x"}),
    ("add_member", {"board": "{bid}", "email": "new@x.io"}),
    ("remove_member", {"board": "{bid}", "email": "bo@x.io"}),
    ("add_list", {"board": "{bid}", "name": "New"}),
    ("update_list", {"board": "{bid}", "list_name": "{list}", "name": "Renamed"}),
    ("move_list", {"board": "{bid}", "list_name": "{list}"}),
    ("archive_list", {"board": "{bid}", "list_name": "{list}"}),
    ("add_card", {"board": "{bid}", "title": "x"}),
    ("update_card", {"card": "{card}", "title": "x"}),
    ("assign_card", {"card": "{card}", "assignee": "bo@x.io"}),
    ("move_card", {"card": "{card}", "list_name": "Done"}),
    ("archive_card", {"card": "{card}"}),
    ("add_blocker", {"card": "{card}", "blocked_by": "{target}"}),
    ("remove_blocker", {"card": "{card}", "blocked_by": "{target}"}),
    ("suggest_checklist", {"card": "{card}"}),
    ("save_checklist", {"card": "{card}", "items": ["a"]}),
    ("tick_checklist_item", {"card": "{card}", "item": "1"}),
]
MCP_FOR_ANYONE = {"create_board"}
MCP_READS = [
    ("show_board", {"board": "{bid}"}),
    ("show_card", {"card": "{card}"}),
    ("card_history", {"card": "{card}"}),
    ("board_activity", {"board": "{bid}"}),
    ("list_members", {"board": "{bid}"}),
    ("what_is_blocking", {"card": "{card}"}),
    ("find_similar_cards", {"card": "{card}"}),
    ("board_summary", {"board": "{bid}"}),
    ("overdue_cards", {"board": "{bid}"}),
]
MCP_PERSONAL = {"whoami", "my_boards", "my_cards", "overdue_cards"}


@pytest.fixture
def world(svc):
    """A board with: a card in To do, a blocker link to a second card, an empty fourth list."""
    s, bid = svc
    card = s.add_card(OWNER, bid, "Thing", assignee=MEMBER.email)
    target = s.add_card(OWNER, bid, "Other")
    s.add_blocker(OWNER, card["id"], target["id"])
    s.save_checklist(OWNER, card["id"], ["first"])
    spare = s.add_list(OWNER, bid, "Spare")
    return {"bid": bid, "card": card["id"], "target": target["id"], "list": spare["id"]}


def fill(value, ids):
    if isinstance(value, str):
        return value.format(**ids)
    if isinstance(value, dict):
        return {k: fill(v, ids) for k, v in value.items()}
    if isinstance(value, list):
        return [fill(v, ids) for v in value]
    return value


def snapshot(s, bid):
    """Everything the owner can see, to prove a refused write changed nothing."""
    board = s.show_board(OWNER, bid)
    return board, s.board_activity(OWNER, bid, 200)


def send(web, person, method, path, body, ids):
    return web.request(method, fill(path, ids), headers=header(person),
                       json=fill(body, ids) if body is not None else None)


@pytest.mark.parametrize("method,path,body", WEB_WRITES, ids=lambda v: str(v)[:40])
def test_web_viewer_can_not_do_this(svc, world, web, method, path, body):
    before = snapshot(svc[0], world["bid"])
    r = send(web, VIEWER, method, path, body, world)
    assert r.status_code == 403, r.text
    assert snapshot(svc[0], world["bid"]) == before


@pytest.mark.parametrize("method,path,body", WEB_WRITES, ids=lambda v: str(v)[:40])
def test_web_stranger_gets_nothing_at_all(svc, world, web, method, path, body):
    before = snapshot(svc[0], world["bid"])
    r = send(web, STRANGER, method, path, body, world)
    assert r.status_code == 404, r.text
    assert snapshot(svc[0], world["bid"]) == before


@pytest.mark.parametrize("method,path,body", WEB_WRITES, ids=lambda v: str(v)[:40])
def test_web_nobody_signed_in_is_refused(world, web, method, path, body):
    r = web.request(method, fill(path, world), json=fill(body, world) if body is not None else None)
    assert r.status_code == 401


@pytest.mark.parametrize("method,path,body", WEB_WRITES, ids=lambda v: str(v)[:40])
def test_web_other_sites_can_not_ride_on_a_session(world, web, method, path, body):
    r = web.request(method, fill(path, world), headers={**header(OWNER),
                                                        "sec-fetch-site": "cross-site"},
                    json=fill(body, world) if body is not None else None)
    assert r.status_code == 403 and "cross-site" in r.text


OWNER_ONLY = {"PATCH /api/boards/{bid}", "POST /api/boards/{bid}/members",
              "DELETE /api/boards/{bid}/members/bo@x.io"}


@pytest.mark.parametrize("method,path,body", WEB_WRITES, ids=lambda v: str(v)[:40])
def test_web_member_may_edit_but_not_manage(world, web, method, path, body):
    r = send(web, MEMBER, method, path, body, world)
    if f"{method} {path}" in OWNER_ONLY:
        assert r.status_code == 403, r.text
    else:
        assert r.status_code in (200, 201), r.text


@pytest.mark.parametrize("method,path,body", WEB_WRITES, ids=lambda v: str(v)[:40])
def test_web_owner_may_do_everything(world, web, method, path, body):
    r = send(web, OWNER, method, path, body, world)
    assert r.status_code in (200, 201), r.text


@pytest.mark.parametrize("path", WEB_READS)
def test_web_stranger_can_not_read(world, web, path):
    assert web.get(fill(path, world), headers=header(STRANGER)).status_code == 404


@pytest.mark.parametrize("path", WEB_READS)
def test_web_viewer_can_read(world, web, path):
    r = web.get(fill(path, world), headers=header(VIEWER))
    assert r.status_code == 200, r.text


@pytest.mark.parametrize("path", WEB_PERSONAL)
def test_web_stranger_sees_nothing_of_the_board_in_personal_views(world, web, path):
    r = web.get(path, headers=header(STRANGER))
    assert r.status_code == 200
    assert "Thing" not in r.text and world["bid"] not in r.text


@pytest.mark.parametrize("path", WEB_READS + WEB_PERSONAL)
def test_web_reads_need_a_signed_in_person(world, web, path):
    assert web.get(fill(path, world)).status_code == 401


@pytest.mark.parametrize("tool,args", MCP_WRITES, ids=lambda v: v if isinstance(v, str) else "")
def test_mcp_viewer_can_not_do_this(svc, world, mcp_as, tool, args):
    before = snapshot(svc[0], world["bid"])
    text = mcp_as(VIEWER).error(tool, **fill(args, world))
    assert "viewer" in text or "owner" in text
    assert snapshot(svc[0], world["bid"]) == before


@pytest.mark.parametrize("tool,args", MCP_WRITES, ids=lambda v: v if isinstance(v, str) else "")
def test_mcp_stranger_gets_nothing_at_all(svc, world, mcp_as, tool, args):
    before = snapshot(svc[0], world["bid"])
    text = mcp_as(STRANGER).error(tool, **fill(args, world))
    assert "no such" in text
    assert snapshot(svc[0], world["bid"]) == before


MCP_OWNER_ONLY = {"rename_board", "add_member", "remove_member"}


@pytest.mark.parametrize("tool,args", MCP_WRITES, ids=lambda v: v if isinstance(v, str) else "")
def test_mcp_member_may_edit_but_not_manage(world, mcp_as, tool, args):
    caller = mcp_as(MEMBER)
    if tool in MCP_OWNER_ONLY:
        assert "owner" in caller.error(tool, **fill(args, world))
    else:
        caller.call(tool, **fill(args, world))


@pytest.mark.parametrize("tool,args", MCP_WRITES, ids=lambda v: v if isinstance(v, str) else "")
def test_mcp_owner_may_do_everything(world, mcp_as, tool, args):
    mcp_as(OWNER).call(tool, **fill(args, world))


@pytest.mark.parametrize("tool,args", MCP_READS, ids=lambda v: v if isinstance(v, str) else "")
def test_mcp_stranger_can_not_read(world, mcp_as, tool, args):
    assert "no such" in mcp_as(STRANGER).error(tool, **fill(args, world))


@pytest.mark.parametrize("tool,args", MCP_READS, ids=lambda v: v if isinstance(v, str) else "")
def test_mcp_viewer_can_read(world, mcp_as, tool, args):
    mcp_as(VIEWER).call(tool, **fill(args, world))


def test_mcp_stranger_finds_no_board_by_name_card_by_number_or_title(world, mcp_as):
    caller = mcp_as(STRANGER)
    for tool, args in (("show_board", {"board": "Launch"}), ("show_card", {"card": 1}),
                       ("show_card", {"card": "Thing"}), ("move_card", {
                           "card": "Thing", "list_name": "Done"}),
                       ("add_card", {"board": "Launch", "title": "x"})):
        assert "no such" in caller.error(tool, **args)


def test_mcp_personal_tools_show_a_stranger_nothing(world, mcp_as):
    caller = mcp_as(STRANGER)
    assert caller.call("my_boards") == []
    assert caller.call("overdue_cards")["cards"] == []
    plate = caller.call("my_cards")
    assert not any(plate[k] for k in ("overdue", "due_soon", "no_due_date", "due_later"))


def test_every_action_is_covered(svc, web, mcp_as):
    """A new route or tool has to be added to the tables above to get past this."""
    routes = set()
    for route in web.app.routes:
        if isinstance(route, APIRoute) and route.path.startswith("/api/"):
            routes |= {(m, route.path) for m in route.methods}
    def normal(path):
        path = path.split("?")[0]
        for short, long in (("{bid}", "{board_id}"), ("{list}", "{list_id}"),
                            ("{card}", "{card_id}"), ("{target}", "{blocked_by}"),
                            ("bo@x.io", "{email}")):
            path = path.replace(short, long)
        return path

    named = {(m, normal(p)) for m, p, _ in WEB_WRITES + WEB_FOR_ANYONE}
    named |= {("GET", normal(p)) for p in WEB_READS + WEB_PERSONAL}
    assert routes == named

    tools = {t["name"] for t in mcp_as(OWNER).tools()}
    assert tools == ({t for t, _ in MCP_WRITES + MCP_READS} | MCP_FOR_ANYONE | MCP_PERSONAL)