| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237 |
- """A count on an external sidebar link, set by the app it points to.
- The contract these tests pin:
- ``PUT /external-links/badge`` with ``{url, count}`` sets the count on every
- sidebar link pointing at that address (same origin and path, trailing slash
- ignored), e.g. Bambuddy Orders with its To do count. Callers need what
- ``POST /notifications/app-message`` needs: an API key with the "Send
- notifications" permission, or a user with ``notifications:update``. The
- list of links carries the count while it is fresh (set within the last ten
- minutes), so an app that stopped reporting doesn't leave an old number.
- Setting it doesn't touch the link's own ``updated_at``.
- """
- from datetime import timedelta
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select, update
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.core.auth import generate_api_key
- from backend.app.models.api_key import APIKey
- from backend.app.models.external_link import ExternalLink, ExternalLinkBadge
- from backend.app.models.user import User
- from backend.app.utils.local_time import utcnow_naive
- URL = "/api/v1/external-links/badge"
- async def _admin_token(client: AsyncClient) -> str:
- await client.post(
- "/api/v1/auth/setup",
- json={
- "auth_enabled": True,
- "admin_username": "badgeadmin",
- "admin_password": "AdminPass1!", # pragma: allowlist secret
- },
- )
- login = await client.post(
- "/api/v1/auth/login",
- json={"username": "badgeadmin", "password": "AdminPass1!"}, # pragma: allowlist secret
- )
- return login.json()["access_token"]
- async def _key(db: AsyncSession, *, allowed: bool) -> str:
- owner = (await db.execute(select(User).where(User.username == "badgeadmin"))).scalar_one()
- full_key, key_hash, key_prefix = generate_api_key()
- db.add(
- APIKey(
- name="Bambuddy Orders",
- key_hash=key_hash,
- key_prefix=key_prefix,
- user_id=owner.id,
- can_send_notifications=allowed,
- )
- )
- await db.commit()
- return full_key
- async def _link(db: AsyncSession, url: str, name: str = "Orders") -> ExternalLink:
- link = ExternalLink(name=name, url=url, icon="Package", sort_order=0)
- db.add(link)
- await db.commit()
- await db.refresh(link)
- return link
- async def _listed(client: AsyncClient, token: str) -> dict[str, int | None]:
- response = await client.get("/api/v1/external-links/", headers={"Authorization": f"Bearer {token}"})
- assert response.status_code == 200, response.text
- return {link["name"]: link["badge_count"] for link in response.json()}
- class TestBadge:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_app_sets_the_count_on_the_link_pointing_at_it(self, async_client: AsyncClient, db_session):
- token = await _admin_token(async_client)
- key = await _key(db_session, allowed=True)
- await _link(db_session, "http://orders.local:8090/")
- await _link(db_session, "http://other.local:8090/", name="Other")
- response = await async_client.put(
- URL, json={"url": "http://orders.local:8090", "count": 7}, headers={"X-API-Key": key}
- )
- assert response.status_code == 200, response.text
- assert response.json() == {"links": 1}
- assert await _listed(async_client, token) == {"Orders": 7, "Other": None}
- await async_client.put(URL, json={"url": "http://orders.local:8090/", "count": 0}, headers={"X-API-Key": key})
- assert (await _listed(async_client, token))["Orders"] == 0
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_path_counts_too(self, async_client: AsyncClient, db_session):
- token = await _admin_token(async_client)
- key = await _key(db_session, allowed=True)
- await _link(db_session, "https://shop.example/orders/")
- await _link(db_session, "https://shop.example/print", name="Print")
- await async_client.put(URL, json={"url": "https://shop.example/orders", "count": 2}, headers={"X-API-Key": key})
- assert await _listed(async_client, token) == {"Orders": 2, "Print": None}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_no_link_pointing_there_is_a_404(self, async_client: AsyncClient, db_session):
- await _admin_token(async_client)
- key = await _key(db_session, allowed=True)
- await _link(db_session, "http://orders.local:8090/")
- response = await async_client.put(
- URL, json={"url": "http://orders.local:9999", "count": 1}, headers={"X-API-Key": key}
- )
- assert response.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_old_count_is_not_shown(self, async_client: AsyncClient, db_session):
- token = await _admin_token(async_client)
- key = await _key(db_session, allowed=True)
- await _link(db_session, "http://orders.local:8090")
- await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
- await db_session.execute(update(ExternalLinkBadge).values(updated_at=utcnow_naive() - timedelta(minutes=11)))
- await db_session.commit()
- assert (await _listed(async_client, token))["Orders"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_link_itself_is_not_changed(self, async_client: AsyncClient, db_session):
- await _admin_token(async_client)
- key = await _key(db_session, allowed=True)
- link = await _link(db_session, "http://orders.local:8090")
- before = link.updated_at
- await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
- stored = await db_session.execute(select(ExternalLink.updated_at).where(ExternalLink.id == link.id))
- assert stored.scalar_one() == before
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_deleting_the_link_drops_its_count(self, async_client: AsyncClient, db_session):
- token = await _admin_token(async_client)
- key = await _key(db_session, allowed=True)
- link = await _link(db_session, "http://orders.local:8090")
- await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
- response = await async_client.delete(
- f"/api/v1/external-links/{link.id}", headers={"Authorization": f"Bearer {token}"}
- )
- assert response.status_code == 200
- assert (await db_session.execute(select(ExternalLinkBadge))).scalars().all() == []
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_needs_the_send_notifications_permission(self, async_client: AsyncClient, db_session):
- await _admin_token(async_client)
- key = await _key(db_session, allowed=False)
- await _link(db_session, "http://orders.local:8090")
- body = {"url": "http://orders.local:8090", "count": 1}
- assert (await async_client.put(URL, json=body, headers={"X-API-Key": key})).status_code == 403
- assert (await async_client.put(URL, json=body)).status_code == 401
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_refuses_nonsense(self, async_client: AsyncClient, db_session):
- await _admin_token(async_client)
- key = await _key(db_session, allowed=True)
- for body in ({"url": "ftp://orders.local", "count": 1}, {"url": "http://orders.local", "count": -1}):
- assert (await async_client.put(URL, json=body, headers={"X-API-Key": key})).status_code == 422
- class TestMatching:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_same_address_written_differently_still_matches(self, async_client: AsyncClient, db_session):
- token = await _admin_token(async_client)
- key = await _key(db_session, allowed=True)
- await _link(db_session, "http://orders.local", name="Default port")
- await _link(db_session, "https://user:pass@shop.example/", name="With login") # pragma: allowlist secret
- await _link(db_session, "http://ORDERS.local:80/", name="Spelled out")
- response = await async_client.put(
- URL, json={"url": "http://orders.local:80", "count": 4}, headers={"X-API-Key": key}
- )
- assert response.json() == {"links": 2}
- await async_client.put(URL, json={"url": "https://shop.example:443", "count": 5}, headers={"X-API-Key": key})
- assert await _listed(async_client, token) == {"Default port": 4, "With login": 5, "Spelled out": 4}
- class TestWhoMaySet:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_signed_in_user_needs_notifications_update(self, async_client: AsyncClient, db_session):
- token = await _admin_token(async_client)
- await _link(db_session, "http://orders.local:8090")
- response = await async_client.put(
- URL, json={"url": "http://orders.local:8090", "count": 2}, headers={"Authorization": f"Bearer {token}"}
- )
- assert response.status_code == 200
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_key_can_do_no_more_than_its_owner(self, async_client: AsyncClient, db_session):
- from backend.app.models.group import Group
- await _admin_token(async_client)
- viewers = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
- owner = User(username="viewer", password_hash="x", role="user", is_active=True)
- owner.groups = [viewers]
- db_session.add(owner)
- await db_session.commit()
- full_key, key_hash, key_prefix = generate_api_key()
- db_session.add(
- APIKey(name="k", key_hash=key_hash, key_prefix=key_prefix, user_id=owner.id, can_send_notifications=True)
- )
- await db_session.commit()
- await _link(db_session, "http://orders.local:8090")
- response = await async_client.put(
- URL, json={"url": "http://orders.local:8090", "count": 2}, headers={"X-API-Key": full_key}
- )
- assert response.status_code == 403
- class TestLinkGone:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_link_removed_any_way_takes_its_count_along(self, async_client: AsyncClient, db_session):
- """Not only through the delete route: SQLite applies no ON DELETE by itself."""
- token = await _admin_token(async_client)
- key = await _key(db_session, allowed=True)
- link = await _link(db_session, "http://orders.local:8090")
- await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
- await db_session.delete(await db_session.get(ExternalLink, link.id))
- await db_session.commit()
- assert (await db_session.execute(select(ExternalLinkBadge))).scalars().all() == []
- await _link(db_session, "http://other.local:8090", name="Next")
- assert await _listed(async_client, token) == {"Next": None}
|