north-scape/relayboard

Relayboard: accessible team boards with audited actions, dependency maps, and shared web/MCP use cases.

A remix template · 0 remixes · Remix north-scape/relayboard

tests/test_checklist.py

"""Reviewed checklist persistence and both permission-aware doors; no live AI/data."""
import copy
import json
from datetime import date

import pytest
from fastapi.testclient import TestClient

from boards.application.use_cases import Boards, NotFound
from boards.domain.model import Conflict, Forbidden, Invalid, Person
from boards.infrastructure.memory import MemoryBoards
from boards.main import create_app

OWNER, MEMBER, VIEWER, OUTSIDER = [Person(x + '@example.test') for x in ('owner', 'member', 'viewer', 'outsider')]

class AI:
    def __init__(self): self.steps = [' Confirm scope ', '<img src=x onerror=alert(1)>']; self.calls = 0
    def checklist(self, title): self.calls += 1; return self.steps

@pytest.fixture
def setup():
    ai = AI()
    svc = Boards(MemoryBoards(), ai, today=lambda: date(2026, 10, 30))
    bid = svc.create_board(OWNER, 'Launch')['id']
    svc.add_member(OWNER, bid, MEMBER.email)
    svc.add_member(OWNER, bid, VIEWER.email, 'viewer')
    card = svc.add_card(OWNER, bid, 'Ship')
    return svc, bid, card, ai

def headers(person): return {'X-Dodil-User': json.dumps({'email': person.email}), 'accept': 'application/json, text/event-stream'}
def call(client, person, tool, args):
    r = client.post('/mcp', headers=headers(person), json={'jsonrpc': '2.0', 'id': 9, 'method': 'tools/call', 'params': {'name': tool, 'arguments': args}})
    assert r.status_code == 200, r.text
    return r.json()['result']

def test_suggestions_read_only_revision_bound_and_literal(setup):
    svc, bid, card, ai = setup
    before = svc.repo.snapshot()
    result = svc.suggest_checklist(VIEWER, card['id'])
    assert result == {'card_id': card['id'], 'suggested_checklist': ['Confirm scope', ai.steps[1]], 'source_revision': 1, 'suggestion_only': True}
    assert svc.repo.snapshot() == before and ai.calls == 1

@pytest.mark.parametrize('steps', [None, 'not a list', [], [''] , ['   '], [123], [{}], ['x' * 301], ['step'] * 9])
def test_malformed_suggestions_fail_safely_without_writes(setup, steps):
    svc, _, card, ai = setup
    ai.steps = steps
    before = svc.repo.snapshot()
    with pytest.raises(Invalid): svc.suggest_checklist(OWNER, card['id'])
    assert svc.repo.snapshot() == before

def test_save_toggle_edit_remove_and_clear_preserve_stable_ids_and_history(setup):
    svc, bid, card, _ = setup
    added = svc.update_checklist(MEMBER, card['id'], [{'text': ' Scope '}, {'text': 'Verify', 'done': True}], 1)
    first, second = added['checklist']
    assert first['text'] == 'Scope' and first['done'] is False and second['done'] is True
    assert first['id'] != second['id'] and len(first['id']) == 32
    assert svc.show_board(VIEWER, bid)['cards'][0]['checklist'] == added['checklist']
    changed = svc.update_card(MEMBER, card['id'], {'title': 'Ready', 'checklist': [{**first, 'done': True, 'text': 'Approved scope'}]}, 2)
    assert changed['revision'] == 3 and changed['title'] == 'Ready'
    assert changed['checklist'] == [{'id': first['id'], 'done': True, 'text': 'Approved scope'}]
    event = svc.activity(OWNER, bid)[0]
    assert event['actor'] == MEMBER.email and event['card_id'] == card['id'] and event['event_at']
    assert event['changes']['checklist'] == {'before': added['checklist'], 'after': changed['checklist']}
    assert event['changes']['title'] == {'before': 'Ship', 'after': 'Ready'}
    assert svc.update_checklist(OWNER, card['id'], [], 3)['checklist'] == []

@pytest.mark.parametrize('person,error', [(VIEWER, Forbidden), (OUTSIDER, NotFound)])
def test_checklist_permission_denials_do_not_write(setup, person, error):
    svc, _, card, _ = setup
    before = svc.repo.snapshot()
    with pytest.raises(error): svc.update_checklist(person, card['id'], [{'text': 'No'}], 1)
    assert svc.repo.snapshot() == before

def test_outsider_cannot_trigger_checklist_ai(setup):
    svc, _, card, ai = setup
    with pytest.raises(NotFound): svc.suggest_checklist(OUTSIDER, card['id'])
    assert ai.calls == 0

@pytest.mark.parametrize('items', [None, 'x', [{}], [{'text': ''}], [{'text': 'x' * 301}], [{'text': 'x', 'done': 1}], [{'text': 'x', 'done': 'false'}], [{'text': 'x', 'done': None}], [{'text': 'x', 'actor': 'owner'}], [{'id': 'invented', 'text': 'x'}], [{'id': [], 'text': 'x'}], [{'id': None, 'text': 'x'}], [{'text': 'x'}] * 51])
def test_invalid_checklists_reject_entire_card_update(setup, items):
    svc, _, card, _ = setup
    before = svc.repo.snapshot()
    with pytest.raises(Invalid): svc.update_card(MEMBER, card['id'], {'title': 'Must not save', 'checklist': items}, 1)
    assert svc.repo.snapshot() == before

def test_duplicate_cross_card_and_removed_step_ids_refused(setup):
    svc, bid, card, _ = setup
    saved = svc.update_checklist(OWNER, card['id'], [{'text': 'One'}], 1)
    step = saved['checklist'][0]
    other = svc.add_card(OWNER, bid, 'Another')
    before = svc.repo.snapshot()
    with pytest.raises(Invalid): svc.update_checklist(OWNER, card['id'], [step, step], 2)
    with pytest.raises(Invalid): svc.update_checklist(OWNER, other['id'], [step], 1)
    assert svc.repo.snapshot() == before
    svc.update_checklist(OWNER, card['id'], [], 2)
    with pytest.raises(Invalid): svc.update_checklist(OWNER, card['id'], [step], 3)

@pytest.mark.parametrize('revision,error', [(None, Invalid), (0, Conflict), (True, Invalid), ('1', Invalid), (1.0, Invalid)])
def test_revision_is_required_and_strict_before_saving(setup, revision, error):
    svc, _, card, _ = setup
    before = svc.repo.snapshot()
    with pytest.raises(error): svc.update_checklist(OWNER, card['id'], [{'text': 'Do'}], revision)
    assert svc.repo.snapshot() == before

def test_stale_checklist_cannot_overwrite_newer_card_edit(setup):
    svc, _, card, _ = setup
    svc.update_card(MEMBER, card['id'], {'description': 'Newer'}, 1)
    before = svc.repo.snapshot()
    with pytest.raises(Conflict): svc.update_checklist(OWNER, card['id'], [{'text': 'Old suggestion'}], 1)
    assert svc.repo.snapshot() == before

def test_noop_has_no_revision_or_history_noise(setup):
    svc, _, card, _ = setup
    saved = svc.update_checklist(OWNER, card['id'], [{'text': 'One'}], 1)
    before = svc.repo.snapshot()
    assert svc.update_checklist(OWNER, card['id'], copy.deepcopy(saved['checklist']), 2) == saved
    assert svc.repo.snapshot() == before

def test_audit_failure_rolls_back_checklist_and_other_fields(setup, monkeypatch):
    svc, _, card, _ = setup
    before = svc.repo.snapshot()
    def fail(*args, **kwargs): raise RuntimeError('audit unavailable')
    monkeypatch.setattr(svc.repo, 'log', fail)
    with pytest.raises(RuntimeError): svc.update_card(OWNER, card['id'], {'title': 'No', 'checklist': [{'text': 'No'}]}, 1)
    assert svc.repo.snapshot() == before

@pytest.mark.parametrize('revision', [None, True, '1', 1.0])
def test_web_rejects_missing_or_noninteger_checklist_revision(setup, revision):
    svc, _, card, _ = setup
    before = svc.repo.snapshot()
    with TestClient(create_app(svc)) as client:
        r = client.patch('/api/cards/' + card['id'], headers=headers(OWNER), json={'checklist': [{'text': 'Do'}], 'expected_revision': revision})
        assert r.status_code == 422
    assert svc.repo.snapshot() == before

def test_web_persists_reviewed_steps_and_enforces_visibility(setup):
    svc, bid, card, _ = setup
    with TestClient(create_app(svc)) as client:
        path = '/api/cards/' + card['id']
        body = {'checklist': [{'text': 'Reviewed', 'done': False}], 'expected_revision': 1}
        assert client.patch(path, json=body).status_code == 401
        assert client.patch(path, headers=headers(VIEWER), json=body).status_code == 403
        assert client.patch(path, headers=headers(OUTSIDER), json=body).status_code == 404
        r = client.patch(path, headers=headers(MEMBER), json=body)
        assert r.status_code == 200 and r.json()['checklist'][0]['text'] == 'Reviewed'
        assert client.patch(path, headers=headers(MEMBER), json=body).status_code == 409
        assert client.get('/api/boards/' + bid, headers=headers(VIEWER)).json()['cards'][0]['checklist'] == r.json()['checklist']

@pytest.mark.parametrize('revision', [None, True, '1', 1.0])
def test_mcp_checklist_rejects_invalid_revision(setup, revision):
    svc, _, card, _ = setup
    before = svc.repo.snapshot()
    with TestClient(create_app(svc)) as client:
        result = call(client, MEMBER, 'update_checklist', {'card_id': card['id'], 'items': [{'text': 'Do'}], 'expected_revision': revision})
        assert result.get('isError')
    assert svc.repo.snapshot() == before

def test_mcp_review_then_apply_is_explicit_and_permission_scoped(setup):
    svc, bid, card, _ = setup
    args = {'card_id': card['id'], 'items': [{'text': 'Reviewed'}], 'expected_revision': 1}
    with TestClient(create_app(svc)) as client:
        assert not call(client, MEMBER, 'suggest_checklist', {'card_id': card['id']}).get('isError')
        assert svc.repo.get_card(card['id']).checklist == []
        assert call(client, VIEWER, 'update_checklist', args).get('isError')
        assert call(client, OUTSIDER, 'update_checklist', args).get('isError')
        assert not call(client, MEMBER, 'update_checklist', args).get('isError')
        assert call(client, MEMBER, 'update_checklist', args).get('isError')
    assert svc.repo.get_card(card['id']).checklist[0]['text'] == 'Reviewed'

@pytest.mark.parametrize('revision', [None, True, '1', 1.0])
def test_generic_mcp_card_edit_cannot_bypass_checklist_revision_contract(setup, revision):
    svc, _, card, _ = setup
    before = svc.repo.snapshot()
    with TestClient(create_app(svc)) as client:
        result = call(client, MEMBER, 'update_card', {'card_id': card['id'], 'changes': {'checklist': [{'text': 'Do'}]}, 'expected_revision': revision})
        assert result.get('isError')
    assert svc.repo.snapshot() == before


def test_summary_links_blockers_and_checklist_progress_without_leaking_other_boards(setup):
    svc, bid, card, _ = setup
    captured = []
    class SummaryAI:
        def summary(self, payload): captured.append(payload); return 'Fake summary for contract test.'
    svc.assistant = SummaryAI()
    blocker = svc.add_card(OWNER, bid, 'Approval', assignee=MEMBER.email)
    svc.set_blockers(OWNER, card['id'], [blocker['id']], 1)
    svc.update_checklist(OWNER, card['id'], [{'text': 'Draft', 'done': True}, {'text': 'Deliver'}], 2)
    secret_board = svc.create_board(OWNER, 'SECRET BOARD')['id']
    svc.add_card(OWNER, secret_board, 'SECRET CARD')
    before = svc.repo.snapshot()
    result = svc.board_summary(MEMBER, bid)
    assert result['suggestion_only'] is True
    assert svc.repo.snapshot() == before
    payload = captured[0]
    cards = {c['id']: c for c in payload['cards']}
    assert cards[card['id']]['blocked_by'] == [blocker['id']]
    assert cards[blocker['id']]['title'] == 'Approval'
    assert cards[blocker['id']]['assignee'] == MEMBER.email
    assert cards[card['id']]['reference'].startswith('RB-')
    assert cards[card['id']]['checklist_progress'] == {'done': 1, 'total': 2}
    assert cards[blocker['id']]['checklist_progress'] == {'done': 0, 'total': 0}
    assert 'SECRET' not in json.dumps(payload)
    assert 'Deliver' not in json.dumps(payload), 'only counts, not checklist contents'
    with pytest.raises(NotFound): svc.board_summary(MEMBER, secret_board)
    assert len(captured) == 1