486 lines
20 KiB
Python
486 lines
20 KiB
Python
"""
|
|
Tests for issue linking (duplicate / related).
|
|
|
|
Two things are worth testing here. The first is the link semantics: one stored
|
|
row read from both sides, no self-links, no double-linking in either direction.
|
|
|
|
The second, and the reason this file is long, is SCOPE. A link is a pointer to
|
|
another issue's id, description and facility, so it is a potential way to read
|
|
an issue you have no access to. Three surfaces have to hold that line
|
|
independently -- the panel that renders links, the search that finds candidates,
|
|
and the POST that creates them -- and only the POST is a real boundary.
|
|
"""
|
|
import pytest
|
|
|
|
from app import db as _db
|
|
from app.models.issue import Issue, IssueLink
|
|
from app.models.project import Project, CustomerAssignment
|
|
from app.models.inspector_assignment import InspectorAssignment
|
|
from app.utils.time_utils import now_eastern
|
|
|
|
|
|
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def make_issue(db):
|
|
def _make(facility=None, area=None, severity='high', status='open', **kw):
|
|
issue = Issue(
|
|
facility_id=facility.id if facility else None,
|
|
area_id=area.id if area else None,
|
|
severity=severity,
|
|
description=kw.pop('description', 'a leak under the sink'),
|
|
status=status,
|
|
reported_at=now_eastern(),
|
|
)
|
|
for k, v in kw.items():
|
|
setattr(issue, k, v)
|
|
db.session.add(issue)
|
|
db.session.commit()
|
|
return issue
|
|
return _make
|
|
|
|
|
|
@pytest.fixture
|
|
def make_project(db):
|
|
def _make(name='Contract A'):
|
|
proj = Project(name=name, active=True)
|
|
db.session.add(proj)
|
|
db.session.commit()
|
|
return proj
|
|
return _make
|
|
|
|
|
|
@pytest.fixture
|
|
def link_url():
|
|
def _make(issue):
|
|
return f'/issues/{issue.id}/links'
|
|
return _make
|
|
|
|
|
|
# ── Model semantics ──────────────────────────────────────────────────────────
|
|
|
|
def test_a_link_reads_differently_from_each_end(app, db, make_issue, make_facility):
|
|
"""One stored row; 'duplicate' is directional and says so on both issues."""
|
|
facility = make_facility()
|
|
dupe = make_issue(facility=facility)
|
|
orig = make_issue(facility=facility)
|
|
|
|
db.session.add(IssueLink(issue_id=dupe.id, linked_issue_id=orig.id,
|
|
link_type='duplicate'))
|
|
db.session.commit()
|
|
|
|
link = IssueLink.query.one()
|
|
assert link.label_for(dupe.id) == 'Duplicate of'
|
|
assert link.label_for(orig.id) == 'Duplicated by'
|
|
assert link.other_issue(dupe.id).id == orig.id
|
|
assert link.other_issue(orig.id).id == dupe.id
|
|
|
|
|
|
def test_related_reads_the_same_from_both_ends(app, db, make_issue, make_facility):
|
|
facility = make_facility()
|
|
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
|
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
|
|
link_type='related'))
|
|
db.session.commit()
|
|
|
|
link = IssueLink.query.one()
|
|
assert link.label_for(a.id) == 'Related to'
|
|
assert link.label_for(b.id) == 'Related to'
|
|
|
|
|
|
def test_one_row_appears_on_both_issues(app, db, make_issue, make_facility):
|
|
"""all_links() merges the two storage directions into one list."""
|
|
facility = make_facility()
|
|
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
|
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
|
|
link_type='related'))
|
|
db.session.commit()
|
|
|
|
assert len(a.all_links()) == 1
|
|
assert len(b.all_links()) == 1
|
|
assert IssueLink.query.count() == 1
|
|
|
|
|
|
def test_exists_between_is_direction_agnostic(app, db, make_issue, make_facility):
|
|
"""The stored UniqueConstraint only covers one direction -- this covers both."""
|
|
facility = make_facility()
|
|
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
|
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
|
|
link_type='related'))
|
|
db.session.commit()
|
|
|
|
assert IssueLink.exists_between(a.id, b.id)
|
|
assert IssueLink.exists_between(b.id, a.id)
|
|
assert not IssueLink.exists_between(a.id, 99999)
|
|
|
|
|
|
def test_deleting_an_issue_removes_its_links_from_both_sides(
|
|
app, db, make_issue, make_facility):
|
|
"""A surviving link would render a dead row on the other issue's page."""
|
|
facility = make_facility()
|
|
a, b, c = (make_issue(facility=facility) for _ in range(3))
|
|
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id, link_type='related'))
|
|
db.session.add(IssueLink(issue_id=c.id, linked_issue_id=a.id, link_type='duplicate'))
|
|
db.session.commit()
|
|
assert IssueLink.query.count() == 2
|
|
|
|
db.session.delete(a) # the path both the single and bulk delete use
|
|
db.session.commit()
|
|
|
|
assert IssueLink.query.count() == 0
|
|
assert b.all_links() == []
|
|
assert c.all_links() == []
|
|
|
|
|
|
# ── Creating links through the route ─────────────────────────────────────────
|
|
|
|
def test_admin_can_link_two_issues(client, login, make_user, make_facility,
|
|
make_issue, link_url):
|
|
facility = make_facility()
|
|
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
|
login(make_user(role='admin'))
|
|
|
|
res = client.post(link_url(a),
|
|
data={'link_type': 'duplicate', 'linked_issue_id': str(b.id)},
|
|
follow_redirects=True)
|
|
assert res.status_code == 200
|
|
link = IssueLink.query.one()
|
|
assert (link.issue_id, link.linked_issue_id) == (a.id, b.id)
|
|
assert link.link_type == 'duplicate'
|
|
|
|
|
|
def test_a_leading_hash_is_accepted(client, login, make_user, make_facility,
|
|
make_issue, link_url):
|
|
"""People type '#412' because that is how the id is shown everywhere."""
|
|
facility = make_facility()
|
|
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
|
login(make_user(role='admin'))
|
|
|
|
client.post(link_url(a),
|
|
data={'link_type': 'related', 'linked_issue_id': f'#{b.id}'},
|
|
follow_redirects=True)
|
|
assert IssueLink.query.count() == 1
|
|
|
|
|
|
def test_linking_does_not_touch_either_issue(client, login, make_user,
|
|
make_facility, make_issue, link_url):
|
|
"""The chosen design: a link is navigation, not a workflow action."""
|
|
facility = make_facility()
|
|
a = make_issue(facility=facility, status='open')
|
|
b = make_issue(facility=facility, status='open')
|
|
login(make_user(role='admin'))
|
|
|
|
client.post(link_url(a),
|
|
data={'link_type': 'duplicate', 'linked_issue_id': str(b.id)},
|
|
follow_redirects=True)
|
|
|
|
_db.session.refresh(a)
|
|
_db.session.refresh(b)
|
|
assert a.status == 'open' and a.resolved_at is None and a.assigned_to is None
|
|
assert b.status == 'open' and b.resolved_at is None
|
|
|
|
|
|
@pytest.mark.parametrize('payload', [
|
|
{'link_type': 'related', 'linked_issue_id': 'abc'},
|
|
{'link_type': 'related', 'linked_issue_id': ''},
|
|
{'link_type': 'nonsense', 'linked_issue_id': '1'},
|
|
{'link_type': '', 'linked_issue_id': '1'},
|
|
{},
|
|
])
|
|
def test_malformed_link_requests_are_rejected_without_error(
|
|
client, login, make_user, make_facility, make_issue, link_url, payload):
|
|
facility = make_facility()
|
|
a = make_issue(facility=facility)
|
|
make_issue(facility=facility)
|
|
login(make_user(role='admin'))
|
|
|
|
res = client.post(link_url(a), data=payload, follow_redirects=True)
|
|
assert res.status_code == 200
|
|
assert IssueLink.query.count() == 0
|
|
|
|
|
|
def test_an_issue_cannot_be_linked_to_itself(client, login, make_user,
|
|
make_facility, make_issue, link_url):
|
|
facility = make_facility()
|
|
a = make_issue(facility=facility)
|
|
login(make_user(role='admin'))
|
|
|
|
client.post(link_url(a),
|
|
data={'link_type': 'related', 'linked_issue_id': str(a.id)},
|
|
follow_redirects=True)
|
|
assert IssueLink.query.count() == 0
|
|
|
|
|
|
def test_the_same_pair_cannot_be_linked_twice_in_either_direction(
|
|
client, login, make_user, make_facility, make_issue, link_url):
|
|
facility = make_facility()
|
|
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
|
login(make_user(role='admin'))
|
|
|
|
client.post(link_url(a), data={'link_type': 'related',
|
|
'linked_issue_id': str(b.id)},
|
|
follow_redirects=True)
|
|
client.post(link_url(a), data={'link_type': 'duplicate',
|
|
'linked_issue_id': str(b.id)},
|
|
follow_redirects=True)
|
|
client.post(link_url(b), data={'link_type': 'duplicate',
|
|
'linked_issue_id': str(a.id)},
|
|
follow_redirects=True)
|
|
|
|
assert IssueLink.query.count() == 1
|
|
|
|
|
|
def test_unlinking_removes_the_row_and_works_from_either_end(
|
|
client, login, make_user, make_facility, make_issue, db):
|
|
facility = make_facility()
|
|
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
|
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
|
|
link_type='related'))
|
|
db.session.commit()
|
|
link_id = IssueLink.query.one().id
|
|
|
|
login(make_user(role='admin'))
|
|
# from the far end, which is the row's linked_issue_id
|
|
client.post(f'/issues/{b.id}/links/{link_id}/delete', follow_redirects=True)
|
|
assert IssueLink.query.count() == 0
|
|
|
|
|
|
def test_cannot_delete_a_link_between_two_other_issues(
|
|
client, login, make_user, make_facility, make_issue, db):
|
|
"""The link id is posted by the client, so it must be checked against
|
|
the issue in the URL -- otherwise any link is deletable from anywhere."""
|
|
facility = make_facility()
|
|
a, b, unrelated = (make_issue(facility=facility) for _ in range(3))
|
|
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
|
|
link_type='related'))
|
|
db.session.commit()
|
|
link_id = IssueLink.query.one().id
|
|
|
|
login(make_user(role='admin'))
|
|
client.post(f'/issues/{unrelated.id}/links/{link_id}/delete',
|
|
follow_redirects=True)
|
|
assert IssueLink.query.count() == 1
|
|
|
|
|
|
# ── Permission ───────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize('role', ['admin', 'director', 'auditor'])
|
|
def test_issue_managers_may_link(client, login, make_user, make_facility,
|
|
make_issue, link_url, role):
|
|
facility = make_facility()
|
|
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
|
login(make_user(role=role))
|
|
client.post(link_url(a), data={'link_type': 'related',
|
|
'linked_issue_id': str(b.id)},
|
|
follow_redirects=True)
|
|
assert IssueLink.query.count() == 1
|
|
|
|
|
|
def test_the_assignee_may_link_their_own_issue(client, login, make_user, db,
|
|
make_facility, make_issue,
|
|
make_project, link_url):
|
|
project = make_project()
|
|
facility = make_facility(project=project)
|
|
inspector = make_user(role='inspector')
|
|
db.session.add(InspectorAssignment(user_id=inspector.id, project_id=project.id))
|
|
db.session.commit()
|
|
|
|
a = make_issue(facility=facility, assigned_to=inspector.id)
|
|
b = make_issue(facility=facility)
|
|
|
|
login(inspector)
|
|
client.post(link_url(a), data={'link_type': 'related',
|
|
'linked_issue_id': str(b.id)},
|
|
follow_redirects=True)
|
|
assert IssueLink.query.count() == 1
|
|
|
|
|
|
def test_a_project_manager_may_not_link(client, login, make_user,
|
|
make_facility, make_issue, link_url):
|
|
"""Matches the page's existing can_edit set. Widening this is a decision,
|
|
not an accident -- if it changes, issues/view.html must change too."""
|
|
facility = make_facility()
|
|
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
|
login(make_user(role='project_manager'))
|
|
|
|
res = client.post(link_url(a), data={'link_type': 'related',
|
|
'linked_issue_id': str(b.id)})
|
|
assert res.status_code == 403
|
|
assert IssueLink.query.count() == 0
|
|
|
|
|
|
def test_a_customer_may_not_link_even_on_their_own_facility(
|
|
client, login, make_user, db, make_facility, make_issue,
|
|
make_project, link_url):
|
|
project = make_project()
|
|
facility = make_facility(project=project)
|
|
customer = make_user(role='customer')
|
|
db.session.add(CustomerAssignment(user_id=customer.id, project_id=project.id))
|
|
db.session.commit()
|
|
|
|
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
|
|
|
login(customer)
|
|
res = client.post(link_url(a), data={'link_type': 'related',
|
|
'linked_issue_id': str(b.id)})
|
|
assert res.status_code == 403
|
|
assert IssueLink.query.count() == 0
|
|
|
|
|
|
# ── Scope: the part that actually matters ────────────────────────────────────
|
|
|
|
def test_cannot_link_to_an_issue_outside_your_scope(
|
|
client, login, make_user, db, make_facility, make_issue,
|
|
make_project, link_url):
|
|
"""An inspector must not be able to attach another contract's issue."""
|
|
mine = make_project('Mine')
|
|
theirs = make_project('Theirs')
|
|
my_facility = make_facility(project=mine)
|
|
their_facility = make_facility(project=theirs)
|
|
|
|
inspector = make_user(role='inspector')
|
|
db.session.add(InspectorAssignment(user_id=inspector.id, project_id=mine.id))
|
|
db.session.commit()
|
|
|
|
a = make_issue(facility=my_facility, assigned_to=inspector.id)
|
|
out_of_scope = make_issue(facility=their_facility,
|
|
description='another contract')
|
|
|
|
login(inspector)
|
|
res = client.post(link_url(a),
|
|
data={'link_type': 'related',
|
|
'linked_issue_id': str(out_of_scope.id)},
|
|
follow_redirects=True)
|
|
|
|
assert IssueLink.query.count() == 0
|
|
# And the refusal must not confirm the issue exists.
|
|
body = res.get_data(as_text=True)
|
|
assert 'another contract' not in body
|
|
|
|
|
|
def test_the_panel_hides_a_link_whose_far_end_is_out_of_scope(
|
|
client, login, make_user, db, make_facility, make_issue, make_project):
|
|
"""An admin can link across contracts. A customer at one end must still not
|
|
read the issue at the other."""
|
|
mine = make_project('Mine')
|
|
theirs = make_project('Theirs')
|
|
my_facility = make_facility(project=mine)
|
|
their_facility = make_facility(project=theirs)
|
|
|
|
customer = make_user(role='customer')
|
|
db.session.add(CustomerAssignment(user_id=customer.id, project_id=mine.id))
|
|
db.session.commit()
|
|
|
|
visible = make_issue(facility=my_facility, description='my own issue')
|
|
hidden = make_issue(facility=their_facility,
|
|
description='SECRET other customer issue')
|
|
db.session.add(IssueLink(issue_id=visible.id, linked_issue_id=hidden.id,
|
|
link_type='related'))
|
|
db.session.commit()
|
|
|
|
login(customer)
|
|
body = client.get(f'/issues/{visible.id}').get_data(as_text=True)
|
|
|
|
assert 'my own issue' in body
|
|
assert 'SECRET other customer issue' not in body
|
|
assert f'/issues/{hidden.id}' not in body
|
|
|
|
|
|
def test_link_search_is_scoped(client, login, make_user, db, make_facility,
|
|
make_issue, make_project):
|
|
mine = make_project('Mine')
|
|
theirs = make_project('Theirs')
|
|
my_facility = make_facility(project=mine)
|
|
their_facility = make_facility(project=theirs)
|
|
|
|
inspector = make_user(role='inspector')
|
|
db.session.add(InspectorAssignment(user_id=inspector.id, project_id=mine.id))
|
|
db.session.commit()
|
|
|
|
a = make_issue(facility=my_facility, description='mine mine mine')
|
|
findable = make_issue(facility=my_facility, description='findable mine')
|
|
hidden = make_issue(facility=their_facility, description='findable theirs')
|
|
|
|
login(inspector)
|
|
data = client.get(f'/issues/{a.id}/link-search?q=findable').get_json()
|
|
ids = {r['id'] for r in data['results']}
|
|
|
|
assert findable.id in ids
|
|
assert hidden.id not in ids
|
|
|
|
|
|
def test_link_search_excludes_self_and_already_linked(
|
|
client, login, make_user, db, make_facility, make_issue):
|
|
facility = make_facility()
|
|
a = make_issue(facility=facility, description='widget one')
|
|
already = make_issue(facility=facility, description='widget two')
|
|
free = make_issue(facility=facility, description='widget three')
|
|
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=already.id,
|
|
link_type='related'))
|
|
db.session.commit()
|
|
|
|
login(make_user(role='admin'))
|
|
data = client.get(f'/issues/{a.id}/link-search?q=widget').get_json()
|
|
ids = {r['id'] for r in data['results']}
|
|
|
|
assert ids == {free.id}
|
|
|
|
|
|
def test_link_search_finds_by_issue_number(client, login, make_user,
|
|
make_facility, make_issue):
|
|
facility = make_facility()
|
|
a = make_issue(facility=facility)
|
|
target = make_issue(facility=facility, description='nothing in common')
|
|
|
|
login(make_user(role='admin'))
|
|
data = client.get(f'/issues/{a.id}/link-search?q={target.id}').get_json()
|
|
assert target.id in {r['id'] for r in data['results']}
|
|
|
|
|
|
def test_link_search_returns_nothing_for_an_unscoped_inspector(
|
|
client, login, make_user, make_facility, make_issue):
|
|
"""Strict scoping: no assignments means no candidates, not all of them."""
|
|
facility = make_facility()
|
|
a = make_issue(facility=facility, description='findable')
|
|
make_issue(facility=facility, description='findable too')
|
|
|
|
inspector = make_user(role='inspector')
|
|
login(inspector)
|
|
# No InspectorAssignment -> cannot even open the issue.
|
|
assert client.get(f'/issues/{a.id}/link-search?q=findable').status_code == 403
|
|
|
|
|
|
# ── Rendering ────────────────────────────────────────────────────────────────
|
|
|
|
def test_the_panel_renders_both_directions(client, login, make_user, db,
|
|
make_facility, make_issue):
|
|
facility = make_facility()
|
|
subject = make_issue(facility=facility, description='the one being viewed')
|
|
original = make_issue(facility=facility, description='the original')
|
|
other = make_issue(facility=facility, description='a related thing')
|
|
|
|
db.session.add(IssueLink(issue_id=subject.id, linked_issue_id=original.id,
|
|
link_type='duplicate'))
|
|
db.session.add(IssueLink(issue_id=other.id, linked_issue_id=subject.id,
|
|
link_type='duplicate'))
|
|
db.session.commit()
|
|
|
|
login(make_user(role='admin'))
|
|
body = client.get(f'/issues/{subject.id}').get_data(as_text=True)
|
|
|
|
assert 'Duplicate of' in body # subject -> original
|
|
assert 'Duplicated by' in body # other -> subject
|
|
assert f'/issues/{original.id}' in body
|
|
assert f'/issues/{other.id}' in body
|
|
|
|
|
|
def test_the_panel_renders_with_no_links(client, login, make_user,
|
|
make_facility, make_issue):
|
|
facility = make_facility()
|
|
issue = make_issue(facility=facility)
|
|
login(make_user(role='admin'))
|
|
|
|
body = client.get(f'/issues/{issue.id}').get_data(as_text=True)
|
|
assert 'Linked Issues' in body
|
|
assert 'No linked issues' in body
|