""" 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