dmslanes/lanes

Lanes - team task boards with roles, history and an MCP endpoint for Claude and ChatGPT

A remix template · 0 remixes · Remix dmslanes/lanes

tests/test_doors.py

"""Both doors over one app: the web routes and the MCP tools obey the same rules."""
import json
import os

import pytest

os.environ["DEV_ALLOW_ANON"] = "1"
os.environ["LANES_STORAGE"] = "memory"
os.environ["LANES_NO_AUTOAPP"] = "1"

from fastapi.testclient import TestClient  # noqa: E402

from app.main import create_app  # noqa: E402

OWNER, MEMBER, VIEWER, STRANGER = "olga@team.io", "mike@team.io", "vera@team.io", "sam@else.io"


def as_(email):
    return {"x-dev-user": email}


@pytest.fixture
def client():
    with TestClient(create_app(), base_url="http://localhost") as c:
        yield c


@pytest.fixture
def board(client):
    ws = client.post("/api/workspaces", json={"name": "Acme"}, headers=as_(OWNER)).json()
    for email, role in ((MEMBER, "member"), (VIEWER, "viewer")):
        assert client.post(f"/api/workspaces/{ws['id']}/members", json={"email": email, "role": role},
                           headers=as_(OWNER)).status_code == 201
    return client.post("/api/boards", json={"workspace_id": ws["id"], "name": "Ops"}, headers=as_(OWNER)).json()


def mcp(client, email, tool, **args):
    r = client.post("/mcp", headers={**as_(email), "accept": "application/json, text/event-stream",
                                     "content-type": "application/json"},
                    json={"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}})
    assert r.status_code == 200, r.text
    result = r.json()["result"]
    text = result["content"][0]["text"] if result.get("content") else ""
    sc = result.get("structuredContent")
    if sc is None:
        err = result.get("isError", False)
        return err, text if err else json.loads(text)
    return result.get("isError", False), sc["result"] if set(sc) == {"result"} else sc


def test_web_roles(client, board):
    r = client.post(f"/api/boards/{board['id']}/cards", json={"list": "To do", "title": "Send invoice"}, headers=as_(MEMBER))
    assert r.status_code == 201
    n = r.json()["number"]
    assert client.post(f"/api/cards/{n}/move", json={"to_list": "Done"}, headers=as_(VIEWER)).status_code == 403
    assert client.get(f"/api/boards/{board['id']}", headers=as_(STRANGER)).status_code == 404
    assert client.get("/api/boards", headers=as_(STRANGER)).json() == []
    assert client.post(f"/api/cards/{n}/move", json={"to_list": "Done"}, headers=as_(MEMBER)).status_code == 200
    assert [h["action"] for h in client.get(f"/api/cards/{n}/history", headers=as_(OWNER)).json()] == ["created", "moved"]


def test_mcp_actions_like_an_assistant(client, board):
    err, card = mcp(client, MEMBER, "add_card", board="Ops board", list="Doing", title="Send invoice",
                    assignee=MEMBER, due_date="2000-01-01")
    assert not err and card["list"] == "Doing"
    err, plate = mcp(client, MEMBER, "my_cards", days=7)
    assert [c["title"] for c in plate] == ["Send invoice"]
    err, late = mcp(client, VIEWER, "overdue_cards")
    assert [c["number"] for c in late] == [card["number"]]
    err, moved = mcp(client, MEMBER, "move_card", card=str(card["number"]), to_list="Done")
    assert not err and moved["list"] == "Done"
    err, msg = mcp(client, VIEWER, "move_card", card=str(card["number"]), to_list="Doing")
    assert err and "forbidden" in msg
    err, boards = mcp(client, STRANGER, "list_boards")
    assert boards == []
    err, msg = mcp(client, STRANGER, "show_board", board="Ops")
    assert err and "not_found" in msg


def test_mcp_without_identity_asks_to_sign_in(monkeypatch):
    monkeypatch.setenv("DEV_ALLOW_ANON", "0")
    monkeypatch.setenv("DODIL_USER_POOL", "acme/lanes-users")
    with TestClient(create_app(), base_url="http://localhost") as c:
        r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
        assert r.status_code == 401
        assert "oauth-protected-resource/mcp" in r.headers["www-authenticate"]
        meta = c.get("/.well-known/oauth-protected-resource/mcp").json()
        assert meta["authorization_servers"] == ["https://appid.dodil.io/acme/lanes-users"]
        assert c.get("/api/me").status_code == 401
        assert c.get("/healthz").status_code == 200


def test_forged_dev_header_is_ignored_when_deployed(monkeypatch):
    monkeypatch.setenv("DEV_ALLOW_ANON", "0")
    with TestClient(create_app(), base_url="http://localhost") as c:
        assert c.get("/api/me", headers=as_(OWNER)).status_code == 401
        who = {"x-dodil-user": json.dumps({"sub": "1", "email": "Olga@Team.io"})}
        assert c.get("/api/me", headers=who).json()["email"] == OWNER