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)