bottleneckcrm/backend/tasks/tests/test_task_access.py
Ashwin dd6ca2958d Add unit tests for organization admin checks and saved views provider
- Implement tests for `isOrgAdminProvider` to validate admin roles based on organization membership and role.
- Create tests for `saved_views_provider` to ensure correct API interactions for saving, renaming, and deleting saved views.
- Enhance ticket tree tests to accurately reflect inactive and merged ticket handling.
- Update CSV import tests to verify error handling and file saving functionality.
- Introduce tests for deal cards to ensure correct display of next steps and task statuses.
2026-09-26 20:38:32 +05:30

667 lines
26 KiB
Python

"""What the task endpoints let people do, and what they must not.
Everything here was driven against the running server before it was written
down, and every defect it pins was reproduced live first. The list, in the
order a person would meet them:
1. **A member could create a task and then never open it again.** `POST`
succeeded, the task appeared in their list, and `GET` answered 500, with
`PATCH`, comment and `DELETE` all answering 403. `created_by` is a `User`
and every check compared it to a `Profile`, so the creator clause was dead,
except in the list query, which spelled it correctly and therefore
listed tasks the detail view refused.
2. **No non-admin could open *any* task, including their own.**
`created_by.user.email`, `created_by` is already the user, threw
`AttributeError` behind an always-true branch.
3. **The 403 path was a 500.** `get_context_data` returned a `Response`,
which `get()` wrapped in a second one: "Object of type Response is not
JSON serializable".
4. **Any admin could delete any attachment in the database.** `Attachments`
is one generic table and the lookup had no `org=`. Proven by destroying a
file that belonged to another org and hung off a lead, not a task.
5. **The uploader could not delete their own attachment**: same
`Profile`/`User` comparison.
6. **`created_by` was writable.** A task could be handed a creator from
another org. Harmless only while the field was never read; the moment
fix (1) lands it is a route to `delete`.
7. **The four parent FKs were unscoped.** A task could be created holding
another org's account, and the list rendered that org's account name back.
8. **"One parent entity" did not survive `PATCH`.** Sending a second parent
one request at a time walked past the serializer, and the model's own
refusal surfaced as a 500 because Django's `ValidationError` is not DRF's.
9. **Every malformed id was a 500**: task, comment and attachment, on
every verb, and a well-formed id for a missing comment or attachment too.
"""
import uuid
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from accounts.models import Account
from cases.models import Case
from common.models import Attachments, Profile, User
from leads.models import Lead
from opportunity.models import Opportunity
from tasks.models import Task
def _task(org, creator, assignees=(), **kwargs):
"""A task owned by ``creator``.
``BaseModel.save()`` stamps ``created_by`` from the crum thread-local,
which is empty in a test, so passing it to ``create()`` is silently
dropped. ``.update()`` goes round ``save()`` and actually sets it, the
same trick the contacts and cases suites needed.
"""
task = Task.objects.create(
title=kwargs.pop("title", "A task"),
status=kwargs.pop("status", "New"),
priority=kwargs.pop("priority", "Medium"),
org=org,
**kwargs,
)
Task.objects.filter(pk=task.pk).update(created_by=creator)
task.assigned_to.set(assignees)
task.refresh_from_db()
return task
@pytest.fixture
def other_user():
return User.objects.create_user(email="other@test.com", password="testpass123")
@pytest.fixture
def other_profile(other_user, org_a):
"""A second plain member of org A: the person on neither side of a task."""
return Profile.objects.create(
user=other_user, org=org_a, role="USER", is_active=True
)
@pytest.fixture
def other_client(other_user, org_a, other_profile):
from conftest import _make_authenticated_client
return _make_authenticated_client(other_user, org_a, other_profile)
@pytest.mark.django_db
class TestWhoMayOpenATask:
"""`access`: admin, creator, assignee. Each clause, both directions."""
def test_the_creator_can_open_their_own_task(
self, user_client, regular_user, admin_user, org_a
):
"""The headline. This was a 500, which is why the rest of the file exists."""
task = _task(org_a, regular_user)
response = user_client.get(f"/api/tasks/{task.id}/")
assert response.status_code == 200
assert response.json()["task_obj"]["title"] == "A task"
def test_an_assignee_can_open_a_task_somebody_else_made(
self, user_client, user_profile, admin_user, org_a
):
task = _task(org_a, admin_user, assignees=[user_profile])
assert user_client.get(f"/api/tasks/{task.id}/").status_code == 200
def test_a_member_on_neither_side_is_refused(
self, other_client, admin_user, user_profile, org_a
):
task = _task(org_a, admin_user, assignees=[user_profile])
assert other_client.get(f"/api/tasks/{task.id}/").status_code == 403
def test_the_refusal_is_a_403_and_not_a_500(self, other_client, admin_user, org_a):
"""`get_context_data` used to *return* a Response, which `get()` then
wrapped in another one. The denial branch never rendered."""
task = _task(org_a, admin_user)
response = other_client.get(f"/api/tasks/{task.id}/")
assert response.status_code == 403
assert "Permission" in str(response.json())
def test_an_admin_can_open_a_task_they_have_nothing_to_do_with(
self, admin_client, regular_user, org_a
):
task = _task(org_a, regular_user)
assert admin_client.get(f"/api/tasks/{task.id}/").status_code == 200
def test_another_org_gets_404_not_403(self, org_b_client, admin_user, org_a):
"""Not "you may not", which would confirm the task exists."""
task = _task(org_a, admin_user)
assert org_b_client.get(f"/api/tasks/{task.id}/").status_code == 404
@pytest.mark.django_db
class TestTheListAndTheDetailAgree:
"""A queue that lists what it will not open is the bug this module fixes."""
def test_everything_a_member_can_list_they_can_also_open(
self, user_client, regular_user, admin_user, user_profile, org_a
):
_task(org_a, regular_user, title="Mine, made by me")
_task(org_a, admin_user, assignees=[user_profile], title="Mine, handed to me")
_task(org_a, admin_user, title="Not mine at all")
listed = user_client.get("/api/tasks/").json()["tasks"]
assert {row["title"] for row in listed} == {
"Mine, made by me",
"Mine, handed to me",
}
for row in listed:
assert user_client.get(f"/api/tasks/{row['id']}/").status_code == 200
def test_an_admin_sees_the_whole_org(
self, admin_client, regular_user, admin_user, org_a
):
_task(org_a, regular_user, title="Theirs")
_task(org_a, admin_user, title="Mine")
assert admin_client.get("/api/tasks/").json()["tasks_count"] == 2
def test_the_list_stops_at_the_org_boundary(
self, admin_client, admin_user, org_a, org_b
):
_task(org_a, admin_user, title="Ours")
_task(org_b, admin_user, title="Theirs")
titles = [t["title"] for t in admin_client.get("/api/tasks/").json()["tasks"]]
assert titles == ["Ours"]
@pytest.mark.django_db
class TestWritingAndDeletingDifferOnPurpose:
"""`access` covers reading and writing; `delete` is narrower.
Being handed a task is a reason to work it, not a reason to erase it, the
same line `cases.access` draws for an assignee, and it has to be asserted
separately or a later tidy-up will collapse the two.
"""
def test_an_assignee_may_edit(self, user_client, user_profile, admin_user, org_a):
task = _task(org_a, admin_user, assignees=[user_profile])
response = user_client.patch(
f"/api/tasks/{task.id}/", {"priority": "High"}, format="json"
)
assert response.status_code == 200
task.refresh_from_db()
assert task.priority == "High"
def test_an_assignee_may_not_delete(
self, user_client, user_profile, admin_user, org_a
):
task = _task(org_a, admin_user, assignees=[user_profile])
assert user_client.delete(f"/api/tasks/{task.id}/").status_code == 403
assert Task.objects.filter(pk=task.pk).exists()
def test_the_creator_may_delete(self, user_client, regular_user, org_a):
task = _task(org_a, regular_user)
assert user_client.delete(f"/api/tasks/{task.id}/").status_code == 200
assert not Task.objects.filter(pk=task.pk).exists()
def test_an_admin_may_delete_anything_in_the_org(
self, admin_client, regular_user, org_a
):
task = _task(org_a, regular_user)
assert admin_client.delete(f"/api/tasks/{task.id}/").status_code == 200
def test_a_member_on_neither_side_may_not_edit(
self, other_client, admin_user, org_a
):
task = _task(org_a, admin_user)
response = other_client.patch(
f"/api/tasks/{task.id}/", {"priority": "High"}, format="json"
)
assert response.status_code == 403
def test_a_member_on_neither_side_may_not_comment(
self, other_client, admin_user, org_a
):
task = _task(org_a, admin_user)
response = other_client.post(
f"/api/tasks/{task.id}/", {"comment": "hello"}, format="json"
)
assert response.status_code == 403
def test_the_creator_can_run_the_whole_loop(self, user_client, regular_user, org_a):
"""Create, open, edit, comment, delete, as one plain member.
Before the fix this read 200 / 500 / 403 / 403 / 403: a create endpoint
that handed every non-admin a task they could never touch again.
"""
created = user_client.post(
"/api/tasks/",
{"title": "Ring the customer", "status": "New", "priority": "Low"},
format="json",
)
assert created.status_code == 200
task = Task.objects.get(title="Ring the customer")
assert task.created_by == regular_user
assert user_client.get(f"/api/tasks/{task.id}/").status_code == 200
assert (
user_client.patch(
f"/api/tasks/{task.id}/", {"status": "In Progress"}, format="json"
).status_code
== 200
)
assert (
user_client.post(
f"/api/tasks/{task.id}/", {"comment": "left a voicemail"}, format="json"
).status_code
== 200
)
assert user_client.delete(f"/api/tasks/{task.id}/").status_code == 200
@pytest.mark.django_db
class TestCreatedByIsServerDerived:
"""It decides `delete`, so it cannot come from the request body."""
def test_a_client_cannot_name_the_creator_on_create(
self, user_client, regular_user, admin_user, org_a
):
user_client.post(
"/api/tasks/",
{
"title": "Claimed",
"status": "New",
"priority": "Low",
"created_by": str(admin_user.id),
},
format="json",
)
assert Task.objects.get(title="Claimed").created_by == regular_user
def test_a_client_cannot_rewrite_the_creator_on_update(
self, user_client, user_profile, regular_user, admin_user, org_a
):
"""An assignee may edit the task. If they could also edit this field
they would be granting themselves the right to delete it."""
task = _task(org_a, admin_user, assignees=[user_profile])
response = user_client.patch(
f"/api/tasks/{task.id}/",
{"created_by": str(regular_user.id)},
format="json",
)
assert response.status_code == 200
task.refresh_from_db()
assert task.created_by == admin_user
assert user_client.delete(f"/api/tasks/{task.id}/").status_code == 403
def test_a_client_cannot_borrow_a_creator_from_another_org(
self, admin_client, admin_user, user_b, org_a
):
task = _task(org_a, admin_user)
admin_client.patch(
f"/api/tasks/{task.id}/", {"created_by": str(user_b.id)}, format="json"
)
task.refresh_from_db()
assert task.created_by == admin_user
@pytest.mark.django_db
class TestParentsAreScopedToTheOrg:
"""A task links to one account, opportunity, case or lead, one of *ours*.
Every one of these was a 200 before, and the list endpoint then rendered
the other org's record name back to the requester.
"""
def test_an_account_from_another_org_is_refused(self, admin_client, org_a, org_b):
foreign = Account.objects.create(name="Their Co", org=org_b)
response = admin_client.post(
"/api/tasks/",
{
"title": "Reach out",
"status": "New",
"priority": "Low",
"account": str(foreign.id),
},
format="json",
)
assert response.status_code == 400
assert "account" in response.json()["errors"]
assert not Task.objects.filter(title="Reach out").exists()
def test_a_lead_from_another_org_is_refused(self, admin_client, org_a, org_b):
foreign = Lead.objects.create(first_name="Their", last_name="Lead", org=org_b)
response = admin_client.post(
"/api/tasks/",
{
"title": "Chase",
"status": "New",
"priority": "Low",
"lead": str(foreign.id),
},
format="json",
)
assert response.status_code == 400
def test_a_case_from_another_org_is_refused_on_update(
self, admin_client, admin_user, org_a, org_b
):
task = _task(org_a, admin_user)
foreign = Case.objects.create(name="Their ticket", status="New", org=org_b)
response = admin_client.patch(
f"/api/tasks/{task.id}/", {"case": str(foreign.id)}, format="json"
)
assert response.status_code == 400
task.refresh_from_db()
assert task.case_id is None
def test_an_opportunity_from_our_own_org_is_accepted(
self, admin_client, admin_user, org_a
):
"""The refusals above have to be about the org and not about the field."""
ours = Opportunity.objects.create(name="Ours", stage="QUALIFICATION", org=org_a)
task = _task(org_a, admin_user)
response = admin_client.patch(
f"/api/tasks/{task.id}/", {"opportunity": str(ours.id)}, format="json"
)
assert response.status_code == 200
task.refresh_from_db()
assert task.opportunity_id == ours.id
@pytest.mark.django_db
class TestOneParentEntity:
"""The model has always said so; the API only enforced it per request."""
def test_two_parents_in_one_request_are_refused(self, admin_client, org_a):
account = Account.objects.create(name="Acme", org=org_a)
lead = Lead.objects.create(first_name="Jo", last_name="Blow", org=org_a)
response = admin_client.post(
"/api/tasks/",
{
"title": "Both",
"status": "New",
"priority": "Low",
"account": str(account.id),
"lead": str(lead.id),
},
format="json",
)
assert response.status_code == 400
def test_a_second_parent_added_later_is_refused(
self, admin_client, admin_user, org_a
):
"""One at a time used to look like a single-parent request and pass.
It then hit `Task.clean()` inside `save()` and came back as a 500,
because Django's ValidationError is not DRF's.
"""
account = Account.objects.create(name="Acme", org=org_a)
lead = Lead.objects.create(first_name="Jo", last_name="Blow", org=org_a)
task = _task(org_a, admin_user, account=account)
response = admin_client.patch(
f"/api/tasks/{task.id}/", {"lead": str(lead.id)}, format="json"
)
assert response.status_code == 400
assert "one parent entity" in str(response.json()["errors"])
task.refresh_from_db()
assert task.lead_id is None
assert task.account_id == account.id
def test_swapping_one_parent_for_another_in_a_single_request_works(
self, admin_client, admin_user, org_a
):
"""Otherwise a task's parent would be impossible to change."""
account = Account.objects.create(name="Acme", org=org_a)
lead = Lead.objects.create(first_name="Jo", last_name="Blow", org=org_a)
task = _task(org_a, admin_user, account=account)
response = admin_client.patch(
f"/api/tasks/{task.id}/",
{"account": None, "lead": str(lead.id)},
format="json",
)
assert response.status_code == 200
task.refresh_from_db()
assert task.account_id is None
assert task.lead_id == lead.id
def test_editing_something_else_on_a_task_with_one_parent_still_works(
self, admin_client, admin_user, org_a
):
"""The rule must read the payload's *effect*, not just count columns."""
account = Account.objects.create(name="Acme", org=org_a)
task = _task(org_a, admin_user, account=account)
response = admin_client.patch(
f"/api/tasks/{task.id}/", {"priority": "High"}, format="json"
)
assert response.status_code == 200
@pytest.mark.django_db
class TestAttachmentDeleteIsScopedToTheOrg:
"""`Attachments` is one table shared by every module.
Without `org=` this endpoint was "delete any attachment in the database by
UUID" for anybody's admin. Proven live against a file in another org that
was attached to a lead.
"""
def _attachment(self, org, uploader, task=None):
target = task or _task(org, uploader)
attachment = Attachments(
file_name="notes.txt",
content_object=target,
org=org,
attachment=SimpleUploadedFile("notes.txt", b"hello"),
)
attachment.save()
Attachments.objects.filter(pk=attachment.pk).update(created_by=uploader)
return attachment
def test_an_admin_cannot_reach_another_orgs_attachment(
self, admin_client, user_b, org_b
):
attachment = self._attachment(org_b, user_b)
assert (
admin_client.delete(f"/api/tasks/attachment/{attachment.id}/").status_code
== 404
)
assert Attachments.objects.filter(pk=attachment.pk).exists()
def test_the_uploader_can_delete_their_own(self, user_client, regular_user, org_a):
attachment = self._attachment(org_a, regular_user)
assert (
user_client.delete(f"/api/tasks/attachment/{attachment.id}/").status_code
== 200
)
def test_another_member_cannot_delete_it(
self, other_client, other_profile, regular_user, org_a
):
attachment = self._attachment(org_a, regular_user)
# A reader of the task who did not upload it; a non-reader gets 404
# (`test_attachment_and_comment_reach_only_visible_tasks.py`).
attachment.content_object.assigned_to.add(other_profile)
assert (
other_client.delete(f"/api/tasks/attachment/{attachment.id}/").status_code
== 403
)
assert Attachments.objects.filter(pk=attachment.pk).exists()
def test_an_admin_of_the_same_org_can(self, admin_client, regular_user, org_a):
attachment = self._attachment(org_a, regular_user)
assert (
admin_client.delete(f"/api/tasks/attachment/{attachment.id}/").status_code
== 200
)
@pytest.mark.django_db
class TestMissingAndMalformedIds:
"""A bad id is a request for something that does not exist. That is a 404."""
BAD = "not-a-uuid"
def test_every_task_verb_survives_a_malformed_id(self, admin_client):
assert admin_client.get(f"/api/tasks/{self.BAD}/").status_code == 404
assert (
admin_client.patch(
f"/api/tasks/{self.BAD}/", {"priority": "Low"}, format="json"
).status_code
== 404
)
assert (
admin_client.put(
f"/api/tasks/{self.BAD}/", {"title": "x"}, format="json"
).status_code
== 404
)
assert (
admin_client.post(
f"/api/tasks/{self.BAD}/", {"comment": "x"}, format="json"
).status_code
== 404
)
assert admin_client.delete(f"/api/tasks/{self.BAD}/").status_code == 404
def test_a_well_formed_id_for_no_task_is_also_404(self, admin_client):
assert admin_client.get(f"/api/tasks/{uuid.uuid4()}/").status_code == 404
def test_comment_and_attachment_ids_too(self, admin_client):
for prefix in ("comment", "attachment"):
assert (
admin_client.delete(f"/api/tasks/{prefix}/{self.BAD}/").status_code
== 404
)
assert (
admin_client.delete(f"/api/tasks/{prefix}/{uuid.uuid4()}/").status_code
== 404
)
@pytest.mark.django_db
class TestTheListPayload:
"""Totals and the form catalogues, in the shape cases and deals publish."""
def test_totals_count_the_queryset_not_the_page(
self, admin_client, admin_user, org_a
):
for i in range(12):
_task(org_a, admin_user, title=f"Task {i}")
totals = admin_client.get("/api/tasks/?limit=5").json()["totals"]
assert totals["count"] == 12
assert totals["open"] == 12
def test_overdue_ignores_finished_work(self, admin_client, admin_user, org_a):
"""A task completed late is not something anybody can act on."""
from datetime import timedelta
yesterday = timezone.localdate() - timedelta(days=1)
_task(org_a, admin_user, title="Late and open", due_date=yesterday)
_task(
org_a,
admin_user,
title="Late but done",
due_date=yesterday,
status="Completed",
)
totals = admin_client.get("/api/tasks/").json()["totals"]
assert totals["overdue"] == 1
assert totals["open"] == 1
assert totals["count"] == 2
def test_totals_are_the_viewers_totals(
self, user_client, regular_user, admin_user, org_a
):
_task(org_a, regular_user, title="Mine")
_task(org_a, admin_user, title="Not mine")
assert user_client.get("/api/tasks/").json()["totals"]["count"] == 1
def test_the_form_options_are_there(self, admin_client, admin_user, org_a):
Account.objects.create(name="Acme", org=org_a)
body = admin_client.get("/api/tasks/").json()
assert [row[0] for row in body["status"]] == ["New", "In Progress", "Completed"]
assert [row[0] for row in body["priority"]] == ["Low", "Medium", "High"]
assert len(body["accounts_list"]) == 1
assert "contacts_list" in body
assert body["users"][0]["user__email"] == admin_user.email
def test_slim_drops_the_catalogues(self, admin_client, org_a):
Account.objects.create(name="Acme", org=org_a)
body = admin_client.get("/api/tasks/?slim=true").json()
assert "accounts_list" not in body
assert "contacts_list" not in body
assert "totals" in body
def test_a_member_is_only_offered_admins_to_assign_to(
self, user_client, admin_user, admin_profile, other_profile, org_a
):
"""Same narrowing the detail view has always applied.
`other_profile` is a second plain member, and its absence from the
answer is the point. Otherwise this would pass on an empty list.
"""
emails = [
row["user__email"] for row in user_client.get("/api/tasks/").json()["users"]
]
assert emails == [admin_user.email]
def test_the_list_does_not_cost_a_query_per_row(
self, admin_client, admin_user, org_a, django_assert_num_queries
):
"""Fixed count, not a ceiling: a ceiling passes while it rots.
The first request of a process pays for a few one-off lookups, the
JWT's user and profile, the content types, so the count depends on
what ran before it. Measuring the *second* request makes the number
the query cost of serving the page and nothing else, which is the
number worth pinning.
"""
for i in range(12):
_task(org_a, admin_user, title=f"Task {i}")
admin_client.get("/api/tasks/?limit=12&slim=true")
with django_assert_num_queries(12):
admin_client.get("/api/tasks/?limit=12&slim=true")
def test_twelve_rows_cost_the_same_as_two(
self, admin_client, admin_user, org_a, django_assert_num_queries
):
"""The claim the number above is really making.
Before `select_related`/`prefetch_related` the parents and assignees
were fetched per row, so this is the assertion that would have caught
it, and it holds whatever the fixed number turns out to be.
"""
for i in range(2):
_task(org_a, admin_user, title=f"Small {i}")
admin_client.get("/api/tasks/?slim=true")
with django_assert_num_queries(12):
admin_client.get("/api/tasks/?slim=true")
for i in range(20):
_task(org_a, admin_user, title=f"Big {i}")
with django_assert_num_queries(12):
admin_client.get("/api/tasks/?limit=25&slim=true")
@pytest.mark.django_db
class TestCrossOrgIsolationOnTheOtherVerbs:
"""RLS is the safety net. The explicit org filter is the contract."""
def test_another_orgs_admin_cannot_edit(self, org_b_client, admin_user, org_a):
task = _task(org_a, admin_user)
response = org_b_client.patch(
f"/api/tasks/{task.id}/", {"title": "Theirs now"}, format="json"
)
assert response.status_code == 404
task.refresh_from_db()
assert task.title == "A task"
def test_another_orgs_admin_cannot_delete(self, org_b_client, admin_user, org_a):
task = _task(org_a, admin_user)
assert org_b_client.delete(f"/api/tasks/{task.id}/").status_code == 404
assert Task.objects.filter(pk=task.pk).exists()
def test_another_orgs_admin_cannot_comment(self, org_b_client, admin_user, org_a):
task = _task(org_a, admin_user)
response = org_b_client.post(
f"/api/tasks/{task.id}/", {"comment": "hello"}, format="json"
)
assert response.status_code == 404