bottleneckcrm/backend/common/tests/test_pat_org_api.py
Ashwin 3a4ef40ba5 feat: Implement close-with-children dialog and related settings
- Added `close_with_children_dialog.dart` to handle the close ticket prompt with linked tickets.
- Updated `_closeWithChildren` method in `ticket_detail_screen.dart` to utilize the new dialog and respect organization settings for default checkbox state.
- Enhanced ticket closing feedback messages based on whether linked tickets were also closed.
- Introduced unit tests for `AccessToken` and `OrgSettings` to ensure proper serialization and functionality.
- Added tests for the new close-with-children dialog to verify UI behavior and settings integration.
- Updated existing tests to cover new functionality and ensure no regressions.
2026-08-08 01:18:27 +05:30

216 lines
9.2 KiB
Python

"""Org-wide token oversight, the admin's cross-user view and revoke.
Distinct from test_pat_api.py, which covers the SELF-scoped /profile/tokens/
endpoints. These endpoints (/org/tokens/) are the admin oversight half added
for the /v2/settings/api-tokens page: read every token in the org, revoke any
of them. Every rule is asserted both ways, the allowed path AND the forbidden
one, because personal_access_token has no RLS, so the explicit org filter and
the IsOrgAdmin gate are the only barriers and a silent regression in either
would leak or let through cross-tenant/cross-role access.
"""
import uuid
from datetime import timedelta
import pytest
from django.test import Client
from django.utils import timezone
from common.models import PersonalAccessToken
LIST_URL = "/api/org/tokens/"
def _detail_url(pk):
return f"/api/org/tokens/{pk}/"
@pytest.mark.django_db
class TestOrgTokenList:
def test_admin_sees_every_owner_in_org(
self, admin_client, admin_profile, user_profile
):
# Two owners, one org. The self-scoped list would show only the admin's;
# this oversight list must show both.
_, mine = PersonalAccessToken.generate(profile=admin_profile, name="mine")
_, theirs = PersonalAccessToken.generate(profile=user_profile, name="theirs")
body = admin_client.get(LIST_URL).json()
assert body["error"] is False
ids = {t["id"] for t in body["tokens"]}
assert str(mine.id) in ids
assert str(theirs.id) in ids
assert body["totals"]["count"] == 2
# Owner block is present and names the person + role; is_active carried.
by_id = {t["id"]: t for t in body["tokens"]}
assert by_id[str(theirs.id)]["owner"]["role"] == "USER"
assert by_id[str(theirs.id)]["owner"]["is_active"] is True
def test_non_admin_is_forbidden(self, user_client, user_profile):
# A member must not see the whole org's tokens, not even their own via
# this endpoint; self-service lives on /profile/tokens/.
PersonalAccessToken.generate(profile=user_profile, name="mine")
resp = user_client.get(LIST_URL)
assert resp.status_code == 403, resp.content
def test_cross_org_isolation(self, org_b_client, admin_profile, profile_b):
# No RLS on this table: the org filter is the ONLY barrier. Org B's admin
# must never see org A's token.
_, a_token = PersonalAccessToken.generate(
profile=admin_profile, name="org-a-secret"
)
_, b_token = PersonalAccessToken.generate(profile=profile_b, name="org-b-own")
body = org_b_client.get(LIST_URL).json()
ids = {t["id"] for t in body["tokens"]}
assert str(a_token.id) not in ids
assert str(b_token.id) in ids
assert body["totals"]["count"] == 1
def test_orphaned_counts_live_token_on_deactivated_owner(
self, admin_client, admin_profile, user_profile
):
user_profile.is_active = False
user_profile.save()
PersonalAccessToken.generate(profile=user_profile, name="left-behind")
body = admin_client.get(LIST_URL).json()
assert body["totals"]["orphaned"] == 1
assert body["totals"]["live"] == 1
row = body["tokens"][0]
assert row["owner"]["is_active"] is False
assert row["is_live"] is True # token-valid, even though auth would reject it
def test_revoked_and_expired_are_not_orphaned_or_live(
self, admin_client, admin_profile, user_profile
):
user_profile.is_active = False
user_profile.save()
_, revoked = PersonalAccessToken.generate(profile=user_profile, name="revoked")
revoked.revoked_at = timezone.now()
revoked.save()
PersonalAccessToken.generate(
profile=user_profile,
name="expired",
expires_at=timezone.now() - timedelta(days=1),
)
body = admin_client.get(LIST_URL).json()
assert body["totals"]["count"] == 2
assert body["totals"]["live"] == 0
assert body["totals"]["orphaned"] == 0
def test_unused_90d_counted(self, admin_client, admin_profile):
_, stale = PersonalAccessToken.generate(profile=admin_profile, name="stale")
PersonalAccessToken.objects.filter(pk=stale.pk).update(
last_used_at=timezone.now() - timedelta(days=120)
)
_, fresh = PersonalAccessToken.generate(profile=admin_profile, name="fresh")
PersonalAccessToken.objects.filter(pk=fresh.pk).update(
last_used_at=timezone.now()
)
body = admin_client.get(LIST_URL).json()
assert body["totals"]["unused_90d"] == 1
def test_a_token_just_issued_is_not_unused_for_90_days(
self, admin_client, admin_profile
):
# last_used_at is null for a token issued three years ago AND for one
# issued a minute ago. Counting on null alone put the token you had just
# created into "unused for 90+ days" on the very next reload.
PersonalAccessToken.generate(profile=admin_profile, name="brand new")
body = admin_client.get(LIST_URL).json()
assert body["totals"]["unused_90d"] == 0
def test_a_never_used_token_counts_once_it_is_old_enough(
self, admin_client, admin_profile
):
# The other half: never used is still the strongest signal there is, so
# an old one has to count. created_at is what dates it.
_, forgotten = PersonalAccessToken.generate(
profile=admin_profile, name="forgotten"
)
PersonalAccessToken.objects.filter(pk=forgotten.pk).update(
created_at=timezone.now() - timedelta(days=120)
)
body = admin_client.get(LIST_URL).json()
assert body["totals"]["unused_90d"] == 1
def test_no_secret_ever_serialized(self, admin_client, admin_profile):
raw, _ = PersonalAccessToken.generate(profile=admin_profile, name="cli")
body = admin_client.get(LIST_URL).json()
assert raw not in str(body)
assert all("token_hash" not in t for t in body["tokens"])
assert all("token" not in t for t in body["tokens"])
def test_unauthenticated_is_403(self, unauthenticated_client):
# Same shape as the self endpoint: the org-context middleware denies a
# credential-less request before DRF's auth layer would emit a 401.
resp = unauthenticated_client.get(LIST_URL)
assert resp.status_code == 403, resp.content
@pytest.mark.django_db
class TestOrgTokenRevoke:
def test_admin_revokes_another_members_token(
self, admin_client, admin_profile, user_profile
):
# The whole reason this endpoint exists: the self-scoped revoke 404s on
# someone else's token; the admin one retires it.
_, theirs = PersonalAccessToken.generate(profile=user_profile, name="theirs")
resp = admin_client.delete(_detail_url(theirs.id))
assert resp.status_code == 200, resp.content
theirs.refresh_from_db()
assert theirs.revoked_at is not None
def test_non_admin_cannot_revoke(self, user_client, admin_profile):
_, target = PersonalAccessToken.generate(profile=admin_profile, name="admins")
resp = user_client.delete(_detail_url(target.id))
assert resp.status_code == 403, resp.content
target.refresh_from_db()
assert target.revoked_at is None
def test_cross_org_revoke_is_404(self, org_b_client, admin_profile):
_, a_token = PersonalAccessToken.generate(profile=admin_profile, name="org-a")
resp = org_b_client.delete(_detail_url(a_token.id))
assert resp.status_code == 404, resp.content
a_token.refresh_from_db()
assert a_token.revoked_at is None
def test_revoke_is_idempotent(self, admin_client, admin_profile, user_profile):
_, t = PersonalAccessToken.generate(profile=user_profile, name="theirs")
first = admin_client.delete(_detail_url(t.id))
assert first.status_code == 200, first.content
t.refresh_from_db()
stamp = t.revoked_at
assert stamp is not None
second = admin_client.delete(_detail_url(t.id))
assert second.status_code == 200, second.content
t.refresh_from_db()
assert t.revoked_at == stamp # not re-stamped
def test_revoke_unknown_uuid_is_404(self, admin_client, admin_profile):
resp = admin_client.delete(_detail_url(uuid.uuid4()))
assert resp.status_code == 404, resp.content
@pytest.mark.django_db
def test_deactivated_owner_token_is_rejected_at_the_api(admin_client, user_profile):
"""End-to-end proof of the page's reframed claim.
The mock said a deactivated owner's token "keeps working with their old
role." It does not: with the owner's profile.is_active false, the token is
refused at the door. This guards the copy on /v2/settings/api-tokens and
/v2/team, and complements the unit test test_pat_auth.py::
test_inactive_profile_raises with a real request through the middleware.
"""
raw, _ = PersonalAccessToken.generate(profile=user_profile, name="pre-deactivation")
user_profile.is_active = False
user_profile.save()
resp = Client().get("/api/leads/", HTTP_AUTHORIZATION=f"Bearer {raw}")
assert resp.status_code in (401, 403), resp.content