| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473 |
- """An API key acts as its owner (#3256).
- A route's own checks on cost centers, archives, library files and folders run
- against the key's owner, with the owner's permissions narrowed to the key's
- scope flags. Where it applies, each case goes through the key and through the
- owner's session, which must agree. A key made before keys had owners owns
- nothing and may use no cost center.
- """
- from __future__ import annotations
- from pathlib import Path
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select
- from backend.app.core.auth import generate_api_key, get_password_hash
- from backend.app.core.config import settings as app_settings
- from backend.app.models.api_key import APIKey
- from backend.app.models.archive import PrintArchive
- from backend.app.models.finance import CostCenter, CostCenterMember
- from backend.app.models.group import Group
- from backend.app.models.library import LibraryFile, LibraryFolder
- from backend.app.models.print_batch import PrintBatch
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.printer import Printer
- from backend.app.models.settings import Settings
- from backend.app.models.user import User
- PASSWORD = "Ownerpass1!"
- pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
- async def _set(db_session, key: str, value: str) -> None:
- row = await db_session.scalar(select(Settings).where(Settings.key == key))
- if row is None:
- db_session.add(Settings(key=key, value=value))
- else:
- row.value = value
- @pytest.fixture
- async def world(db_session):
- """Auth and billing on; a key owner who may queue and reprint only their
- own archives, another user, and one printer."""
- await _set(db_session, "auth_enabled", "true")
- await _set(db_session, "advanced_auth_enabled", "false")
- await _set(db_session, "billing_enabled", "true")
- group = Group(
- name="own-queuers",
- description="t",
- permissions=[
- "queue:create",
- "queue:read_own",
- "queue:update_own",
- "archives:read_own",
- "archives:reprint_own",
- "library:read_own",
- ],
- is_system=False,
- )
- db_session.add(group)
- await db_session.flush()
- owner = User(username="keyowner", password_hash=get_password_hash(PASSWORD), is_active=True, groups=[group])
- other = User(username="otheruser", password_hash=get_password_hash(PASSWORD), is_active=True)
- admin = User(username="adminowner", password_hash=get_password_hash(PASSWORD), role="admin", is_active=True)
- printer = Printer(
- name="P", ip_address="192.168.9.9", serial_number="00M00A3256000001", access_code="12345678", model="X1C"
- )
- db_session.add_all([owner, other, admin, printer])
- await db_session.commit()
- return {"owner": owner, "other": other, "admin": admin, "printer": printer}
- async def _archive(db_session, created_by_id: int | None, n: int) -> PrintArchive:
- archive = PrintArchive(
- filename=f"a{n}.3mf",
- print_name=f"a{n}",
- file_path=f"/tmp/a3256_{n}.3mf", # nosec B108
- file_size=1,
- content_hash=f"hash3256_{n}",
- status="completed",
- cost=1.25,
- filament_used_grams=50.0,
- created_by_id=created_by_id,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- return archive
- async def _center(db_session, *, owner_user_id: int | None = None, member_id: int | None = None) -> CostCenter:
- center = CostCenter(
- name=f"cc-{owner_user_id}-{member_id}",
- is_active=True,
- is_private=owner_user_id is not None,
- owner_user_id=owner_user_id,
- )
- db_session.add(center)
- await db_session.flush()
- if member_id is not None:
- db_session.add(CostCenterMember(cost_center_id=center.id, user_id=member_id, can_print=True))
- await db_session.commit()
- await db_session.refresh(center)
- return center
- async def _key(db_session, owner_id: int | None, *, can_queue: bool = True) -> dict[str, str]:
- full_key, key_hash, key_prefix = generate_api_key()
- db_session.add(
- APIKey(
- name="probe",
- key_hash=key_hash,
- key_prefix=key_prefix,
- user_id=owner_id,
- can_queue=can_queue,
- can_read_status=True,
- )
- )
- await db_session.commit()
- return {"X-API-Key": full_key}
- async def _login(client: AsyncClient, username: str) -> dict[str, str]:
- response = await client.post("/api/v1/auth/login", json={"username": username, "password": PASSWORD})
- assert response.status_code == 200, response.text
- return {"Authorization": f"Bearer {response.json()['access_token']}"}
- async def _queue(client: AsyncClient, headers, printer: Printer, archive: PrintArchive, cost_center_id: int | None):
- return await client.post(
- "/api/v1/queue/",
- json={"printer_id": printer.id, "archive_id": archive.id, "cost_center_id": cost_center_id},
- headers=headers,
- )
- class TestCostCenters:
- async def test_another_users_private_cost_center_is_refused(self, async_client, db_session, world):
- archive = await _archive(db_session, world["owner"].id, 1)
- center = await _center(db_session, owner_user_id=world["other"].id)
- key = await _key(db_session, world["owner"].id)
- by_key = await _queue(async_client, key, world["printer"], archive, center.id)
- by_session = await _queue(
- async_client, await _login(async_client, "keyowner"), world["printer"], archive, center.id
- )
- assert by_key.status_code == by_session.status_code == 403
- assert await db_session.scalar(select(PrintQueueItem)) is None
- async def test_a_shared_cost_center_needs_membership(self, async_client, db_session, world):
- archive = await _archive(db_session, world["owner"].id, 1)
- center = await _center(db_session, member_id=world["other"].id)
- key = await _key(db_session, world["owner"].id)
- assert (await _queue(async_client, key, world["printer"], archive, center.id)).status_code == 403
- async def test_the_owners_own_cost_center_works_and_the_item_is_the_owners(self, async_client, db_session, world):
- archive = await _archive(db_session, world["owner"].id, 1)
- center = await _center(db_session, owner_user_id=world["owner"].id)
- key = await _key(db_session, world["owner"].id)
- response = await _queue(async_client, key, world["printer"], archive, center.id)
- assert response.status_code == 200, response.text
- item = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == response.json()["id"]))
- assert item.cost_center_id == center.id
- # Credited to the owner, so the scheduler's check at print start has
- # someone to check, and the owner sees it under queue:read_own.
- assert item.created_by_id == world["owner"].id
- async def test_a_cost_center_the_owner_is_a_member_of_works(self, async_client, db_session, world):
- archive = await _archive(db_session, world["owner"].id, 1)
- center = await _center(db_session, member_id=world["owner"].id)
- key = await _key(db_session, world["owner"].id)
- assert (await _queue(async_client, key, world["printer"], archive, center.id)).status_code == 200
- async def test_an_admins_key_may_use_any_cost_center_like_the_admin(self, async_client, db_session, world):
- archive = await _archive(db_session, world["admin"].id, 1)
- center = await _center(db_session, owner_user_id=world["other"].id)
- key = await _key(db_session, world["admin"].id)
- assert (await _queue(async_client, key, world["printer"], archive, center.id)).status_code == 200
- async def test_a_key_without_an_owner_may_use_no_cost_center(self, async_client, db_session, world):
- archive = await _archive(db_session, None, 1)
- center = await _center(db_session, member_id=world["owner"].id)
- key = await _key(db_session, None)
- assert (await _queue(async_client, key, world["printer"], archive, center.id)).status_code == 403
- async def test_moving_an_item_to_a_forbidden_cost_center_is_refused(self, async_client, db_session, world):
- # PATCH through a key needs the owner to hold queue:update_all.
- group = await db_session.scalar(select(Group).where(Group.name == "own-queuers"))
- group.permissions = [*group.permissions, "queue:update_all"]
- await db_session.commit()
- archive = await _archive(db_session, world["owner"].id, 1)
- allowed = await _center(db_session, owner_user_id=world["owner"].id)
- forbidden = await _center(db_session, owner_user_id=world["other"].id)
- key = await _key(db_session, world["owner"].id)
- created = await _queue(async_client, key, world["printer"], archive, allowed.id)
- assert created.status_code == 200, created.text
- response = await async_client.patch(
- f"/api/v1/queue/{created.json()['id']}", json={"cost_center_id": forbidden.id}, headers=key
- )
- assert response.status_code == 403
- item = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == created.json()["id"]))
- await db_session.refresh(item)
- assert item.cost_center_id == allowed.id
- class TestListingCostCenters:
- async def test_a_key_lists_its_owners_cost_centers(self, async_client, db_session, world):
- mine = await _center(db_session, owner_user_id=world["owner"].id)
- shared = await _center(db_session, member_id=world["owner"].id)
- await _center(db_session, owner_user_id=world["other"].id)
- key = await _key(db_session, world["owner"].id)
- by_key = await async_client.get("/api/v1/finance/cost-centers/mine", headers=key)
- by_session = await async_client.get(
- "/api/v1/finance/cost-centers/mine", headers=await _login(async_client, "keyowner")
- )
- assert by_key.status_code == 200, by_key.text
- assert {c["id"] for c in by_key.json()} == {mine.id, shared.id}
- assert by_key.json() == by_session.json()
- async def test_a_key_that_cannot_queue_gets_none(self, async_client, db_session, world):
- await _center(db_session, owner_user_id=world["owner"].id)
- key = await _key(db_session, world["owner"].id, can_queue=False)
- assert (await async_client.get("/api/v1/finance/cost-centers/mine", headers=key)).status_code == 403
- async def test_a_key_without_an_owner_has_none(self, async_client, db_session, world):
- await _center(db_session, owner_user_id=world["owner"].id)
- key = await _key(db_session, None)
- response = await async_client.get("/api/v1/finance/cost-centers/mine", headers=key)
- assert response.status_code == 200
- assert response.json() == []
- class TestSources:
- async def test_another_users_archive_is_not_found(self, async_client, db_session, world):
- await _set(db_session, "billing_enabled", "false")
- archive = await _archive(db_session, world["other"].id, 1)
- key = await _key(db_session, world["owner"].id)
- by_key = await _queue(async_client, key, world["printer"], archive, None)
- by_session = await _queue(async_client, await _login(async_client, "keyowner"), world["printer"], archive, None)
- assert by_key.status_code == by_session.status_code == 404
- async def test_another_users_library_file_is_not_found(self, async_client, db_session, world):
- await _set(db_session, "billing_enabled", "false")
- rel_path = "archive/library/files/probe_3256.gcode.3mf"
- abs_path = Path(app_settings.base_dir) / rel_path
- abs_path.parent.mkdir(parents=True, exist_ok=True)
- abs_path.write_bytes(b"probe")
- library_file = LibraryFile(
- filename="probe_3256.gcode.3mf",
- file_path=rel_path,
- file_size=5,
- file_type="3mf",
- created_by_id=world["other"].id,
- )
- db_session.add(library_file)
- await db_session.commit()
- key = await _key(db_session, world["owner"].id)
- try:
- response = await async_client.post(
- "/api/v1/queue/",
- json={"printer_id": world["printer"].id, "library_file_id": library_file.id},
- headers=key,
- )
- finally:
- abs_path.unlink(missing_ok=True)
- assert response.status_code == 404
- async def test_the_owners_own_archive_still_queues(self, async_client, db_session, world):
- await _set(db_session, "billing_enabled", "false")
- archive = await _archive(db_session, world["owner"].id, 1)
- key = await _key(db_session, world["owner"].id)
- assert (await _queue(async_client, key, world["printer"], archive, None)).status_code == 200
- class TestBatches:
- async def test_another_users_batch_cannot_be_changed(self, async_client, db_session, world):
- await _set(db_session, "billing_enabled", "false")
- batch = PrintBatch(name="theirs", created_by_id=world["other"].id)
- db_session.add(batch)
- await db_session.commit()
- key = await _key(db_session, world["owner"].id)
- update = await async_client.patch(f"/api/v1/queue/batches/{batch.id}", json={"name": "mine"}, headers=key)
- ungroup = await async_client.post(f"/api/v1/queue/batches/{batch.id}/ungroup", headers=key)
- assert update.status_code == 404
- assert ungroup.status_code in (403, 404)
- await db_session.refresh(batch)
- assert batch.name == "theirs"
- @pytest.fixture
- async def library_world(db_session, world):
- """The same owner, who may also upload, read stats and pipelines, and a
- key with the library and status scopes as well."""
- await _set(db_session, "billing_enabled", "false")
- group = await db_session.scalar(select(Group).where(Group.name == "own-queuers"))
- group.permissions = [*group.permissions, "library:upload", "stats:read", "pipelines:read"]
- full_key, key_hash, key_prefix = generate_api_key()
- db_session.add(
- APIKey(
- name="library probe",
- key_hash=key_hash,
- key_prefix=key_prefix,
- user_id=world["owner"].id,
- can_queue=True,
- can_read_status=True,
- can_manage_library=True,
- )
- )
- theirs = LibraryFolder(name="theirs", created_by_id=world["other"].id, shared=False)
- db_session.add(theirs)
- await db_session.commit()
- return {**world, "key": {"X-API-Key": full_key}, "their_folder": theirs}
- async def _their_file(db_session, world, filename: str = "theirs.stl") -> LibraryFile:
- row = LibraryFile(
- filename=filename,
- file_path=f"library/files/{filename}",
- file_type=filename.rsplit(".", 1)[-1],
- file_size=1024,
- created_by_id=world["other"].id,
- )
- db_session.add(row)
- await db_session.commit()
- await db_session.refresh(row)
- return row
- _SLICE_BODY = {"printer_preset_id": 1, "process_preset_id": 2, "filament_preset_id": 3}
- class TestLibrary:
- async def test_no_folder_inside_another_users_private_folder(self, async_client, db_session, library_world):
- body = {"name": "sneaky", "parent_id": library_world["their_folder"].id}
- by_key = await async_client.post("/api/v1/library/folders", json=body, headers=library_world["key"])
- by_session = await async_client.post(
- "/api/v1/library/folders", json=body, headers=await _login(async_client, "keyowner")
- )
- assert by_key.status_code == by_session.status_code == 404
- async def test_a_keys_folder_is_its_owners_and_not_shared(self, async_client, db_session, library_world):
- response = await async_client.post(
- "/api/v1/library/folders", json={"name": "mine"}, headers=library_world["key"]
- )
- assert response.status_code == 200, response.text
- folder = await db_session.get(LibraryFolder, response.json()["id"])
- assert folder.created_by_id == library_world["owner"].id
- assert folder.shared is False
- async def test_no_upload_into_another_users_private_folder(self, async_client, db_session, library_world):
- response = await async_client.post(
- "/api/v1/library/files",
- params={"folder_id": library_world["their_folder"].id},
- files={"file": ("cube.stl", b"solid cube\nendsolid cube\n", "application/octet-stream")},
- headers=library_world["key"],
- )
- assert response.status_code == 404
- async def test_no_combining_another_users_file(self, async_client, db_session, library_world):
- theirs = await _their_file(db_session, library_world)
- response = await async_client.post(
- "/api/v1/library/files/combine",
- json={"items": [{"file_id": theirs.id}], "filename": "mix"},
- headers=library_world["key"],
- )
- assert response.status_code == 404
- assert response.json()["detail"] == "File not found"
- async def test_no_slicing_another_users_file(self, async_client, db_session, library_world):
- theirs = await _their_file(db_session, library_world)
- response = await async_client.post(
- f"/api/v1/library/files/{theirs.id}/slice", json=_SLICE_BODY, headers=library_world["key"]
- )
- assert response.status_code == 404
- assert response.json()["detail"] == "File not found"
- async def test_no_queueing_another_users_file_from_the_library(self, async_client, db_session, library_world):
- theirs = await _their_file(db_session, library_world, "theirs.gcode.3mf")
- response = await async_client.post(
- "/api/v1/library/files/add-to-queue",
- json={"file_ids": [theirs.id], "printer_id": library_world["printer"].id},
- headers=library_world["key"],
- )
- # The same answer an unknown id gets
- assert response.status_code == 400
- assert response.json()["detail"]["errors"][0]["error"] == "File not found"
- assert await db_session.scalar(select(PrintQueueItem)) is None
- class TestArchivesAndPipelines:
- async def test_no_slicing_another_users_archive(self, async_client, db_session, library_world):
- archive = await _archive(db_session, library_world["other"].id, 1)
- response = await async_client.post(
- f"/api/v1/archives/{archive.id}/slice", json=_SLICE_BODY, headers=library_world["key"]
- )
- assert response.status_code == 404
- assert response.json()["detail"] == "Archive not found"
- async def test_no_per_user_stats_without_the_owners_permission(self, async_client, db_session, library_world):
- by_key = await async_client.get(
- "/api/v1/archives/stats",
- params={"created_by_id": library_world["other"].id},
- headers=library_world["key"],
- )
- by_session = await async_client.get(
- "/api/v1/archives/stats",
- params={"created_by_id": library_world["other"].id},
- headers=await _login(async_client, "keyowner"),
- )
- assert by_key.status_code == by_session.status_code == 403
- async def test_no_pipeline_on_another_users_file(self, async_client, db_session, library_world):
- theirs = await _their_file(db_session, library_world)
- created = await async_client.post(
- "/api/v1/slicer-pipelines/",
- json={
- "name": "Batch",
- "description": None,
- "printer_preset": {"source": "local", "id": "1"},
- "process_preset": {"source": "local", "id": "2"},
- "filament_presets": [{"source": "local", "id": "3"}],
- "bed_type": None,
- },
- headers=await _login(async_client, "adminowner"),
- )
- assert created.status_code == 201, created.text
- response = await async_client.post(
- f"/api/v1/slicer-pipelines/{created.json()['id']}/check-eligibility",
- json={"source_library_file_id": theirs.id},
- headers=library_world["key"],
- )
- # Refused by the ownership check, not later for the missing file
- assert response.status_code == 404
- assert response.json()["detail"] == "File not found"
|