test_external_link_badge.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. """A count on an external sidebar link, set by the app it points to.
  2. The contract these tests pin:
  3. ``PUT /external-links/badge`` with ``{url, count}`` sets the count on every
  4. sidebar link pointing at that address (same origin and path, trailing slash
  5. ignored), e.g. Bambuddy Orders with its To do count. Callers need what
  6. ``POST /notifications/app-message`` needs: an API key with the "Send
  7. notifications" permission, or a user with ``notifications:update``. The
  8. list of links carries the count while it is fresh (set within the last ten
  9. minutes), so an app that stopped reporting doesn't leave an old number.
  10. Setting it doesn't touch the link's own ``updated_at``.
  11. """
  12. from datetime import timedelta
  13. import pytest
  14. from httpx import AsyncClient
  15. from sqlalchemy import select, update
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from backend.app.core.auth import generate_api_key
  18. from backend.app.models.api_key import APIKey
  19. from backend.app.models.external_link import ExternalLink, ExternalLinkBadge
  20. from backend.app.models.user import User
  21. from backend.app.utils.local_time import utcnow_naive
  22. URL = "/api/v1/external-links/badge"
  23. async def _admin_token(client: AsyncClient) -> str:
  24. await client.post(
  25. "/api/v1/auth/setup",
  26. json={
  27. "auth_enabled": True,
  28. "admin_username": "badgeadmin",
  29. "admin_password": "AdminPass1!", # pragma: allowlist secret
  30. },
  31. )
  32. login = await client.post(
  33. "/api/v1/auth/login",
  34. json={"username": "badgeadmin", "password": "AdminPass1!"}, # pragma: allowlist secret
  35. )
  36. return login.json()["access_token"]
  37. async def _key(db: AsyncSession, *, allowed: bool) -> str:
  38. owner = (await db.execute(select(User).where(User.username == "badgeadmin"))).scalar_one()
  39. full_key, key_hash, key_prefix = generate_api_key()
  40. db.add(
  41. APIKey(
  42. name="Bambuddy Orders",
  43. key_hash=key_hash,
  44. key_prefix=key_prefix,
  45. user_id=owner.id,
  46. can_send_notifications=allowed,
  47. )
  48. )
  49. await db.commit()
  50. return full_key
  51. async def _link(db: AsyncSession, url: str, name: str = "Orders") -> ExternalLink:
  52. link = ExternalLink(name=name, url=url, icon="Package", sort_order=0)
  53. db.add(link)
  54. await db.commit()
  55. await db.refresh(link)
  56. return link
  57. async def _listed(client: AsyncClient, token: str) -> dict[str, int | None]:
  58. response = await client.get("/api/v1/external-links/", headers={"Authorization": f"Bearer {token}"})
  59. assert response.status_code == 200, response.text
  60. return {link["name"]: link["badge_count"] for link in response.json()}
  61. class TestBadge:
  62. @pytest.mark.asyncio
  63. @pytest.mark.integration
  64. async def test_an_app_sets_the_count_on_the_link_pointing_at_it(self, async_client: AsyncClient, db_session):
  65. token = await _admin_token(async_client)
  66. key = await _key(db_session, allowed=True)
  67. await _link(db_session, "http://orders.local:8090/")
  68. await _link(db_session, "http://other.local:8090/", name="Other")
  69. response = await async_client.put(
  70. URL, json={"url": "http://orders.local:8090", "count": 7}, headers={"X-API-Key": key}
  71. )
  72. assert response.status_code == 200, response.text
  73. assert response.json() == {"links": 1}
  74. assert await _listed(async_client, token) == {"Orders": 7, "Other": None}
  75. await async_client.put(URL, json={"url": "http://orders.local:8090/", "count": 0}, headers={"X-API-Key": key})
  76. assert (await _listed(async_client, token))["Orders"] == 0
  77. @pytest.mark.asyncio
  78. @pytest.mark.integration
  79. async def test_the_path_counts_too(self, async_client: AsyncClient, db_session):
  80. token = await _admin_token(async_client)
  81. key = await _key(db_session, allowed=True)
  82. await _link(db_session, "https://shop.example/orders/")
  83. await _link(db_session, "https://shop.example/print", name="Print")
  84. await async_client.put(URL, json={"url": "https://shop.example/orders", "count": 2}, headers={"X-API-Key": key})
  85. assert await _listed(async_client, token) == {"Orders": 2, "Print": None}
  86. @pytest.mark.asyncio
  87. @pytest.mark.integration
  88. async def test_no_link_pointing_there_is_a_404(self, async_client: AsyncClient, db_session):
  89. await _admin_token(async_client)
  90. key = await _key(db_session, allowed=True)
  91. await _link(db_session, "http://orders.local:8090/")
  92. response = await async_client.put(
  93. URL, json={"url": "http://orders.local:9999", "count": 1}, headers={"X-API-Key": key}
  94. )
  95. assert response.status_code == 404
  96. @pytest.mark.asyncio
  97. @pytest.mark.integration
  98. async def test_an_old_count_is_not_shown(self, async_client: AsyncClient, db_session):
  99. token = await _admin_token(async_client)
  100. key = await _key(db_session, allowed=True)
  101. await _link(db_session, "http://orders.local:8090")
  102. await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
  103. await db_session.execute(update(ExternalLinkBadge).values(updated_at=utcnow_naive() - timedelta(minutes=11)))
  104. await db_session.commit()
  105. assert (await _listed(async_client, token))["Orders"] is None
  106. @pytest.mark.asyncio
  107. @pytest.mark.integration
  108. async def test_the_link_itself_is_not_changed(self, async_client: AsyncClient, db_session):
  109. await _admin_token(async_client)
  110. key = await _key(db_session, allowed=True)
  111. link = await _link(db_session, "http://orders.local:8090")
  112. before = link.updated_at
  113. await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
  114. stored = await db_session.execute(select(ExternalLink.updated_at).where(ExternalLink.id == link.id))
  115. assert stored.scalar_one() == before
  116. @pytest.mark.asyncio
  117. @pytest.mark.integration
  118. async def test_deleting_the_link_drops_its_count(self, async_client: AsyncClient, db_session):
  119. token = await _admin_token(async_client)
  120. key = await _key(db_session, allowed=True)
  121. link = await _link(db_session, "http://orders.local:8090")
  122. await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
  123. response = await async_client.delete(
  124. f"/api/v1/external-links/{link.id}", headers={"Authorization": f"Bearer {token}"}
  125. )
  126. assert response.status_code == 200
  127. assert (await db_session.execute(select(ExternalLinkBadge))).scalars().all() == []
  128. @pytest.mark.asyncio
  129. @pytest.mark.integration
  130. async def test_needs_the_send_notifications_permission(self, async_client: AsyncClient, db_session):
  131. await _admin_token(async_client)
  132. key = await _key(db_session, allowed=False)
  133. await _link(db_session, "http://orders.local:8090")
  134. body = {"url": "http://orders.local:8090", "count": 1}
  135. assert (await async_client.put(URL, json=body, headers={"X-API-Key": key})).status_code == 403
  136. assert (await async_client.put(URL, json=body)).status_code == 401
  137. @pytest.mark.asyncio
  138. @pytest.mark.integration
  139. async def test_refuses_nonsense(self, async_client: AsyncClient, db_session):
  140. await _admin_token(async_client)
  141. key = await _key(db_session, allowed=True)
  142. for body in ({"url": "ftp://orders.local", "count": 1}, {"url": "http://orders.local", "count": -1}):
  143. assert (await async_client.put(URL, json=body, headers={"X-API-Key": key})).status_code == 422
  144. class TestMatching:
  145. @pytest.mark.asyncio
  146. @pytest.mark.integration
  147. async def test_the_same_address_written_differently_still_matches(self, async_client: AsyncClient, db_session):
  148. token = await _admin_token(async_client)
  149. key = await _key(db_session, allowed=True)
  150. await _link(db_session, "http://orders.local", name="Default port")
  151. await _link(db_session, "https://user:pass@shop.example/", name="With login") # pragma: allowlist secret
  152. await _link(db_session, "http://ORDERS.local:80/", name="Spelled out")
  153. response = await async_client.put(
  154. URL, json={"url": "http://orders.local:80", "count": 4}, headers={"X-API-Key": key}
  155. )
  156. assert response.json() == {"links": 2}
  157. await async_client.put(URL, json={"url": "https://shop.example:443", "count": 5}, headers={"X-API-Key": key})
  158. assert await _listed(async_client, token) == {"Default port": 4, "With login": 5, "Spelled out": 4}
  159. class TestWhoMaySet:
  160. @pytest.mark.asyncio
  161. @pytest.mark.integration
  162. async def test_a_signed_in_user_needs_notifications_update(self, async_client: AsyncClient, db_session):
  163. token = await _admin_token(async_client)
  164. await _link(db_session, "http://orders.local:8090")
  165. response = await async_client.put(
  166. URL, json={"url": "http://orders.local:8090", "count": 2}, headers={"Authorization": f"Bearer {token}"}
  167. )
  168. assert response.status_code == 200
  169. @pytest.mark.asyncio
  170. @pytest.mark.integration
  171. async def test_a_key_can_do_no_more_than_its_owner(self, async_client: AsyncClient, db_session):
  172. from backend.app.models.group import Group
  173. await _admin_token(async_client)
  174. viewers = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
  175. owner = User(username="viewer", password_hash="x", role="user", is_active=True)
  176. owner.groups = [viewers]
  177. db_session.add(owner)
  178. await db_session.commit()
  179. full_key, key_hash, key_prefix = generate_api_key()
  180. db_session.add(
  181. APIKey(name="k", key_hash=key_hash, key_prefix=key_prefix, user_id=owner.id, can_send_notifications=True)
  182. )
  183. await db_session.commit()
  184. await _link(db_session, "http://orders.local:8090")
  185. response = await async_client.put(
  186. URL, json={"url": "http://orders.local:8090", "count": 2}, headers={"X-API-Key": full_key}
  187. )
  188. assert response.status_code == 403
  189. class TestLinkGone:
  190. @pytest.mark.asyncio
  191. @pytest.mark.integration
  192. async def test_a_link_removed_any_way_takes_its_count_along(self, async_client: AsyncClient, db_session):
  193. """Not only through the delete route: SQLite applies no ON DELETE by itself."""
  194. token = await _admin_token(async_client)
  195. key = await _key(db_session, allowed=True)
  196. link = await _link(db_session, "http://orders.local:8090")
  197. await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
  198. await db_session.delete(await db_session.get(ExternalLink, link.id))
  199. await db_session.commit()
  200. assert (await db_session.execute(select(ExternalLinkBadge))).scalars().all() == []
  201. await _link(db_session, "http://other.local:8090", name="Next")
  202. assert await _listed(async_client, token) == {"Next": None}