mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 13:11:35 +02:00
Reads, lists, and slices with profiles from a user's Orca Cloud account (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub / email+password); password defaults. Paste-flow PKCE because Orca's Supabase project only allowlists localhost redirect_to — open feature request at OrcaSlicer/OrcaSlicer#14028. Surfaces: - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same rich layout (search + 5 filter dropdowns + 3-column grouped grid + read-only detail modal) - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud > standard); separate status banner per cloud; metadata-aware pre-pick scores Orca filaments above local (Orca's sync_pull returns full content inline so filament_type / filament_colour come for free, no per-setting fetch rate-limit dance) - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed orca_<UUID> to match local_/builtin_); generic Bambu filament-ID derivation from parsed material (printer firmware can't grok Orca UUIDs); slot mapping persists preset_source='orca_cloud' - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the cloud preset list via Promise.allSettled (OrcaProfileMeta is structurally identical to SlicerSetting) Backend: - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange / single-use refresh rotation / get_user_info / list_profiles via the bare /sync/pull bootstrap path - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish, auth/password, status, logout, profiles, profiles/{id}); router-level _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers (SpoolBuddy kiosk) properly resolve their owner User; just-in-time refresh with atomic persist-before-API-call - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu Cloud fetcher (status vocabulary, 5min cache, permission shortcut); _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains orca_cloud + orca_cloud_status - services/preset_resolver.py: PresetRef.source extended with "orca_cloud"; _resolve_orca_cloud walks list + filters - 8 columns on users table for tokens (5 persistent) + transient PKCE handshake state with 10-min TTL (3); dialect-branched DATETIME / TIMESTAMP; auth-disabled mode falls back to Settings table - orca_cloud:auth permission folded into can_access_cloud API-key scope (same trust dimension)
312 lines
12 KiB
Python
312 lines
12 KiB
Python
"""Tests for the source-aware preset resolver used by the slice route."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from backend.app.schemas.slicer import PresetRef
|
|
from backend.app.services import preset_resolver
|
|
|
|
# --- standard tier --------------------------------------------------------
|
|
|
|
|
|
def test_standard_emits_inherits_stub():
|
|
"""Standard tier returns a JSON stub the sidecar's resolver can flatten
|
|
against `BUNDLED_PROFILES_PATH/<category>/<name>.json`. No content
|
|
round-trip needed — the sidecar reads the bundled JSON itself."""
|
|
out = preset_resolver._resolve_standard(
|
|
PresetRef(source="standard", id="Bambu Lab X1 Carbon 0.4 nozzle"),
|
|
slot="printer",
|
|
)
|
|
payload = json.loads(out)
|
|
assert payload == {
|
|
"name": "Bambu Lab X1 Carbon 0.4 nozzle",
|
|
"inherits": "Bambu Lab X1 Carbon 0.4 nozzle",
|
|
# `from: "system"` so the sidecar's compatibility check doesn't
|
|
# treat this as a User-authored profile and reject it against
|
|
# system filament/process pairs.
|
|
"from": "system",
|
|
# `type` is required by the CLI's --load-settings parser. Without
|
|
# it the CLI silently exits with rc=-5 ("input preset file is
|
|
# invalid"), causing every 3MF slice to fall back to embedded
|
|
# settings. See preset_resolver._SLOT_TO_PROFILE_TYPE.
|
|
"type": "machine",
|
|
}
|
|
|
|
|
|
def test_standard_emits_correct_type_per_slot():
|
|
"""Each slot maps to the right `type` value the CLI parser expects:
|
|
printer → machine, process → process, filament → filament. Missing or
|
|
wrong type causes the CLI to silently exit with rc=-5."""
|
|
for slot, expected_type in (("printer", "machine"), ("process", "process"), ("filament", "filament")):
|
|
out = preset_resolver._resolve_standard(
|
|
PresetRef(source="standard", id="anything"),
|
|
slot=slot,
|
|
)
|
|
assert json.loads(out)["type"] == expected_type, slot
|
|
|
|
|
|
def test_standard_rejects_unknown_slot():
|
|
with pytest.raises(HTTPException) as exc:
|
|
preset_resolver._resolve_standard(PresetRef(source="standard", id="anything"), slot="bogus")
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
# --- local tier -----------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_returns_setting_blob():
|
|
db = MagicMock()
|
|
preset = MagicMock()
|
|
preset.preset_type = "filament"
|
|
preset.setting = '{"name": "PLA Basic"}'
|
|
db.get = AsyncMock(return_value=preset)
|
|
|
|
out = await preset_resolver._resolve_local(db, PresetRef(source="local", id="42"), slot="filament")
|
|
assert out == '{"name": "PLA Basic"}'
|
|
db.get.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_rejects_non_integer_id():
|
|
db = MagicMock()
|
|
db.get = AsyncMock()
|
|
with pytest.raises(HTTPException) as exc:
|
|
await preset_resolver._resolve_local(db, PresetRef(source="local", id="not-a-number"), slot="filament")
|
|
assert exc.value.status_code == 400
|
|
db.get.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_rejects_wrong_preset_type():
|
|
"""A `local` ref pointing at a process preset for the filament slot
|
|
must fail — same guard the legacy slice path had."""
|
|
db = MagicMock()
|
|
preset = MagicMock()
|
|
preset.preset_type = "process"
|
|
db.get = AsyncMock(return_value=preset)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await preset_resolver._resolve_local(db, PresetRef(source="local", id="1"), slot="filament")
|
|
assert exc.value.status_code == 400
|
|
assert "preset_type='filament'" in exc.value.detail
|
|
|
|
|
|
# --- cloud tier -----------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_blocks_user_without_cloud_auth():
|
|
"""Defence-in-depth: a user holding LIBRARY_UPLOAD but not CLOUD_AUTH
|
|
cannot slice with cloud presets even if their User row carries a
|
|
leftover cloud_token from a previous permission state."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=False)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_400_when_no_token_stored():
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
with (
|
|
patch.object(
|
|
preset_resolver,
|
|
"get_stored_token",
|
|
AsyncMock(return_value=(None, None, None)),
|
|
),
|
|
pytest.raises(HTTPException) as exc,
|
|
):
|
|
await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
|
|
assert exc.value.status_code == 400
|
|
assert "Sign in" in exc.value.detail
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_unwraps_setting_envelope():
|
|
"""Bambu Cloud's `get_setting_detail` returns the preset wrapped under
|
|
`.setting`; the sidecar wants the inner content, not the envelope."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
cloud_mock = MagicMock()
|
|
cloud_mock.set_token = MagicMock()
|
|
cloud_mock.get_setting_detail = AsyncMock(
|
|
return_value={
|
|
"setting_id": "PFU123",
|
|
"name": "X1C Custom",
|
|
"setting": {"name": "X1C Custom", "nozzle_diameter": [0.4]},
|
|
}
|
|
)
|
|
cloud_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
preset_resolver,
|
|
"get_stored_token",
|
|
AsyncMock(return_value=("tok", "e@x", "global")),
|
|
),
|
|
patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
|
|
):
|
|
out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
|
|
payload = json.loads(out)
|
|
assert payload == {"name": "X1C Custom", "nozzle_diameter": [0.4]}
|
|
cloud_mock.close.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_falls_back_to_top_level_when_no_envelope():
|
|
"""If a cloud response doesn't nest under `.setting` (rare but seen on
|
|
some endpoints), forward the whole payload rather than failing — the
|
|
sidecar will reject malformed content cleanly."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
cloud_mock = MagicMock()
|
|
cloud_mock.set_token = MagicMock()
|
|
cloud_mock.get_setting_detail = AsyncMock(return_value={"name": "X1C Custom", "nozzle_diameter": [0.4]})
|
|
cloud_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
preset_resolver,
|
|
"get_stored_token",
|
|
AsyncMock(return_value=("tok", None, "global")),
|
|
),
|
|
patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
|
|
):
|
|
out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
|
|
payload = json.loads(out)
|
|
assert "name" in payload
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_auth_error_returns_401():
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
cloud_mock = MagicMock()
|
|
cloud_mock.set_token = MagicMock()
|
|
cloud_mock.get_setting_detail = AsyncMock(side_effect=preset_resolver.BambuCloudAuthError("expired"))
|
|
cloud_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
preset_resolver,
|
|
"get_stored_token",
|
|
AsyncMock(return_value=("tok", None, "global")),
|
|
),
|
|
patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
|
|
pytest.raises(HTTPException) as exc,
|
|
):
|
|
await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
|
|
assert exc.value.status_code == 401
|
|
|
|
|
|
# --- orca_cloud tier -------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orca_cloud_blocks_user_without_orca_cloud_auth():
|
|
"""Defence-in-depth, same shape as the Bambu Cloud permission check:
|
|
a user holding LIBRARY_UPLOAD but not ORCA_CLOUD_AUTH can't slice with
|
|
Orca Cloud presets even if their User row still carries a token."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=False)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orca_cloud_unwraps_content():
|
|
"""Orca's profile shape is ``{id, name, content, updated_time, created_time}``
|
|
— the inner ``content`` is the actual slicer-format JSON. We forward
|
|
that, not the wrapper."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
svc_mock = MagicMock()
|
|
svc_mock.get_profile = AsyncMock(
|
|
return_value={
|
|
"id": "abc",
|
|
"name": "X1C Custom",
|
|
"content": {"name": "X1C Custom", "nozzle_diameter": [0.4]},
|
|
}
|
|
)
|
|
svc_mock.close = AsyncMock()
|
|
with patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)):
|
|
out = await preset_resolver._resolve_orca_cloud(
|
|
db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer"
|
|
)
|
|
payload = json.loads(out)
|
|
assert payload == {"name": "X1C Custom", "nozzle_diameter": [0.4]}
|
|
svc_mock.close.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orca_cloud_auth_error_returns_401():
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
svc_mock = MagicMock()
|
|
svc_mock.get_profile = AsyncMock(side_effect=preset_resolver.OrcaCloudAuthError("expired"))
|
|
svc_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)),
|
|
pytest.raises(HTTPException) as exc,
|
|
):
|
|
await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
|
|
assert exc.value.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orca_cloud_not_found_returns_400():
|
|
"""``get_profile`` raises generic ``OrcaCloudError`` for "not found" —
|
|
the resolver maps that to a 400 (not 502) so the UI can show "profile
|
|
no longer exists" rather than "service down"."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
svc_mock = MagicMock()
|
|
svc_mock.get_profile = AsyncMock(side_effect=preset_resolver.OrcaCloudError("profile 'abc' not found (scanned 0)"))
|
|
svc_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)),
|
|
pytest.raises(HTTPException) as exc,
|
|
):
|
|
await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
# --- top-level dispatcher -------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_preset_ref_dispatches_by_source():
|
|
"""The public entrypoint just routes to the right tier-specific
|
|
helper. Verify each branch is selected correctly."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
preset = MagicMock()
|
|
preset.preset_type = "printer"
|
|
preset.setting = '{"local": true}'
|
|
db.get = AsyncMock(return_value=preset)
|
|
|
|
# local
|
|
out = await preset_resolver.resolve_preset_ref(db, user, PresetRef(source="local", id="1"), slot="printer")
|
|
assert out == '{"local": true}'
|
|
|
|
# standard
|
|
out = await preset_resolver.resolve_preset_ref(
|
|
db, user, PresetRef(source="standard", id="Some Bundled Name"), slot="printer"
|
|
)
|
|
assert json.loads(out)["inherits"] == "Some Bundled Name"
|