test_api_key_id_reuse.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. """A deleted API key leaves nothing that the next key given its id would take.
  2. SQLite gives a deleted key's id to the next key created. Open WebSockets opened
  3. with the key, and the camera and WebSocket tokens it minted, are tied to
  4. the key by id: they took the new key's printers.
  5. """
  6. from datetime import datetime, timedelta, timezone
  7. from types import SimpleNamespace
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from fastapi import WebSocketDisconnect
  11. from httpx import AsyncClient
  12. from sqlalchemy import select, text
  13. from backend.app.core.websocket import ws_manager
  14. from backend.app.models.api_key import APIKey
  15. from backend.app.models.auth_ephemeral import AuthEphemeralToken
  16. pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
  17. @pytest.fixture
  18. async def admin(async_client: AsyncClient) -> dict:
  19. await async_client.post(
  20. "/api/v1/auth/setup",
  21. json={"auth_enabled": True, "admin_username": "keyadmin", "admin_password": "AdminPass1!"},
  22. )
  23. login = await async_client.post("/api/v1/auth/login", json={"username": "keyadmin", "password": "AdminPass1!"})
  24. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  25. async def _key(db, user_id: int | None = None) -> int:
  26. key = APIKey(name="k", key_hash="h", key_prefix="bb_k", printer_ids=[1], user_id=user_id)
  27. db.add(key)
  28. await db.commit()
  29. return key.id
  30. async def _tokens(db, key_id: int) -> None:
  31. expires = datetime.now(timezone.utc) + timedelta(hours=1)
  32. db.add_all(
  33. [
  34. AuthEphemeralToken(
  35. token=f"{kind}-{key_id}", token_type=kind, username="", api_key_id=key_id, expires_at=expires
  36. )
  37. for kind in ("camera_stream", "websocket") # media tokens never record the key
  38. ]
  39. )
  40. await db.commit()
  41. async def _token_keys(db) -> list:
  42. rows = await db.execute(
  43. select(AuthEphemeralToken.api_key_id)
  44. .where(AuthEphemeralToken.api_key_id.is_not(None))
  45. .execution_options(populate_existing=True)
  46. )
  47. return sorted(rows.scalars().all())
  48. def _socket(api_key_id: int | None) -> MagicMock:
  49. socket = MagicMock()
  50. socket.state = SimpleNamespace(
  51. bambuddy_scope_principal=("", api_key_id), bambuddy_principal_user_id=None, bambuddy_principal=""
  52. )
  53. socket.accept = AsyncMock()
  54. socket.close = AsyncMock()
  55. socket.send_json = AsyncMock()
  56. socket.receive_json = AsyncMock(side_effect=WebSocketDisconnect())
  57. return socket
  58. async def test_deleting_a_key_closes_its_sockets_and_drops_its_tokens(async_client: AsyncClient, admin, db_session):
  59. gone, kept = await _key(db_session), await _key(db_session)
  60. await _tokens(db_session, gone)
  61. await _tokens(db_session, kept)
  62. theirs, other = _socket(gone), _socket(kept)
  63. ws_manager.active_connections.extend([theirs, other])
  64. try:
  65. response = await async_client.delete(f"/api/v1/api-keys/{gone}", headers=admin)
  66. assert response.status_code == 200, response.text
  67. assert await _token_keys(db_session) == [kept] * 2
  68. theirs.close.assert_awaited_once_with(code=4401)
  69. assert theirs not in ws_manager.active_connections
  70. other.close.assert_not_awaited()
  71. finally:
  72. for socket in (theirs, other):
  73. if socket in ws_manager.active_connections:
  74. ws_manager.active_connections.remove(socket)
  75. async def test_deleting_a_user_does_the_same_for_their_keys(async_client: AsyncClient, admin, db_session):
  76. created = await async_client.post(
  77. "/api/v1/users/", headers=admin, json={"username": "keyowner", "password": "Userpass1!", "role": "user"}
  78. )
  79. owner = created.json()["id"]
  80. key = await _key(db_session, user_id=owner)
  81. await _tokens(db_session, key)
  82. theirs = _socket(key)
  83. ws_manager.active_connections.append(theirs)
  84. try:
  85. response = await async_client.delete(f"/api/v1/users/{owner}", headers=admin)
  86. assert response.status_code == 204, response.text
  87. assert await _token_keys(db_session) == []
  88. theirs.close.assert_awaited_once_with(code=4401)
  89. finally:
  90. if theirs in ws_manager.active_connections:
  91. ws_manager.active_connections.remove(theirs)
  92. async def _connect(key_id: int, connect=None) -> MagicMock:
  93. from backend.app.api.routes import websocket as ws_route
  94. from backend.app.core import database
  95. from backend.app.core.printer_scope import ALL_PRINTERS
  96. socket = _socket(None)
  97. socket.state = SimpleNamespace()
  98. with (
  99. patch.object(ws_route, "is_auth_enabled", AsyncMock(return_value=True)),
  100. patch.object(ws_route, "verify_websocket_token_principal", AsyncMock(return_value=("", key_id))),
  101. patch.object(ws_route, "principal_printer_scope", AsyncMock(return_value=ALL_PRINTERS)),
  102. patch.object(ws_route, "async_session", database.async_session),
  103. patch.object(ws_manager, "connect", connect or ws_manager.connect),
  104. ):
  105. await ws_route.websocket_endpoint(socket, token="t")
  106. return socket
  107. async def test_a_websocket_of_a_deleted_key_is_refused(async_client: AsyncClient):
  108. socket = await _connect(9999)
  109. socket.close.assert_awaited_once_with(code=4401)
  110. socket.accept.assert_not_awaited()
  111. async def test_a_key_deleted_while_its_websocket_joins_closes_it(async_client: AsyncClient, db_session):
  112. key = await _key(db_session)
  113. joined = ws_manager.connect
  114. async def join_while_deleted(websocket):
  115. await db_session.execute(text("DELETE FROM api_keys WHERE id = :id"), {"id": key})
  116. await db_session.commit()
  117. await ws_manager.close_api_keys([key]) # what the delete does after its commit
  118. await joined(websocket)
  119. socket = await _connect(key, join_while_deleted)
  120. socket.close.assert_awaited_once_with(code=4401)
  121. assert socket not in ws_manager.active_connections
  122. async def test_the_startup_cleanup_drops_tokens_of_keys_that_are_gone(db_session):
  123. from backend.app.core.database import _drop_ephemeral_tokens_of_missing_owners
  124. kept = await _key(db_session)
  125. await _tokens(db_session, kept)
  126. await _tokens(db_session, kept + 50) # deleted before deletes took them along
  127. await _drop_ephemeral_tokens_of_missing_owners(await db_session.connection())
  128. await db_session.commit()
  129. assert await _token_keys(db_session) == [kept] * 2
  130. async def test_a_deleted_keys_message_rate_goes_too(async_client: AsyncClient, admin, db_session):
  131. """The next key given the id would start out throttled."""
  132. from backend.app.api.routes import notifications
  133. key = await _key(db_session)
  134. notifications._app_message_times[f"key:{key}"].extend([1.0] * notifications.APP_MESSAGE_LIMIT)
  135. try:
  136. response = await async_client.delete(f"/api/v1/api-keys/{key}", headers=admin)
  137. assert response.status_code == 200, response.text
  138. assert f"key:{key}" not in notifications._app_message_times
  139. finally:
  140. notifications._app_message_times.pop(f"key:{key}", None)
  141. @pytest.mark.parametrize("replaced", [False, True])
  142. async def test_a_scope_refresh_closes_a_socket_whose_key_is_gone(async_client: AsyncClient, db_session, replaced):
  143. """Rotated from the command line, or an id that came free later: the socket
  144. was re-scoped by id, taking the printers of whichever key had it next."""
  145. from backend.app.core import database
  146. await async_client.post(
  147. "/api/v1/auth/setup",
  148. json={"auth_enabled": True, "admin_username": "keyadmin2", "admin_password": "AdminPass1!"},
  149. )
  150. key = await _key(db_session)
  151. prefix = (await db_session.execute(select(APIKey.key_prefix).where(APIKey.id == key))).scalar_one()
  152. socket = _socket(key)
  153. socket.state.bambuddy_api_key_prefix = prefix
  154. ws_manager.active_connections.append(socket)
  155. await db_session.execute(text("DELETE FROM api_keys WHERE id = :id"), {"id": key})
  156. await db_session.commit()
  157. if replaced: # a new key given the freed id
  158. db_session.add(APIKey(id=key, name="new", key_hash="h2", key_prefix="bb_new", printer_ids=None))
  159. await db_session.commit()
  160. try:
  161. with patch("backend.app.core.database.async_session", database.async_session):
  162. await ws_manager.refresh_printer_scopes()
  163. socket.close.assert_awaited_once_with(code=4401)
  164. assert socket not in ws_manager.active_connections
  165. finally:
  166. if socket in ws_manager.active_connections:
  167. ws_manager.active_connections.remove(socket)
  168. async def test_a_deleted_users_message_rate_goes_too(async_client: AsyncClient, admin):
  169. """The next user given the id would start out throttled."""
  170. from backend.app.api.routes import notifications
  171. created = await async_client.post(
  172. "/api/v1/users/", headers=admin, json={"username": "sender", "password": "Userpass1!", "role": "user"}
  173. )
  174. user = created.json()["id"]
  175. notifications._app_message_times[f"user:{user}"].extend([1.0] * notifications.APP_MESSAGE_LIMIT)
  176. try:
  177. response = await async_client.delete(f"/api/v1/users/{user}", headers=admin)
  178. assert response.status_code == 204, response.text
  179. assert f"user:{user}" not in notifications._app_message_times
  180. finally:
  181. notifications._app_message_times.pop(f"user:{user}", None)