Files
ss-tools/backend/src/services/profile_utils.py,cover
busya f75c15dbc6 test: massive coverage expansion — 15 new test modules + assistant tool fixes + orthogonal testing
- 10 translate plugin test files (100% coverage on 12 modules)
- assistant/handler tools: 85+ tests covering dispatch, registry, resolvers, routes, llm_planner, 13 tool handlers
- clean release: artifact_catalog_loader, mappers, approval, publication tests
- API routes: translate_helpers, validation_service extensions, datasets to 100%
- notifications: providers/service tests
- services: profile_preference_service
- docs/orthogonal-test-report.md — full speckit.tests audit
- Fixes: 3 git_base async mock failures, 4 assistant handler permission-check patches
- .gitignore: coverage artifacts
2026-06-15 15:38:59 +03:00

253 lines
9.5 KiB
Plaintext

# #region ProfileUtils [C:2] [TYPE Module] [SEMANTICS string,normalization,utility,sanitize]
# @defgroup Services Module group.
# @BRIEF Pure utility helpers for profile data sanitization, normalization, and secret masking.
# Also contains shared exception classes to avoid circular imports between profile sub-modules.
# @LAYER Domain
# @RATIONALE Extracted from ProfileService to satisfy INV_7 — module length under 400 lines,
# contract length under 150 lines. These are stateless pure functions that do not
# require a class or service container.
# @REJECTED Keeping these as private methods on ProfileService was rejected because they
# inflate the class beyond 150 lines and mix pure computation with I/O-bound
# service logic.
> from collections.abc import Iterable, Sequence
> from datetime import UTC, datetime
> from typing import Any
# #region ProfileValidationError [C:2] [TYPE Class]
# @defgroup Services Module group.
# @RELATION INHERITS -> [EXT:Python:Exception]
# @BRIEF Domain validation error for profile preference update requests.
> class ProfileValidationError(Exception):
> def __init__(self, errors: Sequence[str]):
> self.errors = list(errors)
> super().__init__("Profile preference validation failed")
# #endregion ProfileValidationError
# #region EnvironmentNotFoundError [C:2] [TYPE Class]
# @defgroup Services Module group.
# @RELATION INHERITS -> [EXT:Python:Exception]
# @BRIEF Raised when environment_id from lookup request is unknown in app configuration.
> class EnvironmentNotFoundError(Exception):
> pass
# #endregion EnvironmentNotFoundError
# #region ProfileAuthorizationError [C:2] [TYPE Class]
# @defgroup Services Module group.
# @RELATION INHERITS -> [EXT:Python:Exception]
# @BRIEF Raised when caller attempts cross-user preference mutation.
> class ProfileAuthorizationError(Exception):
> pass
# #endregion ProfileAuthorizationError
> SUPPORTED_START_PAGES: set[str] = {"dashboards", "datasets", "reports"}
> SUPPORTED_DENSITIES: set[str] = {"compact", "comfortable"}
# #region sanitize_text [C:1] [TYPE Function]
# @BRIEF Normalize optional text into trimmed form or None.
> def sanitize_text(value: str | None) -> str | None:
> normalized = str(value or "").strip()
> if not normalized:
> return None
> return normalized
# #endregion sanitize_text
# #region sanitize_secret [C:1] [TYPE Function]
# @BRIEF Normalize secret input into trimmed form or None.
> def sanitize_secret(value: str | None) -> str | None:
> if value is None:
> return None
> normalized = str(value).strip()
> if not normalized:
> return None
> return normalized
# #endregion sanitize_secret
# #region sanitize_username [C:1] [TYPE Function]
# @BRIEF Normalize raw username into trimmed form or None for empty input.
> def sanitize_username(value: str | None) -> str | None:
> return sanitize_text(value)
# #endregion sanitize_username
# #region normalize_username [C:1] [TYPE Function]
# @BRIEF Apply deterministic trim+lower normalization for actor matching.
> def normalize_username(value: str | None) -> str | None:
> sanitized = sanitize_username(value)
> if sanitized is None:
> return None
> return sanitized.lower()
# #endregion normalize_username
# #region normalize_start_page [C:1] [TYPE Function]
# @BRIEF Normalize supported start page aliases to canonical values.
> def normalize_start_page(value: str | None) -> str:
> normalized = str(value or "").strip().lower()
> if normalized == "reports-logs":
> return "reports"
> if normalized in SUPPORTED_START_PAGES:
> return normalized
> return "dashboards"
# #endregion normalize_start_page
# #region normalize_density [C:1] [TYPE Function]
# @BRIEF Normalize supported density aliases to canonical values.
> def normalize_density(value: str | None) -> str:
> normalized = str(value or "").strip().lower()
> if normalized == "free":
> return "comfortable"
> if normalized in SUPPORTED_DENSITIES:
> return normalized
> return "comfortable"
# #endregion normalize_density
# #region mask_secret_value [C:1] [TYPE Function]
# @BRIEF Build a safe display value for sensitive secrets.
> def mask_secret_value(secret: str | None) -> str | None:
> sanitized = sanitize_secret(secret)
> if sanitized is None:
> return None
> if len(sanitized) <= 4:
> return "***"
> return f"{sanitized[:2]}***{sanitized[-2:]}"
# #endregion mask_secret_value
# #region validate_update_payload [C:2] [TYPE Function]
# @ingroup Services
# @BRIEF Validate username/toggle constraints for preference mutation.
# @RELATION CALLS -> [sanitize_username]
# @RELATION CALLS -> [sanitize_text]
# @RELATION DEPENDS_ON -> [ProfileUtils]
# @RELATION DEPENDS_ON -> [ProfileUtils]
# @POST Returns validation errors list; empty list means valid.
> def validate_update_payload(
> superset_username: str | None,
> show_only_my_dashboards: bool,
> git_email: str | None,
> start_page: str,
> dashboards_table_density: str,
> email_address: str | None = None,
> ) -> list[str]:
> errors: list[str] = []
> sanitized_username = sanitize_username(superset_username)
> if sanitized_username and any(ch.isspace() for ch in sanitized_username):
> errors.append(
> "Username should not contain spaces. Please enter a valid Apache Superset username."
> )
> if show_only_my_dashboards and not sanitized_username:
> errors.append(
> "Superset username is required when default filter is enabled."
> )
> sanitized_git_email = sanitize_text(git_email)
> if sanitized_git_email:
> if (
> " " in sanitized_git_email
> or "@" not in sanitized_git_email
> or sanitized_git_email.startswith("@")
> or sanitized_git_email.endswith("@")
> ):
> errors.append("Git email should be a valid email address.")
> if start_page not in SUPPORTED_START_PAGES:
> errors.append("Start page value is not supported.")
> if dashboards_table_density not in SUPPORTED_DENSITIES:
> errors.append("Dashboards table density value is not supported.")
> sanitized_email = sanitize_text(email_address)
> if sanitized_email:
> if (
> " " in sanitized_email
> or "@" not in sanitized_email
> or sanitized_email.startswith("@")
> or sanitized_email.endswith("@")
> ):
> errors.append("Notification email should be a valid email address.")
> return errors
# #endregion validate_update_payload
# #region build_default_preference [C:2] [TYPE Function] [SEMANTICS preference,default,unconfigured]
# @ingroup Services
# @BRIEF Build non-persisted default preference DTO for unconfigured users.
# @RELATION DEPENDS_ON -> [ProfilePreference]
> def build_default_preference(user_id: str) -> Any:
> """Build default ProfilePreference with disabled toggles and empty usernames."""
> from ..schemas.profile import ProfilePreference
> now = datetime.now(UTC)
> return ProfilePreference(
> user_id=str(user_id),
> superset_username=None,
> superset_username_normalized=None,
> show_only_my_dashboards=False,
> show_only_slug_dashboards=True,
> git_username=None,
> git_email=None,
> has_git_personal_access_token=False,
> git_personal_access_token_masked=None,
> start_page="dashboards",
> auto_open_task_drawer=True,
> dashboards_table_density="comfortable",
> telegram_id=None,
> email_address=None,
> notify_on_fail=True,
> created_at=now,
> updated_at=now,
> )
# #endregion build_default_preference
# #region normalize_owner_tokens [C:2] [TYPE Function]
# @ingroup Services
# @BRIEF Normalize owners payload into deduplicated lower-cased tokens.
# @RELATION CALLS -> [normalize_username]
> def normalize_owner_tokens(owners: Iterable[Any] | None) -> list[str]:
> if owners is None:
> return []
> normalized: list[str] = []
> for owner in owners:
> owner_candidates: list[Any]
> if isinstance(owner, dict):
> first_name = sanitize_username(str(owner.get("first_name") or ""))
> last_name = sanitize_username(str(owner.get("last_name") or ""))
> full_name = " ".join(
> part for part in [first_name, last_name] if part
> ).strip()
> snake_name = "_".join(
> part for part in [first_name, last_name] if part
> ).strip("_")
> owner_candidates = [
> owner.get("username"),
> owner.get("user_name"),
> owner.get("name"),
> owner.get("full_name"),
> first_name,
> last_name,
> full_name or None,
> snake_name or None,
> owner.get("email"),
> ]
> else:
> owner_candidates = [owner]
> for candidate in owner_candidates:
> token = normalize_username(str(candidate or ""))
> if token and token not in normalized:
> normalized.append(token)
> return normalized
# #endregion normalize_owner_tokens
# #endregion ProfileUtils