fix(makerworld): resolve API-key owner for cloud-token lookups (#1777)

The makerworld /status, /resolve, and /import handlers passed
  current_user directly into get_stored_token / _build_service.
  require_permission_if_auth_enabled returns None for API-keyed
  callers by design (core/auth.py:1414), so the lookup always
  missed even when the key's owner had a stored Bambu Cloud session.
  Result: a "requires a Bambu Cloud login" 400 on every API-keyed
  import, regardless of the owning account's actual cloud state.

  Wire resolve_api_key_cloud_owner (already used by the slice path
  in #1182 — slicer_presets.py:491 and library.py:3871) into the
  three makerworld routes that read the cloud token. The handler
  falls back to the API-key owner via cloud_token_user =
  current_user or api_key_cloud_owner, then passes that through.
  import_instance also propagates the resolved user to the
  owner_id arg on save_3mf_bytes_to_library, so the resulting
  LibraryFile.created_by_id reflects the key's owner instead of
  NULL.

  Fail-closed semantics preserved: resolve_api_key_cloud_owner
  already fences on api_key.can_access_cloud, so keys with only
  the per-route scope (can_read_status / can_manage_library) still
  take the existing "requires Bambu Cloud login" path — no auth
  widening.

  /recent-imports is unchanged — it only uses current_user as a
  permission gate (_ = current_user) and never touches the cloud
  token.
This commit is contained in:
maziggy
2026-06-19 08:05:20 +02:00
parent 3ef5119b7d
commit 9f8bac63ff
3 changed files with 328 additions and 6 deletions
+1
View File
@@ -16,6 +16,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Admin-configurable session lifetime (#1706, reported by @AD3DStuff)** — The 24-hour session cap that ships with Bambuddy was an intentional security hardening (audit finding M-2 reduced it from 7 days), but the "Remember Me" checkbox only controlled storage location (localStorage vs sessionStorage), not session duration. iPhone PWA users and homelab admins on trusted networks were getting kicked out every 24 hours with no way to extend it. **New setting:** `session_max_hours` under Settings → Users with three presets (24h / 7 days / 30 days) plus a custom field, hard-capped at 30 days (720h). Default remains 24h so existing deployments and the M-2 audit baseline are untouched until an admin opts in. The Settings card surfaces a yellow warning whenever the value exceeds 24h: "Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments." **Backend wiring:** new `resolve_session_max_minutes(db)` helper in `backend/app/core/auth.py` reads the setting, clamps to [1h, 720h], and falls back to 24h on missing / blank / unparseable values. The helper is called at all four token-issuance sites — plain `/auth/login`, 2FA TOTP/email completion, 2FA backup-code completion, and OIDC callback — so a long-session policy works uniformly regardless of how the user authenticates. DB errors in the resolver are deliberately NOT caught: login is already inside a transaction and a broken DB must abort the login rather than silently extend or shrink the session lifetime. Defense-in-depth `SESSION_MAX_HOURS_HARD_CEILING = 720` clamps any tampered DB row above the Pydantic ceiling. Already-issued tokens keep their original expiry — the new setting only affects future logins, so an admin lowering the value can't retroactively revoke active sessions and an admin raising it can't retroactively extend them. **What this does NOT change:** the "Remember Me" checkbox still controls only storage location (cleared on browser close vs persisted across restarts). The relabel from misleading-UX-perspective is left for a separate follow-up — that's a UX choice independent of the session-policy mechanism. API tokens (`MAX_TOKEN_LIFETIME_DAYS`), camera stream tokens (60min), WebSocket tokens (60min), and slicer download tokens (5min) keep their own TTLs and are unaffected. **Tests:** 15 new cases in `backend/tests/integration/test_session_policy.py` split across three classes. `TestResolveSessionMaxMinutes` pins the clamping resolver — missing row, empty string, unparseable value, zero/negative, 1h minimum, 7-day passthrough, 30-day passthrough, above-ceiling clamp. `TestLoginRespectsSessionPolicy` decodes the JWT `exp` claim end-to-end and asserts the token returned by `/auth/login` honours the configured ceiling for the default-24h, configured-7d, and above-ceiling-clamp cases. `TestSettingsAPIExposesSessionMaxHours` round-trips the field through `/settings/` (default = 24, valid update persists as int's string form, zero rejected with 422, above-ceiling rejected with 422). Existing 202-case auth + MFA suite still green. **i18n:** 8 new keys in `settings.sessionPolicy.*` namespace; full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5149 leaves per locale. ESLint clean; `npm run build` clean; ruff clean.
### Fixed
- **MakerWorld import/resolve/status fail under API-key auth even when the owner has a Bambu Cloud login (#1777, reported by @Mx772)** — The reporter (working on a browser extension that drives Bambuddy via `X-API-Key`) noticed that `POST /api/v1/makerworld/import` and `POST /api/v1/makerworld/resolve` returned `{"detail":"Downloading files from MakerWorld requires a Bambu Cloud login"}` even when the key's owning user had a valid stored Bambu Cloud session, and the same imports succeeded from the web UI. Root cause is exactly the shape the reporter traced: `require_permission_if_auth_enabled` in `backend/app/core/auth.py:1414` deliberately returns `current_user=None` for API-keyed callers — the comment at line 1408 makes this explicit and points at `cloud.py` for the resolver. The MakerWorld routes never got that resolver wired in, so `_build_service(db, None)` → `get_stored_token(db, None)` → no token → the "requires Bambu Cloud login" branch fires regardless of what the owning account has set up. Same shape #1182 fixed for cloud slicer presets, and the canonical fix for non-`/cloud/*` routes is already in the codebase as `resolve_api_key_cloud_owner` (cloud.py:128-160) — used by `slicer_presets.py:491` and `library.py:3871`. The MakerWorld routes were missing the wire-up. **Fix:** Three routes get the extra `api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner)` parameter — `get_status`, `resolve_url`, `import_instance` — and each resolves `cloud_token_user = current_user or api_key_cloud_owner` before calling `get_stored_token` / `_build_service`. `import_instance` additionally uses `cloud_token_user.id` for the `owner_id` argument to `save_3mf_bytes_to_library` (which translates to `LibraryFile.created_by_id`), so library rows imported via API key are now attributed to the key's owner instead of staying NULL. `/recent-imports` is unchanged — it only uses `current_user` as a permission gate (`_ = current_user`) and never touches the cloud token. The fix preserves fail-closed semantics for keys *without* the `can_access_cloud` flag: `resolve_api_key_cloud_owner` already fences on `api_key.user_id is not None and api_key.can_access_cloud` (cloud.py:158), so a key with only the per-route scope (`can_read_status` / `can_manage_library`) still surfaces the "requires Bambu Cloud login" error path — no new auth gap. **Two scope fields the API key needs:** the per-route scope (`MAKERWORLD_VIEW` → `can_read_status`, `MAKERWORLD_IMPORT` → `can_manage_library` per `_APIKEY_SCOPE_BY_PERMISSION` in `core/auth.py`) AND the orthogonal `can_access_cloud` flag (separate column on the `api_keys` table). The fix doesn't change that surface — it just stops dropping valid `can_access_cloud=True` keys on the floor. **Tests:** 6 new cases in `backend/tests/integration/test_makerworld_apikey_auth.py` pinning the full surface — API key with `can_access_cloud=True` + owner-has-token → `/status` reports `has_cloud_token=True`, `/resolve` builds the service with the owner User (asserted on the `_build_service` mock's call args), `/import` succeeds end-to-end and the resulting `LibraryFile.created_by_id` matches the API-key owner; API key with `can_access_cloud=False` → status still reports `has_cloud_token=False` (no widening) and import-row's `created_by_id` stays NULL; JWT-authenticated parity check confirms the existing user-session flow is unchanged by the added `Depends`. 6/6 new tests green; full backend suite (6157 tests) still green; ruff clean. No frontend change, no DB migration, no new permission, no new dependency. The reporter's browser extension and any other API-keyed Home Assistant / automation integration unblocks immediately on next deploy.
- **Archive thumbnails missing for prints sliced via the docker sidecar (#1759, reported by @VID-PRO)** — The reporter (P2S) noticed every print sliced through Bambuddy's BS docker sidecar landed in the archive with no thumbnail, while the same model sliced from desktop Bambu Studio on their laptop showed the cover image. The "Some recent prints couldn't be archived with thumbnails" banner pointed at install step 4 (`Store sent files on external storage`) which is unrelated — that flag is set on FTP-fetch failures, not on missing-thumb in the sliced 3MF. Root cause is upstream of Bambuddy entirely: **neither the BambuStudio CLI nor the OrcaSlicer CLI renders `Metadata/plate_N.png` when invoked headlessly with `--slice --export-3mf`.** That render is a separate code path triggered by the `--export-png` flag, which is mutually exclusive with `--export-3mf` and additionally requires a working display backend (BS 02.07.x's bundled GLFW is hard-locked to Wayland — even `XDG_SESSION_TYPE=x11` + `GDK_BACKEND=x11` + `QT_QPA_PLATFORM=xcb` don't switch it back to X11, so an Xvfb display in the sidecar wouldn't help even if we wired a second-pass call). Confirmed empirically by feeding a thumbnail-stripped `Cube-MegaS.3mf` through both sidecars: both produced `.gcode.3mf` with zero PNG entries. The Orca sidecar has been silently shipping thumbnail-less 3MFs from STL inputs since it launched; nobody noticed until VID-PRO filed this against BS specifically. **Fix:** New `backend/app/services/plate_thumbnail.py` renders the missing thumbnails server-side after the slice returns. `inject_plate_thumbnails_if_missing(threemf_bytes)` parses the sliced zip, finds every `Metadata/plate_N.gcode` entry that doesn't have a matching `plate_N.png`, loads `3D/3dmodel.model` via trimesh, renders an isometric Bambu-green-on-dark view at 512×512 (`plate_N.png`) + 128×128 (`plate_N_small.png`) using the same matplotlib Agg pipeline as `stl_thumbnail.py`, and re-packs the zip with the PNGs injected. Visual style deliberately matches Bambuddy's existing library thumbnails — archive cards stay consistent inside Bambuddy rather than chasing parity with desktop Studio's plate render. Best-effort: input bytes are returned unchanged on any failure (no model file, trimesh can't parse, matplotlib render fails) so the slice flow itself can't fail because of a missing thumbnail. Idempotent: re-running on a previously-injected 3MF hits the no-op fast path and returns the input verbatim. Wired into both `backend/app/api/routes/library.py` slice paths (library-file slice at line 3593 + archive re-slice at line 3718) via `result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))` immediately before `out_path.write_bytes(...)` — covers the cross-class merged-multi-plate path (`slicer_3mf_convert.merge_plate_3mfs`) automatically since merged bytes flow into the same write site. **Dependencies:** trimesh's 3MF loader imports `networkx` (scene-graph traversal) and `lxml` (model.xml parse) lazily inside the 3MF code path — both added to `requirements.txt` because they aren't strict trimesh transitives but the loader fails at runtime without them (`ModuleNotFoundError`). **Tests:** 7 new cases in `backend/tests/unit/services/test_plate_thumbnail.py`: input bytes returned unchanged (identity) when every plate already has a thumbnail (desktop-Studio fast path); both PNG sizes injected when missing; injected PNGs decode as 512x512 + 128x128 RGBA; multi-plate 3MF with one pre-existing thumbnail only renders the missing slots (pre-existing bytes preserved verbatim); 3MF with no `3D/3dmodel.model` returns input unchanged; non-zip input returns input unchanged; idempotent on second pass. **Verified end-to-end:** running `inject_plate_thumbnails_if_missing` against the actual BS sidecar and Orca sidecar outputs (`/tmp/bs-no-thumb-out.3mf` / `/tmp/orca-no-thumb-out.3mf` — both 25932/25992 bytes with zero PNG entries) produces 3MFs with valid `Metadata/plate_1.png` + `Metadata/plate_1_small.png` containing the rendered cube model (38.5% Bambu-green pixel coverage confirms the model is actually drawn, not a blank canvas). 6151/6151 backend tests still green; ruff clean. No sidecar Dockerfile change required — earlier experiments with Xvfb + `xvfb-run` in `Dockerfile.bambu-studio` were a false start (the BS GLFW Wayland lock means no X display can help) and have been reverted from the sidecar repo. No frontend change required — the archive UI already extracts `plate_1.png` from the sliced 3MF, the cards just had nothing to show.
- **Local Presets page: deleted row stayed visible until refetch returned, allowing a second delete click → 404** — On the Slicer → Local Profiles page, clicking Delete → Confirm fired the `DELETE /api/v1/local-presets/{id}` request, then the `onSuccess` handler closed the confirmation modal and called `queryClient.invalidateQueries({ queryKey: ['localPresets'] })` without awaiting it. The global QueryClient default `staleTime: 1000 * 60` (App.tsx:78) doesn't block `invalidateQueries` from refetching, but the refetch is *async* — so for ~hundreds of ms the rendered table still showed the just-deleted row, and a quick re-click on the same row opened a fresh confirm dialog → second confirm → backend returns 404 (row already gone) → confusing error toast. Caught while reproducing #1713: log showed `DELETE /api/v1/local-presets/42 → 200` followed by two `→ 404` for the same id within 4 seconds. **Fix:** Add an optimistic `queryClient.setQueryData<LocalPreset[]>(['localPresets'], …)` in `frontend/src/components/LocalProfilesView.tsx::deleteMutation.onSuccess` that filters the deleted row out of the cached list synchronously, then leaves the existing `invalidateQueries` calls in place to reconcile any drift. Row disappears the instant the DELETE returns 200, no re-click window. The same import path's `importMutation` doesn't need the same treatment because additions can't trigger the symmetric "row I just acted on is still there" → 404 loop. ESLint clean; `npm run build` clean; existing `LocalProfilesView.test.tsx` suite still green (no new test added — the bug is a render-timing window the existing render-based vitests don't observe; the existing onSuccess assertions still pass with the new optimistic write).
- **SpoolBuddy inventory search now matches spool ID, slicer filament name, and storage location (#1738, reported by @shaddowlink)** — The reporter found that typing a numeric spool ID into SpoolBuddy → Inventory's search box returned no results, even though the same query in Bambuddy's main Inventory page worked. Root cause: `frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx:147-155` reimplemented the search filter inline and only matched `material`, `subtype`, `brand`, `color_name`, and `note`. The main Inventory page delegates to the shared `filterSpoolsByQuery` helper in `frontend/src/utils/inventorySearch.ts:7`, which additionally matches `String(spool.id)`, `slicer_filament_name`, and `storage_location`. SpoolBuddy had diverged. **Fix:** replace the inline filter with a single call to `filterSpoolsByQuery(list, searchQuery.trim())`. Both inventory modes (internal via `getSpools`, Spoolman via `getSpoolmanInventorySpools`) return the same `InventorySpool` shape, so this covers both paths in one drop. SpoolBuddy now matches Bambuddy's search behaviour across all eight fields. **Tests:** new `SpoolBuddyInventorySearch.test.ts` with 4 cases pinning the parity — exact spool ID match, partial spool ID match, the five pre-fix fields still match, and the three newly-included fields (storage_location, slicer_filament_name, plus implicit id) match. Existing `inventorySearch.test.ts` ID matching test (#1336) still green. ESLint clean; `npm run build` clean. No backend change, no i18n, no new permission.
+25 -6
View File
@@ -21,7 +21,7 @@ from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.api.routes.cloud import get_stored_token
from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
from backend.app.api.routes.library import save_3mf_bytes_to_library
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
@@ -143,9 +143,18 @@ async def proxy_thumbnail(
async def get_status(
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
):
"""Report whether the caller can import 3MFs (needs a Bambu Cloud token)."""
token, _email, _region = await get_stored_token(db, current_user)
"""Report whether the caller can import 3MFs (needs a Bambu Cloud token).
API-keyed callers (which return None from ``current_user``) get the
owner User via ``resolve_api_key_cloud_owner`` when the key carries the
cloud-access scope, so ``has_cloud_token`` reflects the owning user's
stored token rather than always reporting ``False`` (#1777, same shape
as the cloud-presets fix in #1182).
"""
cloud_token_user = current_user or api_key_cloud_owner
token, _email, _region = await get_stored_token(db, cloud_token_user)
has_token = bool(token)
return MakerWorldStatus(has_cloud_token=has_token, can_download=has_token)
@@ -155,6 +164,7 @@ async def resolve_url(
body: MakerWorldResolveRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
):
"""Resolve a MakerWorld URL to full model metadata + plate list.
@@ -167,7 +177,10 @@ async def resolve_url(
except MakerWorldError as exc:
raise _map_service_error(exc) from exc
service = await _build_service(db, current_user)
# API-keyed callers carry identity on the key, not in current_user — see
# the /status handler comment and #1777 / #1182.
cloud_token_user = current_user or api_key_cloud_owner
service = await _build_service(db, cloud_token_user)
try:
design = await service.get_design(model_id)
instances_envelope = await service.get_design_instances(model_id)
@@ -240,6 +253,7 @@ async def import_instance(
body: MakerWorldImportRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_IMPORT),
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
):
"""Download a specific MakerWorld instance (plate configuration) and save
the 3MF into the library.
@@ -278,7 +292,12 @@ async def import_instance(
await db.flush()
effective_folder_id = mw_folder.id
service = await _build_service(db, current_user)
# API-keyed callers carry identity on the key, not in current_user — see
# the /status handler comment and #1777 / #1182. The same resolved user
# is reused for owner_id on save_3mf_bytes_to_library below so the
# library row is attributed to the key's owner rather than NULL.
cloud_token_user = current_user or api_key_cloud_owner
service = await _build_service(db, cloud_token_user)
# YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
# (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N}.
@@ -387,7 +406,7 @@ async def import_instance(
folder_id=effective_folder_id,
source_type=_SOURCE_TYPE,
source_url=source_url,
owner_id=current_user.id if current_user else None,
owner_id=cloud_token_user.id if cloud_token_user else None,
)
return MakerWorldImportResponse(
@@ -0,0 +1,302 @@
"""Integration tests for #1777 — API-keyed callers on /makerworld/*.
The contract being pinned (mirrors the slice path's #1182 follow-up):
When auth is enabled and the request carries an X-API-Key whose owner
has a stored Bambu Cloud token, the makerworld routes must resolve
identity via ``resolve_api_key_cloud_owner`` (instead of always seeing
``current_user=None``) so:
- /status reports ``has_cloud_token=True`` for keys whose owner has a token
- /resolve builds a MakerWorldService seeded with that token
- /import succeeds end-to-end and attributes the resulting LibraryFile
to the API-key owner
The fail-closed path is preserved: keys without ``can_access_cloud=True``
still surface the "requires Bambu Cloud login" experience (no auth gap).
"""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
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.library import LibraryFile
from backend.app.models.user import User
async def _setup_auth_with_admin(client: AsyncClient) -> str:
await client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": "mwadmin",
"admin_password": "AdminPass1!",
},
)
login = await client.post(
"/api/v1/auth/login",
json={"username": "mwadmin", "password": "AdminPass1!"},
)
return login.json()["access_token"]
async def _store_admin_cloud_token(db: AsyncSession, username: str, token: str) -> User:
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one()
user.cloud_token = token
user.cloud_email = "owner@example.com"
user.cloud_region = "global"
await db.commit()
await db.refresh(user)
return user
async def _make_key(
db: AsyncSession,
*,
owner: User,
name: str,
can_access_cloud: bool = True,
can_read_status: bool = True,
can_manage_library: bool = True,
) -> str:
"""Mint an API key with the scopes /makerworld/* expects.
/status + /resolve gate on ``Permission.MAKERWORLD_VIEW`` which maps
to the ``can_read_status`` scope (see ``_APIKEY_SCOPE_BY_PERMISSION``
in core/auth.py). /import gates on ``Permission.MAKERWORLD_IMPORT``
which maps to ``can_manage_library``. ``can_access_cloud`` is what
``resolve_api_key_cloud_owner`` checks before returning the owner —
the separate field this PR's fix actually depends on.
"""
full_key, key_hash, key_prefix = generate_api_key()
row = APIKey(
name=name,
key_hash=key_hash,
key_prefix=key_prefix,
user_id=owner.id,
can_access_cloud=can_access_cloud,
can_read_status=can_read_status,
can_manage_library=can_manage_library,
)
db.add(row)
await db.commit()
return full_key
def _fake_service(**stubs):
"""Mirror of the fixture in test_makerworld_routes.py — AsyncMock with
method stubs that return the supplied payloads."""
svc = AsyncMock()
svc.close = AsyncMock()
for name, value in stubs.items():
if callable(value) and not isinstance(value, AsyncMock):
setattr(svc, name, AsyncMock(side_effect=value))
else:
setattr(svc, name, AsyncMock(return_value=value))
return svc
class TestStatusEndpoint:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_api_key_owner_with_token_sees_has_cloud_token_true(
self, async_client: AsyncClient, db_session: AsyncSession
):
await _setup_auth_with_admin(async_client)
admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
key = await _make_key(db_session, owner=admin, name="status-cloud")
resp = await async_client.get(
"/api/v1/makerworld/status",
headers={"X-API-Key": key},
)
assert resp.status_code == 200, resp.text
assert resp.json() == {"has_cloud_token": True, "can_download": True}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_api_key_without_cloud_scope_reports_no_token(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""Key has the per-route scope (can_read_status) but NOT can_access_cloud.
Before this PR, both these conditions reported has_cloud_token=False.
After the PR the per-route scope alone still doesn't grant cloud
access — the resolver fences on can_access_cloud — so the response
is unchanged for this case. Pinning so a future change can't
accidentally widen the gate.
"""
await _setup_auth_with_admin(async_client)
admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
key = await _make_key(db_session, owner=admin, name="status-no-cloud", can_access_cloud=False)
resp = await async_client.get(
"/api/v1/makerworld/status",
headers={"X-API-Key": key},
)
assert resp.status_code == 200
assert resp.json() == {"has_cloud_token": False, "can_download": False}
class TestResolveEndpoint:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_api_key_owner_with_token_builds_authed_service(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""The route must reach ``_build_service`` with the API-key owner's
User, which is what ultimately seeds MakerWorldService.auth_token.
We assert on the resolved user argument the route passes through —
the upstream MakerWorld API call is mocked so the test stays offline.
"""
await _setup_auth_with_admin(async_client)
admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
key = await _make_key(db_session, owner=admin, name="resolve-cloud")
design = {"id": 1400373, "modelId": "US2bb73b106683e5", "title": "Cube", "instances": []}
instances = {"total": 0, "hits": []}
svc = _fake_service(get_design=design, get_design_instances=instances)
build = AsyncMock(return_value=svc)
with patch("backend.app.api.routes.makerworld._build_service", build):
resp = await async_client.post(
"/api/v1/makerworld/resolve",
json={"url": "https://makerworld.com/en/models/1400373"},
headers={"X-API-Key": key},
)
assert resp.status_code == 200, resp.text
# _build_service receives (db, user); the user arg must be the owning admin.
# Without the fix it'd be None (the API-key dep value).
assert build.await_count == 1
passed_user = (
build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
)
assert passed_user is not None, "resolve_url must pass the API-key owner, not None"
assert passed_user.id == admin.id
class TestImportEndpoint:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_api_key_owner_import_succeeds_and_stamps_owner_id(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""End-to-end: /import via X-API-Key downloads the 3MF and saves it
with the API-key owner's id on the LibraryFile row, not NULL."""
await _setup_auth_with_admin(async_client)
admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
key = await _make_key(db_session, owner=admin, name="import-cloud")
design = {
"id": 1400373,
"modelId": "US2bb73b106683e5",
"title": "Cube",
"instances": [{"profileId": 298919107, "title": "default"}],
}
manifest = {
"name": "cube.3mf",
"url": "https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
}
# 3MF download returns (bytes, filename). The bytes don't have to be a
# valid zip — save_3mf_bytes_to_library stores them as-is and the
# downstream thumbnail extractor swallows errors.
svc = _fake_service(
get_design=design,
get_profile_download=manifest,
download_3mf=(b"PK\x03\x04fake-3mf-bytes", "cube.3mf"),
)
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
resp = await async_client.post(
"/api/v1/makerworld/import",
json={"model_id": 1400373},
headers={"X-API-Key": key},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["was_existing"] is False
# The library row was attributed to the API-key owner.
# save_3mf_bytes_to_library translates owner_id → created_by_id on the
# LibraryFile column (see library.py:534).
result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
saved = result.scalar_one()
assert saved.created_by_id == admin.id, "Import via API key must attribute the row to the key's owner, not NULL"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_api_key_without_cloud_scope_still_imports_but_owner_is_none(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""Fail-closed parity: a key with can_manage_library but NOT
can_access_cloud reaches the route (permission gate passes) but
the cloud-token resolver returns None, so the service is built
without a token. The MakerWorldService itself would 401 on
get_profile_download in production — here we just confirm the
route doesn't suddenly grant cloud identity from a non-cloud key,
and that the library row's owner_id stays NULL when there's no
resolved cloud-scoped owner.
"""
await _setup_auth_with_admin(async_client)
admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
key = await _make_key(db_session, owner=admin, name="import-no-cloud", can_access_cloud=False)
design = {
"id": 1400373,
"modelId": "US2bb73b106683e5",
"instances": [{"profileId": 298919107}],
}
manifest = {"name": "cube.3mf", "url": "https://makerworld.bblmw.com/x.3mf"}
svc = _fake_service(
get_design=design,
get_profile_download=manifest,
download_3mf=(b"PK\x03\x04fake", "cube.3mf"),
)
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)) as build:
resp = await async_client.post(
"/api/v1/makerworld/import",
json={"model_id": 1400373},
headers={"X-API-Key": key},
)
assert resp.status_code == 200, resp.text
body = resp.json()
# _build_service got None — same as before the PR for non-cloud keys.
passed_user = (
build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
)
assert passed_user is None
# And owner_id is NULL because the cloud-scope fence said no.
result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
saved = result.scalar_one()
assert saved.created_by_id is None
class TestJwtPathUnchanged:
"""Parity check — the existing JWT-authed flow must keep behaving as
it did. The added Depends(resolve_api_key_cloud_owner) returns None
for JWT callers so current_user from RequirePermissionIfAuthEnabled
wins the ``or`` and nothing about the JWT path changes."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_status_with_jwt_admin_token(self, async_client: AsyncClient, db_session: AsyncSession):
admin_token = await _setup_auth_with_admin(async_client)
await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
resp = await async_client.get(
"/api/v1/makerworld/status",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert resp.json() == {"has_cloud_token": True, "can_download": True}