bottleneckcrm/backend/common/tests/test_documents_access.py
Ashwin 41d4148edb feat: Enhance goal tracking with currency support and separate totals
- Updated goal model to include currency, allowing for multiple currencies in goals.
- Modified goal totals calculation to keep currencies separate and accurately reflect targets and achievements.
- Added tests to ensure correct handling of multiple currencies in goal tracking.
- Enhanced invoice reports to support multiple currencies, ensuring accurate reporting without summing across different currencies.
- Implemented lead board provider tests to validate lead movement and pipeline functionality.
- Introduced record edit verb tests for accounts, contacts, and leads, ensuring PATCH requests are correctly formed.
- Improved tag normalization to handle non-Latin scripts and prevent duplicates.
2026-09-26 10:06:25 +05:30

436 lines
18 KiB
Python

"""Access rules for /api/documents/.
There was no coverage of this endpoint at all, which is why three separate
defects survived in it: the list crashed for non-admins on an AttributeError,
the object-level checks compared a `Profile` to a `User` FK and were therefore
always False, and `Document.teams` was written but never read.
Every check below asserts BOTH directions, a permission test that only ever
sees one answer cannot tell a working check from a constant.
"""
import pytest
from common.models import Document, Teams
DOCUMENTS_URL = "/api/documents/"
def _detail_url(pk):
return f"/api/documents/{pk}/"
def _doc(org, user, **kw):
"""Create a Document owned by `user`. `created_by` is a **User** FK.
The second step is not optional: `BaseModel.save()` overwrites
`created_by` from crum's `get_current_user()`, which is None outside a
request, so passing it to `create()` has no effect. `.update()` goes
straight to SQL and is the only way to set it from a test.
"""
doc = Document.objects.create(
title=kw.pop("title", "Doc"),
document_file=kw.pop("document_file", "documents/probe.pdf"),
status=kw.pop("status", "active"),
org=org,
**kw,
)
Document.objects.filter(pk=doc.pk).update(created_by=user)
doc.refresh_from_db()
return doc
def _active_titles(response):
return {d["title"] for d in response.json()["documents_active"]["documents_active"]}
@pytest.mark.django_db
class TestDocumentListAccess:
def test_non_admin_list_does_not_500(self, user_client, regular_user, org_a):
"""Regression: `request.profile.documents()` does not exist on Profile.
This raised AttributeError -> 500 for every non-ADMIN caller, which is
most of them. Admins skipped the branch, so the endpoint looked fine.
"""
_doc(org_a, regular_user, title="Mine")
response = user_client.get(DOCUMENTS_URL)
assert response.status_code == 200
def test_non_admin_sees_own_upload(self, user_client, regular_user, org_a):
_doc(org_a, regular_user, title="Mine")
response = user_client.get(DOCUMENTS_URL)
assert "Mine" in _active_titles(response)
def test_non_admin_sees_document_shared_with_them(
self, user_client, admin_user, org_a, user_profile
):
doc = _doc(org_a, admin_user, title="Shared with me")
doc.shared_to.add(user_profile)
response = user_client.get(DOCUMENTS_URL)
assert "Shared with me" in _active_titles(response)
def test_non_admin_sees_document_shared_with_their_team(
self, user_client, admin_user, org_a, user_profile
):
"""Regression: `teams` was written by POST/PUT and read by no query."""
doc = _doc(org_a, admin_user, title="Shared with my team")
team = Teams.objects.create(name="Support", org=org_a, created_by=admin_user)
team.users.add(user_profile)
doc.teams.add(team)
response = user_client.get(DOCUMENTS_URL)
assert "Shared with my team" in _active_titles(response)
def test_non_admin_does_not_see_unshared_document(
self, user_client, admin_user, org_a
):
"""The other direction: no share, no team, not the creator -> invisible."""
_doc(org_a, admin_user, title="Not for you")
response = user_client.get(DOCUMENTS_URL)
assert "Not for you" not in _active_titles(response)
def test_non_admin_does_not_see_other_teams_document(
self, user_client, admin_user, org_a, user_profile
):
"""Being in *a* team is not being in *the* team."""
doc = _doc(org_a, admin_user, title="Other team only")
mine = Teams.objects.create(name="Mine", org=org_a, created_by=admin_user)
mine.users.add(user_profile)
theirs = Teams.objects.create(name="Theirs", org=org_a, created_by=admin_user)
doc.teams.add(theirs)
response = user_client.get(DOCUMENTS_URL)
assert "Other team only" not in _active_titles(response)
def test_admin_sees_everything_in_the_org(self, admin_client, admin_user, org_a):
_doc(org_a, admin_user, title="Admin visible")
response = admin_client.get(DOCUMENTS_URL)
assert "Admin visible" in _active_titles(response)
def test_list_with_only_archived_documents_does_not_500(
self, admin_client, admin_user, org_a
):
"""Regression: the inactive-envelope offset was keyed off the *active*
results (`results_documents_active[-1]`), a copy-paste from the block
above. An org with archived documents but no active ones made that an
empty-list `[-1]` -> IndexError -> 500. Archiving the last document is
exactly how a real user reaches this state.
"""
_doc(org_a, admin_user, title="Archived only", status="inactive")
response = admin_client.get(DOCUMENTS_URL)
assert response.status_code == 200
inactive = response.json()["documents_inactive"]["documents_inactive"]
assert "Archived only" in {d["title"] for d in inactive}
assert response.json()["documents_active"]["documents_active"] == []
def test_list_is_not_duplicated_by_multiple_grants(
self, user_client, regular_user, org_a, user_profile
):
"""Creator AND share AND team is one row, not three.
The visibility filter joins two M2Ms, so without .distinct() a
document reachable more than one way is returned once per join row.
"""
doc = _doc(org_a, regular_user, title="Reachable three ways")
doc.shared_to.add(user_profile)
team = Teams.objects.create(name="Support", org=org_a, created_by=regular_user)
team.users.add(user_profile)
doc.teams.add(team)
titles = [
d["title"]
for d in user_client.get(DOCUMENTS_URL).json()["documents_active"][
"documents_active"
]
]
assert titles.count("Reachable three ways") == 1
@pytest.mark.django_db
class TestDocumentDetailAccess:
def test_creator_can_open_own_document(
self, user_client, regular_user, org_a, user_profile
):
"""Regression: `request.profile == document.created_by` compared a
Profile to a User FK, so it was always False and the uploader got 403
on their own file."""
doc = _doc(org_a, regular_user, title="Mine")
response = user_client.get(_detail_url(doc.id))
assert response.status_code == 200
def test_shared_user_can_open(self, user_client, admin_user, org_a, user_profile):
doc = _doc(org_a, admin_user)
doc.shared_to.add(user_profile)
assert user_client.get(_detail_url(doc.id)).status_code == 200
def test_team_member_can_open(self, user_client, admin_user, org_a, user_profile):
doc = _doc(org_a, admin_user)
team = Teams.objects.create(name="Support", org=org_a, created_by=admin_user)
team.users.add(user_profile)
doc.teams.add(team)
assert user_client.get(_detail_url(doc.id)).status_code == 200
def test_stranger_is_forbidden(self, user_client, admin_user, org_a):
"""The other direction. The check must still be able to say no."""
doc = _doc(org_a, admin_user)
assert user_client.get(_detail_url(doc.id)).status_code == 403
def test_other_org_is_404_not_403(self, user_client, user_b, org_b):
"""Cross-org must not confirm the row exists."""
doc = _doc(org_b, user_b)
assert user_client.get(_detail_url(doc.id)).status_code == 404
@pytest.mark.django_db
class TestDocumentDelete:
def test_creator_can_delete_own_document(
self, user_client, regular_user, org_a, user_profile
):
doc = _doc(org_a, regular_user)
assert user_client.delete(_detail_url(doc.id)).status_code == 200
assert not Document.objects.filter(id=doc.id).exists()
def test_admin_can_delete_anyones_document(self, admin_client, regular_user, org_a):
doc = _doc(org_a, regular_user)
assert admin_client.delete(_detail_url(doc.id)).status_code == 200
def test_shared_user_cannot_delete(
self, user_client, admin_user, org_a, user_profile
):
"""Being able to read a document is not being able to destroy it.
`_may_delete` is deliberately narrower than `_may_read`: a share hands
someone a copy to work with, not the right to remove it from everyone
else.
"""
doc = _doc(org_a, admin_user)
doc.shared_to.add(user_profile)
assert user_client.delete(_detail_url(doc.id)).status_code == 403
assert Document.objects.filter(id=doc.id).exists()
def test_team_member_cannot_delete(
self, user_client, admin_user, org_a, user_profile
):
doc = _doc(org_a, admin_user)
team = Teams.objects.create(name="Support", org=org_a, created_by=admin_user)
team.users.add(user_profile)
doc.teams.add(team)
assert user_client.delete(_detail_url(doc.id)).status_code == 403
@pytest.mark.django_db
class TestDocumentUpdate:
def test_creator_can_update_own_document(
self, user_client, regular_user, org_a, user_profile
):
doc = _doc(org_a, regular_user, title="Before")
response = user_client.patch(
_detail_url(doc.id), {"title": "After"}, content_type="application/json"
)
assert response.status_code == 200
doc.refresh_from_db()
assert doc.title == "After"
def test_stranger_cannot_update(self, user_client, admin_user, org_a):
doc = _doc(org_a, admin_user, title="Before")
response = user_client.patch(
_detail_url(doc.id), {"title": "After"}, content_type="application/json"
)
assert response.status_code == 403
doc.refresh_from_db()
assert doc.title == "Before"
def test_admin_can_update_anyones_document(self, admin_client, regular_user, org_a):
"""The permissive direction for an admin: not the creator, still allowed."""
doc = _doc(org_a, regular_user, title="Before")
response = admin_client.patch(
_detail_url(doc.id), {"title": "After"}, content_type="application/json"
)
assert response.status_code == 200
doc.refresh_from_db()
assert doc.title == "After"
def test_shared_user_cannot_update(
self, user_client, admin_user, org_a, user_profile
):
"""Reading via a share does not grant rewriting.
PUT/PATCH once authorised on `_may_read`, so anyone a document was
shared with could overwrite it. Editing is now gated by `_may_write`,
which is as narrow as delete: the creator or an admin only. A merely
shared-with user can open it and gets 403 on any change.
"""
doc = _doc(org_a, admin_user, title="Before")
doc.shared_to.add(user_profile)
response = user_client.patch(
_detail_url(doc.id), {"title": "After"}, content_type="application/json"
)
assert response.status_code == 403
doc.refresh_from_db()
assert doc.title == "Before"
def test_shared_user_cannot_update_via_put(
self, user_client, admin_user, org_a, user_profile
):
"""PUT is the sharper case: it also clears `shared_to`/`teams`, so a
shared-with user reaching it could have locked everyone else out. It is
gated by the same `_may_write`, and shares survive the refused call."""
doc = _doc(org_a, admin_user, title="Before")
doc.shared_to.add(user_profile)
response = user_client.put(
_detail_url(doc.id), {"title": "After"}, content_type="application/json"
)
assert response.status_code == 403
doc.refresh_from_db()
assert doc.title == "Before"
assert list(doc.shared_to.all()) == [user_profile]
def test_team_member_cannot_update(
self, user_client, admin_user, org_a, user_profile
):
"""A team share is a read grant, not a write grant, exactly like delete."""
doc = _doc(org_a, admin_user, title="Before")
team = Teams.objects.create(name="Support", org=org_a, created_by=admin_user)
team.users.add(user_profile)
doc.teams.add(team)
response = user_client.patch(
_detail_url(doc.id), {"title": "After"}, content_type="application/json"
)
assert response.status_code == 403
doc.refresh_from_db()
assert doc.title == "Before"
def test_creator_can_update_via_put(self, user_client, regular_user, org_a):
"""The creator keeps full edit rights through PUT, not just PATCH."""
doc = _doc(org_a, regular_user, title="Before")
response = user_client.put(
_detail_url(doc.id), {"title": "After"}, content_type="application/json"
)
assert response.status_code == 200
doc.refresh_from_db()
assert doc.title == "After"
def _inactive_profile(org, email="gone@test.com"):
from common.models import Profile, User
user = User.objects.create_user(email=email, password="testpass123")
return Profile.objects.create(user=user, org=org, role="USER", is_active=False)
def _shared_ids(doc):
return set(doc.shared_to.values_list("id", flat=True))
@pytest.mark.django_db
class TestDocumentPutSharing:
"""PUT carries the whole share list, and a share to somebody deactivated
since survives it when the body names them again.
The PUT used to clear `shared_to` and re-add only ACTIVE profiles, so any
change to a document's sharing silently dropped every share to a
deactivated person, resubmitted or not. The rule now: the body is the full
list; a resubmitted id is kept if it is an active profile in the caller's
org OR already one of this document's shares. That second arm only reaches
ids the document already holds, so it cannot add a new inactive share, and
everything is still filtered on the caller's org.
"""
def test_resubmitted_inactive_share_is_kept(
self, user_client, regular_user, org_a, admin_profile
):
gone = _inactive_profile(org_a)
doc = _doc(org_a, regular_user, title="Before")
doc.shared_to.add(gone, admin_profile)
response = user_client.put(
_detail_url(doc.id),
{"title": "Before", "shared_to": [str(gone.id), str(admin_profile.id)]},
format="json",
)
assert response.status_code == 200
assert _shared_ids(doc) == {gone.id, admin_profile.id}
def test_omitted_inactive_share_is_removed(
self, user_client, regular_user, org_a, admin_profile
):
"""The body is the full list, for inactive shares as for active ones."""
gone = _inactive_profile(org_a)
doc = _doc(org_a, regular_user, title="Before")
doc.shared_to.add(gone, admin_profile)
response = user_client.put(
_detail_url(doc.id),
{"title": "Before", "shared_to": [str(admin_profile.id)]},
format="json",
)
assert response.status_code == 200
assert _shared_ids(doc) == {admin_profile.id}
def test_cannot_add_a_new_inactive_share(
self, user_client, regular_user, org_a, admin_profile
):
gone = _inactive_profile(org_a)
doc = _doc(org_a, regular_user, title="Before")
response = user_client.put(
_detail_url(doc.id),
{"title": "Before", "shared_to": [str(gone.id), str(admin_profile.id)]},
format="json",
)
assert response.status_code == 200
assert _shared_ids(doc) == {admin_profile.id}
def test_cannot_add_another_orgs_profile(
self, user_client, regular_user, org_a, profile_b
):
doc = _doc(org_a, regular_user, title="Before")
response = user_client.put(
_detail_url(doc.id),
{"title": "Before", "shared_to": [str(profile_b.id)]},
format="json",
)
assert response.status_code == 200
assert _shared_ids(doc) == set()
def test_active_shares_are_replaced(
self, user_client, regular_user, org_a, admin_profile, user_profile
):
doc = _doc(org_a, regular_user, title="Before")
doc.shared_to.add(admin_profile)
response = user_client.put(
_detail_url(doc.id),
{"title": "Before", "shared_to": [str(user_profile.id)]},
format="json",
)
assert response.status_code == 200
assert _shared_ids(doc) == {user_profile.id}
def test_shared_user_cannot_change_sharing(
self, user_client, admin_user, org_a, user_profile, admin_profile
):
"""A share is a read grant: its holder cannot re-share or unshare."""
doc = _doc(org_a, admin_user, title="Before")
doc.shared_to.add(user_profile)
response = user_client.put(
_detail_url(doc.id),
{"title": "Before", "shared_to": [str(admin_profile.id)]},
format="json",
)
assert response.status_code == 403
assert _shared_ids(doc) == {user_profile.id}
def test_malformed_list_changes_nothing(
self, user_client, regular_user, org_a, admin_profile
):
"""A 400 must not leave half a write behind.
The ids used to be parsed after the title was saved and the shares
cleared, so a malformed list answered 400 with the rename applied and
every share gone.
"""
doc = _doc(org_a, regular_user, title="Before")
doc.shared_to.add(admin_profile)
response = user_client.put(
_detail_url(doc.id),
{"title": "After", "shared_to": "[not json"},
format="json",
)
assert response.status_code == 400
doc.refresh_from_db()
assert doc.title == "Before"
assert _shared_ids(doc) == {admin_profile.id}