bottleneckcrm/backend/common/tests/test_notification_api.py
Ashwin 78abf8acae Refactor RLS context handling and improve security measures
- Updated the `ORG_SCOPED_TABLES` to clarify the exclusion of `security_audit_log` and its implications for RLS policies.
- Added tests to ensure `security_audit_log` remains unexposed and verify the absence of RLS policies.
- Implemented tests for HSTS settings behind a TLS-terminating proxy to ensure proper header emission.
- Enhanced notification API tests to enforce RLS context during notification creation and access.
- Adjusted notification model tests to ensure RLS context is correctly applied for cross-tenant scenarios.
- Refined RLS context management in `conftest.py` to ensure proper session handling across tests.
- Removed deprecated Salesforce settings from the frontend and updated documentation to reflect changes in API behavior.
- Cleaned up environment variable documentation to clarify the impact of `TRUST_PROXY_SSL_HEADER` and other settings.
2026-08-05 17:35:37 +05:30

309 lines
12 KiB
Python

"""End-to-end tests for the in-app notifications REST surface.
Mirrors the request flow from the SvelteKit frontend: JWT in Authorization
header, no special org header, recipient filter enforced server-side.
"""
from datetime import timedelta
from urllib.parse import urlencode
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from common.models import Notification, Org, Profile, User
from common.serializer import OrgAwareRefreshToken
from conftest import rls_org, set_rls_context
class NotificationAPIBase(TestCase):
@classmethod
def setUpTestData(cls):
cls.org_a = Org.objects.create(name="Org A")
cls.org_b = Org.objects.create(name="Org B")
cls.user_a = User.objects.create_user(email="a@test.com", password="x")
cls.user_b = User.objects.create_user(email="b@test.com", password="x")
cls.profile_a = Profile.objects.create(
user=cls.user_a, org=cls.org_a, role="USER", is_active=True
)
cls.profile_b = Profile.objects.create(
user=cls.user_b, org=cls.org_b, role="USER", is_active=True
)
def setUp(self):
# This class builds its own orgs rather than taking the `org_a`
# fixture, so nothing has set `app.current_org` for it and the
# org-scoped `notification` writes below would be refused by the insert
# check under a role RLS binds.
set_rls_context(self.org_a)
self.client = APIClient()
token = OrgAwareRefreshToken.for_user_and_org(self.user_a, self.org_a)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}")
def _bulk(self, profile, n, **kw):
# Seeds for whichever org the profile belongs to, including org B in the
# isolation tests, so the context has to follow the rows being written.
with rls_org(profile.org):
return [
Notification.objects.create(
org=profile.org, recipient=profile, verb=f"v{i}", **kw
)
for i in range(n)
]
class TestList(NotificationAPIBase):
def test_unauthenticated_rejected(self):
anon = APIClient()
r = anon.get("/api/notifications/")
# DRF returns 401 when an auth scheme advertises WWW-Authenticate;
# this stack returns 403 because no credential was attempted at all.
assert r.status_code in (401, 403)
def test_empty_list(self):
r = self.client.get("/api/notifications/")
assert r.status_code == 200
data = r.json()
assert data == {"count": 0, "unread_count": 0, "results": []}
def test_list_returns_only_my_notifications(self):
self._bulk(self.profile_a, 2)
# Other user's row should never leak
# Seeded as org B: the insert check compares against the session
# variable, so planting the other tenant's row means being that
# tenant for the length of the write.
with rls_org(self.org_b):
Notification.objects.create(
org=self.org_b, recipient=self.profile_b, verb="other"
)
r = self.client.get("/api/notifications/")
data = r.json()
assert data["count"] == 2
assert data["unread_count"] == 2
verbs = [row["verb"] for row in data["results"]]
assert "other" not in verbs
def test_unread_filter(self):
rows = self._bulk(self.profile_a, 3)
rows[0].read_at = timezone.now()
rows[0].save(update_fields=["read_at"])
r = self.client.get("/api/notifications/?unread=true")
data = r.json()
assert data["count"] == 2
assert data["unread_count"] == 2
def test_since_filter(self):
n1 = Notification.objects.create(
org=self.org_a, recipient=self.profile_a, verb="old"
)
# Backdate it
Notification.objects.filter(pk=n1.pk).update(
created_at=timezone.now() - timedelta(hours=2)
)
Notification.objects.create(
org=self.org_a, recipient=self.profile_a, verb="new"
)
cutoff = (timezone.now() - timedelta(hours=1)).isoformat()
r = self.client.get("/api/notifications/?" + urlencode({"since": cutoff}))
data = r.json()
assert data["count"] == 1
assert data["results"][0]["verb"] == "new"
# unread_count is the *global* unread, not since-scoped
assert data["unread_count"] == 2
def test_limit_clamps_to_max(self):
self._bulk(self.profile_a, 3)
r = self.client.get("/api/notifications/?limit=2")
data = r.json()
assert data["count"] == 3 # total still 3
assert len(data["results"]) == 2 # but only 2 returned
def test_results_newest_first(self):
first = Notification.objects.create(
org=self.org_a, recipient=self.profile_a, verb="first"
)
Notification.objects.filter(pk=first.pk).update(
created_at=timezone.now() - timedelta(seconds=10)
)
Notification.objects.create(
org=self.org_a, recipient=self.profile_a, verb="second"
)
r = self.client.get("/api/notifications/")
verbs = [row["verb"] for row in r.json()["results"]]
assert verbs == ["second", "first"]
class TestMarkRead(NotificationAPIBase):
def test_mark_read_sets_read_at(self):
n = Notification.objects.create(
org=self.org_a, recipient=self.profile_a, verb="x"
)
r = self.client.post(f"/api/notifications/{n.id}/read/")
assert r.status_code == 204
n.refresh_from_db()
assert n.read_at is not None
def test_mark_read_idempotent(self):
n = Notification.objects.create(
org=self.org_a,
recipient=self.profile_a,
verb="x",
read_at=timezone.now() - timedelta(hours=1),
)
first = n.read_at
r = self.client.post(f"/api/notifications/{n.id}/read/")
assert r.status_code == 204
n.refresh_from_db()
# Re-marking should not move read_at backwards or forwards
assert n.read_at == first
def test_cannot_mark_read_other_users_notification(self):
# Seeded as org B: the insert check compares against the session
# variable, so planting the other tenant's row means being that
# tenant for the length of the write.
with rls_org(self.org_b):
n = Notification.objects.create(
org=self.org_b, recipient=self.profile_b, verb="x"
)
r = self.client.post(f"/api/notifications/{n.id}/read/")
assert r.status_code == 404
# Reading org B's row back needs org B's context too, for the same
# reason writing it did.
with rls_org(self.org_b):
n.refresh_from_db()
assert n.read_at is None
class TestReadAll(NotificationAPIBase):
def test_marks_all_unread_read(self):
rows = self._bulk(self.profile_a, 3)
r = self.client.post("/api/notifications/read-all/")
assert r.status_code == 204
for row in rows:
row.refresh_from_db()
assert row.read_at is not None
def test_before_cutoff_skips_newer(self):
old = Notification.objects.create(
org=self.org_a, recipient=self.profile_a, verb="old"
)
Notification.objects.filter(pk=old.pk).update(
created_at=timezone.now() - timedelta(hours=2)
)
new = Notification.objects.create(
org=self.org_a, recipient=self.profile_a, verb="new"
)
cutoff = (timezone.now() - timedelta(hours=1)).isoformat()
r = self.client.post(
"/api/notifications/read-all/",
{"before": cutoff},
format="json",
)
assert r.status_code == 204
old.refresh_from_db()
new.refresh_from_db()
assert old.read_at is not None
assert new.read_at is None
def test_does_not_touch_other_users(self):
# Seeded as org B: the insert check compares against the session
# variable, so planting the other tenant's row means being that
# tenant for the length of the write.
with rls_org(self.org_b):
n_other = Notification.objects.create(
org=self.org_b, recipient=self.profile_b, verb="other"
)
self.client.post("/api/notifications/read-all/")
with rls_org(self.org_b):
n_other.refresh_from_db()
assert n_other.read_at is None
class TestDelete(NotificationAPIBase):
def test_delete_my_notification(self):
n = Notification.objects.create(
org=self.org_a, recipient=self.profile_a, verb="x"
)
r = self.client.delete(f"/api/notifications/{n.id}/")
assert r.status_code == 204
assert not Notification.objects.filter(pk=n.id).exists()
def test_cannot_delete_other_users_notification(self):
# Seeded as org B: the insert check compares against the session
# variable, so planting the other tenant's row means being that
# tenant for the length of the write.
with rls_org(self.org_b):
n = Notification.objects.create(
org=self.org_b, recipient=self.profile_b, verb="x"
)
r = self.client.delete(f"/api/notifications/{n.id}/")
assert r.status_code == 404
# Same reason: proving org B's row survived means looking as org B.
with rls_org(self.org_b):
assert Notification.objects.filter(pk=n.id).exists()
class TestPollingRoundTrip(NotificationAPIBase):
"""The exact loop `notifications.svelte.js` runs, now that SSE is gone.
The client keeps a high-water mark: the newest `created_at` it has
ingested, replayed as `?since=`. Two properties have to hold or the bell
either misses notifications or re-toasts the same one forever.
"""
def _poll(self, since=None):
qs = {"limit": "20"}
if since:
qs["since"] = since
return self.client.get("/api/notifications/?" + urlencode(qs)).json()
def test_high_water_mark_does_not_replay_or_skip(self):
first = Notification.objects.create(
org=self.org_a, recipient=self.profile_a, verb="first"
)
initial = self._poll()
assert [r["verb"] for r in initial["results"]] == ["first"]
mark = initial["results"][0]["created_at"]
# 1. Replaying the mark must be empty, not the same row again. The
# filter is `created_at__gt`, strictly greater, so the boundary row
# is excluded. With `gte` the client re-toasts it every poll.
assert self._poll(mark)["results"] == [], (
"the boundary row came back: the client would re-toast it on "
"every single poll"
)
# 2. A newer row must arrive, without dragging the older one back.
Notification.objects.filter(pk=first.pk).update(
created_at=timezone.now() - timedelta(minutes=5)
)
Notification.objects.create(
org=self.org_a, recipient=self.profile_a, verb="second"
)
after = self._poll(mark)
assert [r["verb"] for r in after["results"]] == ["second"]
# And the new mark still terminates.
assert self._poll(after["results"][0]["created_at"])["results"] == []
def test_poll_reports_global_unread_not_the_since_window(self):
"""The client refreshes the badge straight from the poll response.
If `unread_count` were scoped to the `since` window it would read 0 on
every quiet poll and the badge would clear itself while unread
notifications were still sitting there.
"""
for verb in ("one", "two", "three"):
Notification.objects.create(
org=self.org_a, recipient=self.profile_a, verb=verb
)
mark = self._poll()["results"][0]["created_at"]
quiet = self._poll(mark)
assert quiet["results"] == []
assert quiet["count"] == 0, "count is since-scoped, as documented"
assert quiet["unread_count"] == 3, (
"unread_count must stay global or the bell badge zeroes itself "
"on the first quiet poll"
)