test_user_deleted_during_request.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. """A request whose user is deleted while it runs saves nothing under their id.
  2. An import downloads for seconds, a ZIP extract or a combine runs for minutes,
  3. and each holds the user it started with. Saved afterwards, that id points at
  4. nobody, and on SQLite, which gives a freed id to the next user created, at
  5. someone else: they would own the files and private folders. These requests stop
  6. with 401 instead. The tests hand each route the user as the request holds it,
  7. already deleted, with bob created on the freed id.
  8. """
  9. import io
  10. import zipfile
  11. from unittest.mock import AsyncMock, patch
  12. import pytest
  13. import trimesh
  14. from httpx import AsyncClient
  15. from sqlalchemy import delete, select
  16. from backend.app.core.auth import ApiKeyActor, actor_still_exists, get_request_actor
  17. from backend.app.main import app
  18. from backend.app.models.library import LibraryFile, LibraryFolder
  19. from backend.app.models.user import User
  20. pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
  21. async def _user(db, name: str, user_id: int | None = None) -> User:
  22. user = User(id=user_id, username=name, password_hash="x", role="user")
  23. db.add(user)
  24. await db.commit()
  25. return user
  26. @pytest.fixture
  27. async def deleted_alice(db_session):
  28. """Alice as a running request holds her: deleted since, and bob has her id."""
  29. alice = await _user(db_session, "alice")
  30. # An admin, so no ownership check turns the request away before the write.
  31. held = User(id=alice.id, username="alice", password_hash="x", role="admin")
  32. await db_session.execute(delete(User).where(User.id == alice.id))
  33. await db_session.commit()
  34. # AUTOINCREMENT now stops SQLite reusing ids; bob is put on it by hand, as
  35. # databases from before did, to keep the check covered.
  36. assert (await _user(db_session, "bob", alice.id)).id == alice.id
  37. try:
  38. yield lambda: app.dependency_overrides.__setitem__(get_request_actor, lambda: held)
  39. finally:
  40. app.dependency_overrides.pop(get_request_actor, None)
  41. async def _library(db) -> tuple[list[int], list[int]]:
  42. files = (await db.execute(select(LibraryFile.created_by_id))).scalars().all()
  43. folders = (await db.execute(select(LibraryFolder.created_by_id))).scalars().all()
  44. return list(files), list(folders)
  45. async def test_the_check_knows_who_is_still_there(db_session):
  46. alice = await _user(db_session, "alice")
  47. assert await actor_still_exists(db_session, alice)
  48. assert await actor_still_exists(db_session, None) # auth off: nobody to lose
  49. keyless = ApiKeyActor.__new__(ApiKeyActor)
  50. keyless.id, keyless.username = None, None
  51. assert await actor_still_exists(db_session, keyless) # a key without an owner
  52. held = User(id=alice.id, username="alice")
  53. await db_session.execute(delete(User).where(User.id == alice.id))
  54. await db_session.commit()
  55. assert not await actor_still_exists(db_session, held)
  56. await _user(db_session, "bob", held.id) # on alice's id, as before AUTOINCREMENT
  57. assert not await actor_still_exists(db_session, held)
  58. async def test_a_zip_extract_stops(async_client: AsyncClient, db_session, deleted_alice):
  59. deleted_alice()
  60. archive = io.BytesIO()
  61. with zipfile.ZipFile(archive, "w") as zf:
  62. zf.writestr("sub/a.3mf", b"PK\x03\x04 a")
  63. zf.writestr("sub/b.3mf", b"PK\x03\x04 b")
  64. response = await async_client.post(
  65. "/api/v1/library/files/extract-zip",
  66. files={"file": ("models.zip", archive.getvalue(), "application/zip")},
  67. params={"generate_stl_thumbnails": "false"},
  68. )
  69. assert response.status_code == 401, response.text
  70. assert await _library(db_session) == ([], [])
  71. async def test_a_combine_stops(async_client: AsyncClient, db_session, deleted_alice):
  72. stl = trimesh.creation.box((10, 10, 10)).export(file_type="stl")
  73. uploaded = await async_client.post(
  74. "/api/v1/library/files",
  75. files={"file": ("box.stl", stl, "application/octet-stream")},
  76. params={"generate_stl_thumbnails": "false"},
  77. )
  78. assert uploaded.status_code == 200, uploaded.text
  79. deleted_alice()
  80. response = await async_client.post(
  81. "/api/v1/library/files/combine", json={"items": [{"file_id": uploaded.json()["id"]}], "filename": "Plate"}
  82. )
  83. assert response.status_code == 401, response.text
  84. assert (await _library(db_session))[0] == [None] # only the source
  85. async def test_a_makerworld_import_stops(async_client: AsyncClient, db_session, deleted_alice):
  86. from backend.app.services.model_providers.base import ProviderDownload
  87. from backend.tests.integration.test_makerworld_apikey_auth import _download_info, _fake_service
  88. deleted_alice()
  89. service = _fake_service(
  90. get_download=_download_info(),
  91. download=ProviderDownload(file_bytes=b"PK\x03\x04fake-3mf-bytes", filename="cube.3mf"),
  92. )
  93. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=service)):
  94. response = await async_client.post("/api/v1/makerworld/import", json={"model_id": 1400373})
  95. assert response.status_code == 401, response.text
  96. assert (await _library(db_session))[0] == []
  97. async def test_a_manyfold_import_stops(async_client: AsyncClient, db_session, deleted_alice, monkeypatch):
  98. from backend.app.api.routes import manyfold as routes
  99. from backend.app.services.model_providers.manyfold import service as svc
  100. from backend.tests._fixtures.manyfold import BASE, STL, FakeFile, FakeManyfold
  101. fake = FakeManyfold(client_secret="app-secret")
  102. fake.add_model("cube01", "Calibration Cube", {"f1": FakeFile("cube.stl", "model/stl", STL)})
  103. real = routes.ManyfoldService
  104. monkeypatch.setattr(routes, "ManyfoldService", lambda config: real(config, client=fake.client()))
  105. svc.clear_token_cache()
  106. try:
  107. connected = await async_client.put(
  108. "/api/v1/manyfold/config",
  109. json={"url": f"{BASE}/", "client_id": fake.client_id, "client_secret": "app-secret"},
  110. )
  111. assert connected.status_code == 200, connected.text
  112. deleted_alice()
  113. response = await async_client.post("/api/v1/manyfold/import", json={"model_id": "cube01", "file_id": "f1"})
  114. finally:
  115. svc.clear_token_cache()
  116. assert response.status_code == 401, response.text
  117. assert await _library(db_session) == ([], [])