# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import sys from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import ValidationError _BACKEND = Path(__file__).resolve().parents[1] if str(_BACKEND) not in sys.path: sys.path.insert(0, str(_BACKEND)) import utils.personalization_settings as pers # noqa: E402 from auth.authentication import get_current_subject # noqa: E402 from routes import settings as settings_routes # noqa: E402 from routes.settings import ( # noqa: E402 MAX_SIDEBAR_MENU_INPUT_ITEMS, PersonalizationPayload, SIDEBAR_MENU_ITEM_DEFAULTS, ) def test_defaults_fill_missing_fields(): p = PersonalizationPayload.model_validate({}) assert p.version == pers.PERSONALIZATION_VERSION assert p.appearance.theme == "system" assert p.appearance.palette == "standard" assert p.profile.avatarShape == "circle" assert p.profile.displayName == "" assert p.profile.showGreetingSloth is True def test_unknown_keys_are_ignored(): p = PersonalizationPayload.model_validate( {"profile": {"displayName": "Mike", "bogus": 1}, "extra": True} ) assert p.profile.displayName == "Mike" def test_invalid_theme_rejected(): with pytest.raises(ValidationError): PersonalizationPayload.model_validate({"appearance": {"theme": "neon"}}) def test_invalid_palette_rejected(): with pytest.raises(ValidationError): PersonalizationPayload.model_validate({"appearance": {"palette": "neon"}}) def test_customization_defaults(): p = PersonalizationPayload.model_validate({}) c = p.appearance.customization assert c.contrast == 50 assert c.reduceMotion == "system" assert c.fontSmoothing is True assert c.pointerCursors is False assert c.colors.light.accent is None assert c.headingFont is None assert c.chatFont is None assert c.uiFontSize is None assert [(i.id, i.visible) for i in c.sidebarMenu] == [ ("api", True), ("darkMode", True), ("guidedTour", True), ("profile", False), ("appearance", False), ("resources", False), ("chat", False), ("connections", False), ] def test_customization_invalid_values_rejected(): with pytest.raises(ValidationError): PersonalizationPayload.model_validate( {"appearance": {"customization": {"colors": {"light": {"accent": "red"}}}}} ) with pytest.raises(ValidationError): PersonalizationPayload.model_validate({"appearance": {"customization": {"uiFontSize": 99}}}) with pytest.raises(ValidationError): PersonalizationPayload.model_validate({"appearance": {"customization": {"contrast": 500}}}) with pytest.raises(ValidationError): PersonalizationPayload.model_validate( {"appearance": {"customization": {"reduceMotion": "sometimes"}}} ) with pytest.raises(ValidationError): PersonalizationPayload.model_validate( {"appearance": {"customization": {"sidebarMenu": [{"id": "chats"}]}}} ) def test_customization_sidebar_menu_normalized(): p = PersonalizationPayload.model_validate( { "appearance": { "customization": { "sidebarMenu": [ {"id": "guidedTour", "visible": False}, {"id": "guidedTour", "visible": True}, {"id": "api"}, ] } } } ) # Duplicates keep the first entry; missing ids are appended with their # default visibility. assert [(i.id, i.visible) for i in p.appearance.customization.sidebarMenu] == [ ("guidedTour", False), ("api", True), ("darkMode", True), ("profile", False), ("appearance", False), ("resources", False), ("chat", False), ("connections", False), ] def _sidebar(items): return {"appearance": {"customization": {"sidebarMenu": items}}} def test_customization_sidebar_menu_dedupes_oversized_payload(): # A stale/duplicated payload carries more items than there are distinct ids. # It must reach the dedupe validator and normalize to exactly one entry per # id, not be rejected by the length cap before dedupe runs. ids = list(SIDEBAR_MENU_ITEM_DEFAULTS) doubled = [{"id": i} for i in ids] + [{"id": i} for i in ids] assert len(doubled) > len(SIDEBAR_MENU_ITEM_DEFAULTS) p = PersonalizationPayload.model_validate(_sidebar(doubled)) result = [i.id for i in p.appearance.customization.sidebarMenu] assert result == ids assert len(result) == len(SIDEBAR_MENU_ITEM_DEFAULTS) def test_customization_sidebar_menu_rejects_pathological_length(): # The generous input cap still refuses an absurdly long list outright. huge = [{"id": "api"} for _ in range(MAX_SIDEBAR_MENU_INPUT_ITEMS + 1)] with pytest.raises(ValidationError): PersonalizationPayload.model_validate(_sidebar(huge)) def test_customization_imported_fonts_validated(): ok = PersonalizationPayload.model_validate( { "appearance": { "customization": { "importedFonts": [{"name": "My Font", "dataUrl": "data:font/woff2;base64,AAAA"}] } } } ) assert ok.appearance.customization.importedFonts[0].name == "My Font" with pytest.raises(ValidationError): PersonalizationPayload.model_validate( { "appearance": { "customization": { "importedFonts": [ {"name": "Evil", "dataUrl": "https://example.com/font.woff2"} ] } } } ) with pytest.raises(ValidationError): PersonalizationPayload.model_validate( { "appearance": { "customization": { "importedFonts": [ {"name": f"Font {i}", "dataUrl": "data:font/ttf;base64,AAAA"} for i in range(4) ] } } } ) def _imported(fonts): return {"appearance": {"customization": {"importedFonts": fonts}}} def test_imported_font_name_rejects_css_characters(): # Includes backslash (escapes the quoted family), comma/slash (extra # fallbacks / comment start), and a control character. for bad in ['Ev"il', "Ev;il", "Ev{il", "Ev