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