502 lines
17 KiB
Python
502 lines
17 KiB
Python
"""
|
|
tests/test_issue_links.py
|
|
-------------------------
|
|
phase57 — link a duplicate to its original, or two issues about the same thing.
|
|
|
|
Two things are being pinned here.
|
|
|
|
**The direction convention.** One row is stored per pair and shown on BOTH
|
|
issues, so the same row has to read differently at each end ("Duplicate of #B"
|
|
on one, "Duplicated by #A" on the other). That is what makes `exists_between()`
|
|
necessary: `(A,B)` and `(B,A)` are distinct rows to the database but the same
|
|
link to a person, and the UniqueConstraint only covers the stored direction.
|
|
|
|
**Scope, which is the part that actually matters.** A link is a pointer that
|
|
exposes the far issue's id, description, facility and status. Three surfaces
|
|
have to hold the line and only one of them is a real boundary:
|
|
|
|
_readable_links() filters what the panel RENDERS
|
|
link_search() scopes what the picker FINDS
|
|
add_link() re-checks on POST — the search is a convenience
|
|
|
|
If any one of them is unfiltered, a customer reads an issue at a facility they
|
|
hold no assignment to, simply because one of our staff linked it.
|
|
|
|
Cross-TENANT isolation is not tested here and cannot be: `issue_links` lives in
|
|
the tenant database and every query routes through RoutingSession, so an id
|
|
from another tenant does not resolve at all. See tests/test_tenant_isolation.py
|
|
for that layer. What follows is scope WITHIN one tenant.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
with app.app_context():
|
|
from app import db
|
|
db.drop_all()
|
|
db.create_all()
|
|
yield app.test_client()
|
|
db.session.remove()
|
|
|
|
|
|
# ── Builders ─────────────────────────────────────────────────────────────────
|
|
|
|
_n = {'i': 0}
|
|
|
|
|
|
def _uniq():
|
|
_n['i'] += 1
|
|
return _n['i']
|
|
|
|
|
|
def _user(role='admin', **kw):
|
|
from app import db
|
|
from app.models.user import User
|
|
n = _uniq()
|
|
u = User(username=kw.pop('username', f'{role}{n}'),
|
|
full_name=f'{role.title()} {n}',
|
|
email=f'{role}{n}@example.com',
|
|
role=role, active=True, password_set=True)
|
|
u.set_password('pw-correct1')
|
|
for k, v in kw.items():
|
|
setattr(u, k, v)
|
|
db.session.add(u)
|
|
db.session.commit()
|
|
return u
|
|
|
|
|
|
def _project(name=None):
|
|
from app import db
|
|
from app.models.project import Project
|
|
p = Project(name=name or f'Contract {_uniq()}', active=True)
|
|
db.session.add(p)
|
|
db.session.commit()
|
|
return p
|
|
|
|
|
|
def _facility(project=None, name=None):
|
|
from app import db
|
|
from app.models.facility import Facility
|
|
f = Facility(name=name or f'Facility {_uniq()}', active=True,
|
|
project_id=project.id if project else None)
|
|
db.session.add(f)
|
|
db.session.commit()
|
|
return f
|
|
|
|
|
|
def _issue(facility=None, description='Test issue', **kw):
|
|
from app import db
|
|
from app.models.issue import Issue
|
|
from app.utils.time_utils import now_eastern
|
|
i = Issue(facility_id=facility.id if facility else None,
|
|
description=description,
|
|
severity=kw.pop('severity', 'medium'),
|
|
status=kw.pop('status', 'open'),
|
|
reported_at=kw.pop('reported_at', now_eastern()))
|
|
for k, v in kw.items():
|
|
setattr(i, k, v)
|
|
db.session.add(i)
|
|
db.session.commit()
|
|
return i
|
|
|
|
|
|
def _link(a, b, link_type='related'):
|
|
from app import db
|
|
from app.models.issue import IssueLink
|
|
link = IssueLink(issue_id=a.id, linked_issue_id=b.id, link_type=link_type)
|
|
db.session.add(link)
|
|
db.session.commit()
|
|
return link
|
|
|
|
|
|
def _assign_inspector(user, project):
|
|
from app import db
|
|
from app.models.inspector_assignment import InspectorAssignment
|
|
db.session.add(InspectorAssignment(user_id=user.id, project_id=project.id))
|
|
db.session.commit()
|
|
|
|
|
|
def _assign_customer(user, project):
|
|
from app import db
|
|
from app.models.project import CustomerAssignment
|
|
db.session.add(CustomerAssignment(user_id=user.id, project_id=project.id))
|
|
db.session.commit()
|
|
|
|
|
|
def _login(client, user):
|
|
return client.post('/auth/login',
|
|
data={'username': user.username, 'password': 'pw-correct1'},
|
|
follow_redirects=False)
|
|
|
|
|
|
def _link_count():
|
|
from app.models.issue import IssueLink
|
|
return IssueLink.query.count()
|
|
|
|
|
|
# ── Model semantics ──────────────────────────────────────────────────────────
|
|
|
|
def test_a_link_reads_differently_from_each_end(client):
|
|
"""The stored direction carries meaning for 'duplicate'."""
|
|
a, b = _issue(description='dupe'), _issue(description='original')
|
|
link = _link(a, b, 'duplicate')
|
|
|
|
assert link.label_for(a.id) == 'Duplicate of'
|
|
assert link.label_for(b.id) == 'Duplicated by'
|
|
assert link.other_issue(a.id).id == b.id
|
|
assert link.other_issue(b.id).id == a.id
|
|
|
|
|
|
def test_related_reads_the_same_from_both_ends(client):
|
|
a, b = _issue(), _issue()
|
|
link = _link(a, b, 'related')
|
|
assert link.label_for(a.id) == 'Related to'
|
|
assert link.label_for(b.id) == 'Related to'
|
|
|
|
|
|
def test_one_row_appears_on_both_issues(client):
|
|
"""all_links() merges the two storage directions into one list."""
|
|
a, b = _issue(), _issue()
|
|
_link(a, b)
|
|
|
|
assert _link_count() == 1
|
|
assert len(a.all_links()) == 1
|
|
assert len(b.all_links()) == 1
|
|
|
|
|
|
def test_exists_between_is_direction_agnostic(client):
|
|
"""The UniqueConstraint only covers the stored direction; this covers both."""
|
|
from app.models.issue import IssueLink
|
|
|
|
a, b = _issue(), _issue()
|
|
_link(a, b)
|
|
|
|
assert IssueLink.exists_between(a.id, b.id)
|
|
assert IssueLink.exists_between(b.id, a.id)
|
|
assert not IssueLink.exists_between(a.id, _issue().id)
|
|
|
|
|
|
def test_deleting_an_issue_removes_its_links_from_both_sides(client):
|
|
"""A surviving link would render a dead row on the other issue's page."""
|
|
from app import db
|
|
|
|
a, b, c = _issue(), _issue(), _issue()
|
|
_link(a, b) # a is the source
|
|
_link(c, a) # a is the target
|
|
assert _link_count() == 2
|
|
|
|
db.session.delete(a)
|
|
db.session.commit()
|
|
|
|
assert _link_count() == 0
|
|
|
|
|
|
# ── Creating links through the route ─────────────────────────────────────────
|
|
|
|
def test_admin_can_link_two_issues(client):
|
|
admin = _user('admin')
|
|
a, b = _issue(), _issue()
|
|
_login(client, admin)
|
|
|
|
client.post(f'/issues/{a.id}/links',
|
|
data={'link_type': 'duplicate', 'linked_issue_id': str(b.id)},
|
|
follow_redirects=True)
|
|
|
|
from app.models.issue import IssueLink
|
|
link = IssueLink.query.one()
|
|
assert (link.issue_id, link.linked_issue_id) == (a.id, b.id)
|
|
assert link.link_type == 'duplicate'
|
|
assert link.created_by == admin.id
|
|
|
|
|
|
def test_a_leading_hash_is_accepted(client):
|
|
"""People type the issue number the way it is displayed."""
|
|
_login(client, _user('admin'))
|
|
a, b = _issue(), _issue()
|
|
|
|
client.post(f'/issues/{a.id}/links',
|
|
data={'link_type': 'related', 'linked_issue_id': f'#{b.id}'},
|
|
follow_redirects=True)
|
|
|
|
assert _link_count() == 1
|
|
|
|
|
|
def test_linking_does_not_touch_either_issue(client):
|
|
"""A link is PURELY NAVIGATIONAL — no status, SLA, assignee or follower."""
|
|
from app import db
|
|
from app.models.issue import Issue, IssueFollower
|
|
|
|
_login(client, _user('admin'))
|
|
a = _issue(status='open', severity='high')
|
|
b = _issue(status='in_progress', severity='low')
|
|
before = [(i.id, i.status, i.severity, i.assigned_to, i.resolved_at,
|
|
i.sla_notified) for i in (a, b)]
|
|
|
|
client.post(f'/issues/{a.id}/links',
|
|
data={'link_type': 'duplicate', 'linked_issue_id': str(b.id)},
|
|
follow_redirects=True)
|
|
|
|
# db.session.get, not Model.query.get — SQLAlchemy 2.x (CLAUDE.md rule 11).
|
|
after = [(i.id, i.status, i.severity, i.assigned_to, i.resolved_at,
|
|
i.sla_notified)
|
|
for i in (db.session.get(Issue, a.id), db.session.get(Issue, b.id))]
|
|
assert before == after
|
|
assert IssueFollower.query.count() == 0
|
|
|
|
|
|
@pytest.mark.parametrize('payload', [
|
|
{'link_type': 'related', 'linked_issue_id': 'not-a-number'},
|
|
{'link_type': 'related', 'linked_issue_id': ''},
|
|
{'link_type': 'nonsense', 'linked_issue_id': '1'},
|
|
{'linked_issue_id': '1'},
|
|
])
|
|
def test_malformed_link_requests_are_rejected_without_error(client, payload):
|
|
_login(client, _user('admin'))
|
|
a = _issue()
|
|
|
|
res = client.post(f'/issues/{a.id}/links', data=payload,
|
|
follow_redirects=True)
|
|
|
|
assert res.status_code == 200
|
|
assert _link_count() == 0
|
|
|
|
|
|
def test_an_issue_cannot_be_linked_to_itself(client):
|
|
_login(client, _user('admin'))
|
|
a = _issue()
|
|
|
|
client.post(f'/issues/{a.id}/links',
|
|
data={'link_type': 'related', 'linked_issue_id': str(a.id)},
|
|
follow_redirects=True)
|
|
|
|
assert _link_count() == 0
|
|
|
|
|
|
def test_the_same_pair_cannot_be_linked_twice_in_either_direction(client):
|
|
"""The reverse direction is the case the UniqueConstraint cannot catch."""
|
|
_login(client, _user('admin'))
|
|
a, b = _issue(), _issue()
|
|
|
|
client.post(f'/issues/{a.id}/links',
|
|
data={'link_type': 'related', 'linked_issue_id': str(b.id)},
|
|
follow_redirects=True)
|
|
client.post(f'/issues/{b.id}/links',
|
|
data={'link_type': 'duplicate', 'linked_issue_id': str(a.id)},
|
|
follow_redirects=True)
|
|
|
|
assert _link_count() == 1
|
|
|
|
|
|
def test_unlinking_works_from_either_end(client):
|
|
_login(client, _user('admin'))
|
|
a, b = _issue(), _issue()
|
|
link = _link(a, b)
|
|
|
|
# From the far end — the row is stored on a, deleted from b's page.
|
|
client.post(f'/issues/{b.id}/links/{link.id}/delete', follow_redirects=True)
|
|
|
|
assert _link_count() == 0
|
|
|
|
|
|
def test_cannot_delete_a_link_between_two_other_issues(client):
|
|
"""The link must actually touch the issue named in the URL."""
|
|
_login(client, _user('admin'))
|
|
a, b, unrelated = _issue(), _issue(), _issue()
|
|
link = _link(a, b)
|
|
|
|
client.post(f'/issues/{unrelated.id}/links/{link.id}/delete',
|
|
follow_redirects=True)
|
|
|
|
assert _link_count() == 1
|
|
|
|
|
|
# ── Permission ───────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize('role', ['admin', 'director', 'auditor'])
|
|
def test_issue_managers_may_link(client, role):
|
|
_login(client, _user(role))
|
|
a, b = _issue(), _issue()
|
|
|
|
client.post(f'/issues/{a.id}/links',
|
|
data={'link_type': 'related', 'linked_issue_id': str(b.id)},
|
|
follow_redirects=True)
|
|
|
|
assert _link_count() == 1
|
|
|
|
|
|
def test_the_assignee_may_link_their_own_issue(client):
|
|
"""_can_manage_links mirrors the page's can_edit, which includes the assignee."""
|
|
project = _project()
|
|
facility = _facility(project)
|
|
inspector = _user('inspector')
|
|
_assign_inspector(inspector, project)
|
|
|
|
a = _issue(facility=facility, assigned_to=inspector.id)
|
|
b = _issue(facility=facility)
|
|
|
|
_login(client, inspector)
|
|
client.post(f'/issues/{a.id}/links',
|
|
data={'link_type': 'related', 'linked_issue_id': str(b.id)},
|
|
follow_redirects=True)
|
|
|
|
assert _link_count() == 1
|
|
|
|
|
|
def test_a_project_manager_may_not_link(client):
|
|
"""Widening this is a one-line change — but change view.html's can_edit too."""
|
|
_login(client, _user('project_manager'))
|
|
a, b = _issue(), _issue()
|
|
|
|
res = client.post(f'/issues/{a.id}/links',
|
|
data={'link_type': 'related', 'linked_issue_id': str(b.id)})
|
|
|
|
assert res.status_code == 403
|
|
assert _link_count() == 0
|
|
|
|
|
|
def test_a_customer_may_not_link_even_on_their_own_facility(client):
|
|
project = _project()
|
|
facility = _facility(project)
|
|
customer = _user('customer')
|
|
_assign_customer(customer, project)
|
|
|
|
a = _issue(facility=facility)
|
|
b = _issue(facility=facility)
|
|
|
|
_login(client, customer)
|
|
res = client.post(f'/issues/{a.id}/links',
|
|
data={'link_type': 'related', 'linked_issue_id': str(b.id)})
|
|
|
|
assert res.status_code == 403
|
|
assert _link_count() == 0
|
|
|
|
|
|
# ── Scope: the part that actually matters ────────────────────────────────────
|
|
|
|
def test_cannot_link_to_an_issue_outside_your_scope(client):
|
|
"""An inspector must not be able to attach another contract's issue."""
|
|
mine, theirs = _project('Mine'), _project('Theirs')
|
|
my_facility, their_facility = _facility(mine), _facility(theirs)
|
|
|
|
inspector = _user('inspector')
|
|
_assign_inspector(inspector, mine)
|
|
|
|
a = _issue(facility=my_facility, assigned_to=inspector.id)
|
|
out_of_scope = _issue(facility=their_facility, description='another contract')
|
|
|
|
_login(client, inspector)
|
|
res = client.post(f'/issues/{a.id}/links',
|
|
data={'link_type': 'related',
|
|
'linked_issue_id': str(out_of_scope.id)},
|
|
follow_redirects=True)
|
|
|
|
assert _link_count() == 0
|
|
# And the refusal must not confirm the issue exists.
|
|
assert 'another contract' not in res.get_data(as_text=True)
|
|
|
|
|
|
def test_the_panel_hides_a_link_whose_far_end_is_out_of_scope(client):
|
|
"""An admin can link across contracts. A customer at one end must still not
|
|
read the issue at the other."""
|
|
mine, theirs = _project('Mine'), _project('Theirs')
|
|
my_facility, their_facility = _facility(mine), _facility(theirs)
|
|
|
|
customer = _user('customer')
|
|
_assign_customer(customer, mine)
|
|
|
|
visible = _issue(facility=my_facility, description='my own issue')
|
|
hidden = _issue(facility=their_facility,
|
|
description='SECRET other customer issue')
|
|
_link(visible, hidden)
|
|
|
|
_login(client, 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):
|
|
mine, theirs = _project('Mine'), _project('Theirs')
|
|
my_facility, their_facility = _facility(mine), _facility(theirs)
|
|
|
|
inspector = _user('inspector')
|
|
_assign_inspector(inspector, mine)
|
|
|
|
a = _issue(facility=my_facility, description='mine mine mine')
|
|
_issue(facility=my_facility, description='mine also')
|
|
_issue(facility=their_facility, description='mine but theirs')
|
|
|
|
_login(client, inspector)
|
|
results = client.get(f'/issues/{a.id}/link-search?q=mine').get_json()['results']
|
|
|
|
descriptions = {r['description'] for r in results}
|
|
assert 'mine also' in descriptions
|
|
assert 'mine but theirs' not in descriptions
|
|
|
|
|
|
def test_link_search_excludes_self_and_already_linked(client):
|
|
_login(client, _user('admin'))
|
|
a = _issue(description='alpha one')
|
|
b = _issue(description='alpha two')
|
|
_link(a, b)
|
|
|
|
ids = {r['id'] for r in
|
|
client.get(f'/issues/{a.id}/link-search?q=alpha').get_json()['results']}
|
|
|
|
assert a.id not in ids
|
|
assert b.id not in ids
|
|
|
|
|
|
def test_link_search_finds_by_issue_number(client):
|
|
_login(client, _user('admin'))
|
|
a = _issue(description='alpha')
|
|
b = _issue(description='beta')
|
|
|
|
ids = {r['id'] for r in
|
|
client.get(f'/issues/{a.id}/link-search?q={b.id}').get_json()['results']}
|
|
|
|
assert b.id in ids
|
|
|
|
|
|
def test_link_search_returns_nothing_for_an_unscoped_inspector(client):
|
|
"""Empty scope means no access to anything — rule 57's fail-closed default."""
|
|
inspector = _user('inspector') # no InspectorAssignment rows
|
|
a = _issue(facility=_facility(_project()), assigned_to=inspector.id)
|
|
|
|
_login(client, inspector)
|
|
res = client.get(f'/issues/{a.id}/link-search?q=a')
|
|
|
|
# Either the issue itself is unreachable (403) or the search finds nothing.
|
|
assert res.status_code == 403 or res.get_json()['results'] == []
|
|
|
|
|
|
# ── Rendering ────────────────────────────────────────────────────────────────
|
|
|
|
def test_the_panel_renders_both_directions(client):
|
|
_login(client, _user('admin'))
|
|
original = _issue(description='the original')
|
|
dupe = _issue(description='the duplicate')
|
|
_link(dupe, original, 'duplicate')
|
|
|
|
on_dupe = client.get(f'/issues/{dupe.id}').get_data(as_text=True)
|
|
on_original = client.get(f'/issues/{original.id}').get_data(as_text=True)
|
|
|
|
assert 'Duplicate of' in on_dupe
|
|
assert f'/issues/{original.id}' in on_dupe
|
|
assert 'Duplicated by' in on_original
|
|
assert f'/issues/{dupe.id}' in on_original
|
|
|
|
|
|
def test_the_panel_renders_with_no_links(client):
|
|
_login(client, _user('admin'))
|
|
a = _issue()
|
|
|
|
body = client.get(f'/issues/{a.id}').get_data(as_text=True)
|
|
|
|
assert 'Linked Issues' in body
|
|
assert 'No linked issues.' in body
|