diff --git a/.env.enterprise-clean.example b/.env.enterprise-clean.example index f2401bc0..ceabec61 100644 --- a/.env.enterprise-clean.example +++ b/.env.enterprise-clean.example @@ -62,6 +62,16 @@ LLM_CA_CERT_URLS= ENABLE_BELIEF_STATE_LOGGING=true TASK_LOG_LEVEL=INFO +# ====================================================================== +# Шифрование (ОБЯЗАТЕЛЬНО) +# ====================================================================== +# Сгенерируйте Fernet-ключ командой: +# python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())" +# Ключ ДОЛЖЕН быть одинаковым на всех перезапусках сервиса. +# При смене ключа все зашифрованные данные (пароли подключений, API-ключи LLM) +# становятся нечитаемыми. +ENCRYPTION_KEY= + # ====================================================================== # Admin (первый запуск) # ====================================================================== diff --git a/.opencode/agents/security-auditor.md b/.opencode/agents/security-auditor.md index 881f5f82..89456833 100644 --- a/.opencode/agents/security-auditor.md +++ b/.opencode/agents/security-auditor.md @@ -13,7 +13,6 @@ permission: fullstack-coder: deny reflection-agent: deny security-auditor: allow - steps: 80 color: warning --- MANDATORY USE `skill({name="semantics-core"})`, `skill({name="semantics-contracts"})`, `skill({name="molecular-cot-logging"})`, `skill({name="semantics-python"})`, `skill({name="semantics-svelte"})` diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 18cb17b5..4538531c 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -3,34 +3,6 @@ "experimental": { "mcp_timeout": 300000 }, - "provider": { - "cfbt": { - "npm": "@ai-sdk/openai-compatible", - "name": "CFBT CCWU (keyless)", - "options": { - "baseURL": "https://cfbt.ccwu.cc/v1" - }, - "models": { - "@cf/moonshotai/kimi-k2.6": { - "name": "@cf/moonshotai/kimi-k2.6" - } - }, - "api": "openai" - }, - "cfbt_ccwu": { - "npm": "@ai-sdk/openai-compatible", - "name": "CFBT CCWU", - "options": { - "baseURL": "https://cfbt.ccwu.cc/v1" - }, - "models": { - "@cf/moonshotai/kimi-k2.6": { - "name": "@cf/moonshotai/kimi-k2.6" - } - }, - "api": "openai" - } - }, "mcp": { "chrome-devtools": { "type": "local", diff --git a/backend/.env.example b/backend/.env.example index 712b0b46..1d529db6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,7 +1,8 @@ # ── Required: Secrets ── # Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))" AUTH_SECRET_KEY=change-me-to-a-random-secret -ENCRYPTION_KEY=change-me-to-a-fernet-key +# Generate with: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())" +ENCRYPTION_KEY= # ── Required: Database ── # PostgreSQL via docker-compose: diff --git a/backend/src/api/routes/git/_merge_routes.py b/backend/src/api/routes/git/_merge_routes.py index 45fd04da..a1623e84 100644 --- a/backend/src/api/routes/git/_merge_routes.py +++ b/backend/src/api/routes/git/_merge_routes.py @@ -42,7 +42,7 @@ async def get_merge_status( dashboard_id = await _resolve_dashboard_id_from_ref( dashboard_ref, config_manager, env_id ) - return _gs.get_merge_status(dashboard_id) + return await _gs.get_merge_status(dashboard_id) except HTTPException: raise except Exception as e: @@ -71,7 +71,7 @@ async def get_merge_conflicts( dashboard_id = await _resolve_dashboard_id_from_ref( dashboard_ref, config_manager, env_id ) - return _gs.get_merge_conflicts(dashboard_id) + return await _gs.get_merge_conflicts(dashboard_id) except HTTPException: raise except Exception as e: @@ -99,7 +99,7 @@ async def resolve_merge_conflicts( dashboard_id = await _resolve_dashboard_id_from_ref( dashboard_ref, config_manager, env_id ) - resolved_files = _gs.resolve_merge_conflicts( + resolved_files = await _gs.resolve_merge_conflicts( dashboard_id, [item.model_dump() for item in resolve_data.resolutions], ) @@ -129,7 +129,7 @@ async def abort_merge( dashboard_id = await _resolve_dashboard_id_from_ref( dashboard_ref, config_manager, env_id ) - return _gs.abort_merge(dashboard_id) + return await _gs.abort_merge(dashboard_id) except HTTPException: raise except Exception as e: @@ -157,7 +157,7 @@ async def continue_merge( dashboard_id = await _resolve_dashboard_id_from_ref( dashboard_ref, config_manager, env_id ) - return _gs.continue_merge(dashboard_id, continue_data.message) + return await _gs.continue_merge(dashboard_id, continue_data.message) except HTTPException: raise except Exception as e: diff --git a/backend/src/api/routes/git/_repo_lifecycle_routes.py b/backend/src/api/routes/git/_repo_lifecycle_routes.py index 1c8b4d18..a6c54761 100644 --- a/backend/src/api/routes/git/_repo_lifecycle_routes.py +++ b/backend/src/api/routes/git/_repo_lifecycle_routes.py @@ -122,7 +122,7 @@ async def promote_dashboard( reason, ) await _apply_git_identity_from_profile(dashboard_id, db, current_user) - result = _gs.promote_direct_merge( + result = await _gs.promote_direct_merge( dashboard_id=dashboard_id, from_branch=from_branch, to_branch=to_branch, diff --git a/backend/src/api/routes/git/_repo_operations_routes.py b/backend/src/api/routes/git/_repo_operations_routes.py index 2f5e3c94..6eb78e89 100644 --- a/backend/src/api/routes/git/_repo_operations_routes.py +++ b/backend/src/api/routes/git/_repo_operations_routes.py @@ -51,7 +51,7 @@ async def commit_changes( dashboard_ref, config_manager, env_id ) await _apply_git_identity_from_profile(dashboard_id, db, current_user) - _gs.commit_changes( + await _gs.commit_changes( dashboard_id, commit_data.message, commit_data.files ) return {"status": "success"} @@ -80,7 +80,7 @@ async def push_changes( dashboard_id = await _resolve_dashboard_id_from_ref( dashboard_ref, config_manager, env_id ) - _gs.push_changes(dashboard_id) + await _gs.push_changes(dashboard_id) return {"status": "success"} except HTTPException: raise @@ -143,7 +143,7 @@ async def pull_changes( extra={"src": "pull_changes"}, ) await _apply_git_identity_from_profile(dashboard_id, db, current_user) - _gs.pull_changes(dashboard_id) + await _gs.pull_changes(dashboard_id) return {"status": "success"} except HTTPException: raise @@ -235,7 +235,7 @@ async def get_repository_diff( dashboard_id = await _resolve_dashboard_id_from_ref( dashboard_ref, config_manager, env_id ) - return _gs.get_diff(dashboard_id, file_path, staged) + return await _gs.get_diff(dashboard_id, file_path, staged) except HTTPException: raise except Exception as e: @@ -262,7 +262,7 @@ async def get_history( dashboard_id = await _resolve_dashboard_id_from_ref( dashboard_ref, config_manager, env_id ) - return _gs.get_commit_history(dashboard_id, limit) + return await _gs.get_commit_history(dashboard_id, limit) except HTTPException: raise except Exception as e: @@ -290,14 +290,14 @@ async def generate_commit_message( dashboard_id = await _resolve_dashboard_id_from_ref( dashboard_ref, config_manager, env_id ) - diff = _gs.get_diff(dashboard_id, staged=True) + diff = await _gs.get_diff(dashboard_id, staged=True) if not diff: - diff = _gs.get_diff(dashboard_id, staged=False) + diff = await _gs.get_diff(dashboard_id, staged=False) if not diff: return {"message": "No changes detected"} - history_objs = _gs.get_commit_history(dashboard_id, limit=5) + history_objs = await _gs.get_commit_history(dashboard_id, limit=5) history = [h.message for h in history_objs if hasattr(h, "message")] from src.plugins.llm_analysis.models import LLMProviderType diff --git a/backend/src/api/routes/git/_repo_routes.py b/backend/src/api/routes/git/_repo_routes.py index 4f0a3e56..d4980d0b 100644 --- a/backend/src/api/routes/git/_repo_routes.py +++ b/backend/src/api/routes/git/_repo_routes.py @@ -193,7 +193,7 @@ async def get_branches( dashboard_id = await _resolve_dashboard_id_from_ref( dashboard_ref, config_manager, env_id ) - return _gs.list_branches(dashboard_id) + return await _gs.list_branches(dashboard_id) except HTTPException: raise except Exception as e: @@ -223,7 +223,7 @@ async def create_branch( dashboard_ref, config_manager, env_id ) await _apply_git_identity_from_profile(dashboard_id, db, current_user) - _gs.create_branch( + await _gs.create_branch( dashboard_id, branch_data.name, branch_data.from_branch ) return {"status": "success"} @@ -253,7 +253,7 @@ async def checkout_branch( dashboard_id = await _resolve_dashboard_id_from_ref( dashboard_ref, config_manager, env_id ) - _gs.checkout_branch(dashboard_id, checkout_data.name) + await _gs.checkout_branch(dashboard_id, checkout_data.name) return {"status": "success"} except HTTPException: raise diff --git a/backend/src/api/routes/settings.py b/backend/src/api/routes/settings.py index 209cc868..a06e5f1c 100755 --- a/backend/src/api/routes/settings.py +++ b/backend/src/api/routes/settings.py @@ -221,7 +221,10 @@ async def get_environments( logger.reason("Fetching environments", extra={"src": "get_environments"}) environments = config_manager.get_environments() return [ - env.copy(update={"url": _normalize_superset_env_url(env.url)}) + env.copy(update={ + "url": _normalize_superset_env_url(env.url), + "password": "********", + }) for env in environments ] @@ -257,7 +260,8 @@ async def add_environment( ) config_manager.add_environment(env) - return env + masked = env.copy(update={"password": "********"}) + return masked # #endregion add_environment @@ -807,6 +811,9 @@ async def create_connection( return result except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) + except RuntimeError as e: + logger.explore("Connection creation failed (encryption)", error=str(e)) + raise HTTPException(status_code=500, detail=str(e)) # #endregion create_connection @@ -828,6 +835,9 @@ async def update_connection( return result except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) + except RuntimeError as e: + logger.explore("Connection update failed (encryption)", error=str(e)) + raise HTTPException(status_code=500, detail=str(e)) # #endregion update_connection diff --git a/backend/src/core/config_manager.py b/backend/src/core/config_manager.py index 4207c7f3..c74ab0a6 100644 --- a/backend/src/core/config_manager.py +++ b/backend/src/core/config_manager.py @@ -27,7 +27,7 @@ from ..models.config import AppConfigRecord from ..models.mapping import Environment as EnvironmentRecord from .config_models import AppConfig, Environment, GlobalSettings from .database import SessionLocal -from .encryption import EncryptionManager +from .encryption import EncryptionManager, is_fernet_token from .logger import belief_scope, configure_logger, logger @@ -85,10 +85,10 @@ class ConfigManager: try: self._encryption = EncryptionManager() except (RuntimeError, Exception) as exc: - logger.reason( - "EncryptionManager unavailable; passwords stored in plaintext. " - "Ensure ENCRYPTION_KEY is set for production.", - extra={"error": str(exc)}, + logger.explore( + "CRITICAL: EncryptionManager unavailable — environment passwords " + "will not be encrypted at rest. Set ENCRYPTION_KEY.", + error=str(exc), ) self._encryption = None return self._encryption @@ -99,9 +99,12 @@ class ConfigManager: # @PRE payload["environments"] is a list of dicts with optional "password" keys. # @POST Password fields are encrypted in-place (skips masked/empty/already-encrypted). # @SIDE_EFFECT Reads ENCRYPTION_KEY; logs warnings on failure. + # @REJECTED try-decrypt as detection replaced with is_fernet_token() — avoids noisy + # "Assumption violated" warnings from decrypt on plaintext values. def _encrypt_env_passwords(self, payload: dict) -> None: encryption = self._get_encryption_manager() if encryption is None: + logger.warning("Environment passwords will NOT be encrypted — ENCRYPTION_KEY unavailable") return environments = payload.get("environments", []) if not isinstance(environments, list): @@ -110,12 +113,9 @@ class ConfigManager: pwd = env.get("password", "") if not pwd or pwd == "********": continue - # Skip if already encrypted (safe-decrypt check) - try: - encryption.decrypt(pwd) + # Skip if already encrypted (check format, don't try-decrypt) + if is_fernet_token(pwd): continue - except Exception: - pass try: env["password"] = encryption.encrypt(pwd) except Exception as exc: @@ -129,6 +129,13 @@ class ConfigManager: # @PURPOSE: Decrypt all environment passwords in the AppConfig after loading from DB. # @PRE config.environments is populated from DB payload. # @POST Password fields are decrypted in-place (skips plaintext legacy values). + # @SIDE_EFFECT Logs warning for plaintext passwords (will be auto-encrypted on save). + # @SIDE_EFFECT Logs error if Fernet token exists but decryption fails (key mismatch). + # @RATIONALE Differs from ConnectionService._decrypt_password (which raises RuntimeError + # on key mismatch) because this operates on ALL environments at startup. + # A crash would prevent the entire app from loading. Instead, the encrypted + # value is preserved; subsequent Superset API calls will fail with auth error, + # giving the admin a clear signal without taking down the whole service. def _decrypt_env_passwords(self, config: AppConfig) -> None: encryption = self._get_encryption_manager() if encryption is None: @@ -136,10 +143,25 @@ class ConfigManager: for env in config.environments: if not env.password: continue + # Plaintext — warn, keep as-is + if not is_fernet_token(env.password): + logger.reason( + "Plaintext environment password detected, will be auto-encrypted on save", + extra={"env_id": env.id}, + ) + continue + # Looks like Fernet — try to decrypt try: env.password = encryption.decrypt(env.password) - except Exception: - pass # Assume plaintext (legacy data or test fixture) + except Exception as exc: + logger.explore( + "Environment password decryption failed — possible key mismatch. " + "The stored Fernet token cannot be decrypted with the current key.", + error=str(exc), + extra={"env_id": env.id}, + ) + # Keep encrypted value — do NOT return plaintext garbage. + # Superset API will fail with auth error, signalling the issue. # #endregion _decrypt_env_passwords # #region _apply_features_from_env [TYPE Function] diff --git a/backend/src/core/connection_service.py b/backend/src/core/connection_service.py index 58210f42..c5380c6c 100644 --- a/backend/src/core/connection_service.py +++ b/backend/src/core/connection_service.py @@ -41,7 +41,7 @@ from typing import Any from .config_manager import ConfigManager from .config_models import DatabaseConnection -from .encryption import EncryptionManager +from .encryption import EncryptionManager, is_fernet_token from .logger import logger MASKED_PASSWORD = "********" @@ -92,35 +92,86 @@ class ConnectionService: if self._encryption is None: try: self._encryption = EncryptionManager() - except (RuntimeError, Exception): + except (RuntimeError, Exception) as exc: + logger.error( + "CRITICAL: EncryptionManager unavailable — passwords cannot be " + "encrypted/decrypted. Set ENCRYPTION_KEY in environment.", + extra={"error": str(exc)}, + ) self._encryption = None return self._encryption # #endregion _get_encryption # #region _encrypt_password [C:2] [TYPE Function] + # @PRE EncryptionManager is available (ENCRYPTION_KEY set). + # @POST Returns Fernet-encrypted password string. + # @REJECTED Silent plaintext fallback rejected — would mask admin configuration errors. def _encrypt_password(self, password: str) -> str: encryption = self._get_encryption() if encryption is None: - return password + logger.error( + "REJECTED: password encryption skipped — ENCRYPTION_KEY unavailable. " + "Password WILL NOT be stored. Set ENCRYPTION_KEY and retry." + ) + raise RuntimeError( + "Cannot encrypt password: ENCRYPTION_KEY not available. " + "Set ENCRYPTION_KEY in environment and restart." + ) try: return encryption.encrypt(password) - except Exception: - return password + except Exception as exc: + logger.error( + "REJECTED: password encryption failed.", + extra={"error": str(exc)}, + ) + raise RuntimeError( + f"Password encryption failed: {exc}. Check ENCRYPTION_KEY validity." + ) from exc # #endregion _encrypt_password # #region _decrypt_password [C:2] [TYPE Function] + # @PRE EncryptionManager is available (ENCRYPTION_KEY set). + # @POST Returns plaintext password from Fernet token, or plaintext for legacy data. + # @SIDE_EFFECT Logs warning for plaintext passwords — auto-encrypt on next save. + # @SIDE_EFFECT Raises RuntimeError if Fernet token exists but decryption fails (key mismatch). + # @RATIONALE Distinguishes between "plaintext legacy data" and "key mismatch": + # - is_fernet_token() → probably key changed → crash with clear message + # - not is_fernet_token() → legacy plaintext → use as-is, log warning def _decrypt_password(self, password: str) -> str: encryption = self._get_encryption() if encryption is None: - return password + raise RuntimeError( + "Cannot decrypt password: ENCRYPTION_KEY not available. " + "Set ENCRYPTION_KEY in environment and restart." + ) if password == MASKED_PASSWORD: return password + + # If it doesn't look like Fernet, it's legacy plaintext — warn and return + if not is_fernet_token(password): + logger.warning( + "Plaintext password detected in DatabaseConnection. " + "Will be auto-encrypted on next save.", + ) + return password + + # It looks like Fernet — try to decrypt try: return encryption.decrypt(password) - except Exception: - return password + except Exception as exc: + logger.error( + "FATAL: Password decryption failed — ENCRYPTION_KEY mismatch " + "or data corruption. The stored Fernet token cannot be decrypted " + "with the current key.", + extra={"error": str(exc)}, + ) + raise RuntimeError( + "Password decryption failed: the stored value was encrypted with " + "a different ENCRYPTION_KEY. Restore the original key or run " + "the key rotation tool." + ) from exc # #endregion _decrypt_password diff --git a/backend/src/core/encryption.py b/backend/src/core/encryption.py index 53476d47..f8fd8b1d 100644 --- a/backend/src/core/encryption.py +++ b/backend/src/core/encryption.py @@ -12,6 +12,7 @@ # @RATIONALE Extracted from services/llm_provider.py to decouple core encryption from LLM domain # and enable reuse by ConfigManager for Superset password encryption (see [SEC:C-1]). +import base64 import os from cryptography.fernet import Fernet @@ -119,6 +120,30 @@ class EncryptionManager: _encryption_manager: EncryptionManager | None = None +# #region is_fernet_token [C:2] [TYPE Function] [SEMANTICS encryption,validation] +# @BRIEF Check whether a string looks like a valid Fernet-encrypted token. +# @PRE value is a string. +# @POST Returns True if value is base64-decodable with decoded length >= 32 bytes. +# @RATIONALE Differentiates Fernet-encrypted values from plaintext without decrypting. +# Used by ConnectionService and ConfigManager to handle plaintext legacy data +# vs. data corrupted by key mismatch. +# @REJECTED Trying decryption as detection was rejected — it logs noisy "Assumption +# violated" warnings for every plaintext value and cannot distinguish between +# "not encrypted" and "encrypted with a different key". +MIN_FERNET_TOKEN_LENGTH = 80 + + +def is_fernet_token(value: str) -> bool: + if not value or len(value) < MIN_FERNET_TOKEN_LENGTH: + return False + try: + decoded = base64.urlsafe_b64decode(value.encode()) + except Exception: + return False + return len(decoded) >= 32 +# #endregion is_fernet_token + + # #region get_encryption_manager [C:4] [TYPE Function] [SEMANTICS encryption,singleton,cache] # @BRIEF Return the process-wide EncryptionManager singleton. # @PRE ENCRYPTION_KEY env var is set to a valid Fernet key. diff --git a/backend/src/plugins/git_plugin.py b/backend/src/plugins/git_plugin.py index 3910d8f1..e86a2d97 100644 --- a/backend/src/plugins/git_plugin.py +++ b/backend/src/plugins/git_plugin.py @@ -249,7 +249,7 @@ class GitPlugin(PluginBase): async def _handle_sync(self, dashboard_id: int, source_env_id: str | None = None, log=None, git_log=None, superset_log=None) -> dict[str, str]: with belief_scope("GitPlugin._handle_sync"): try: - repo = await run_blocking(kind='git', fn=self.git_service.get_repo, dashboard_id=dashboard_id) + repo = await self.git_service.get_repo(dashboard_id) repo_path = Path(repo.working_dir) git_log.info(f"Target repo path: {repo_path}") @@ -314,7 +314,7 @@ class GitPlugin(PluginBase): try: if not env_id: raise ValueError("Target environment ID required for deployment") - repo = await run_blocking(kind='git', fn=self.git_service.get_repo, dashboard_id=dashboard_id) + repo = await self.git_service.get_repo(dashboard_id) repo_path = Path(repo.working_dir) git_log.info(f"Packing repository {repo_path} for deployment.") diff --git a/backend/src/plugins/llm_analysis/plugin.py b/backend/src/plugins/llm_analysis/plugin.py index 82362596..e1f4ed68 100644 --- a/backend/src/plugins/llm_analysis/plugin.py +++ b/backend/src/plugins/llm_analysis/plugin.py @@ -260,8 +260,7 @@ class DashboardValidationPlugin(PluginBase): api_key = llm_service.get_decrypted_api_key(provider_id) llm_log.debug( - f"API Key decrypted (first 8 chars): " - f"{api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}..." + f"API Key decrypted: length={len(api_key) if api_key else 0}" ) # Check if API key was successfully decrypted @@ -928,7 +927,7 @@ class DocumentationPlugin(PluginBase): llm_log.debug(f" Default Model: {db_provider.default_model}") api_key = llm_service.get_decrypted_api_key(provider_id) - llm_log.debug(f"API Key decrypted (first 8 chars): {api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}...") + llm_log.debug(f"API Key decrypted: length={len(api_key) if api_key else 0}") # Check if API key was successfully decrypted if _is_masked_or_invalid_api_key(api_key): diff --git a/backend/src/plugins/llm_analysis/service.py b/backend/src/plugins/llm_analysis/service.py index 58690e94..1b7f6539 100644 --- a/backend/src/plugins/llm_analysis/service.py +++ b/backend/src/plugins/llm_analysis/service.py @@ -875,7 +875,7 @@ class LLMClient: "provider_type": str(provider_type), "base_url": base_url, "default_model": default_model, - "api_key_prefix": self.api_key[:8] if self.api_key and len(self.api_key) > 8 else "EMPTY_OR_NONE", + "api_key_present": bool(self.api_key), "api_key_length": len(self.api_key) if self.api_key else 0, }, ) diff --git a/backend/tests/core/test_core_config_manager.py b/backend/tests/core/test_core_config_manager.py index 8927ede6..82ec3633 100644 --- a/backend/tests/core/test_core_config_manager.py +++ b/backend/tests/core/test_core_config_manager.py @@ -121,13 +121,14 @@ class TestEncryptEnvPasswords: # #endregion test_encrypt_skips_masked_and_empty # #region test_encrypt_skips_already_encrypted [C:2] [TYPE Function] - # @BRIEF Password that decrypts successfully is already encrypted → skip. + # @BRIEF Password that looks like a Fernet token (via is_fernet_token) is skipped. def test_encrypt_skips_already_encrypted(self): mgr = _build_manager(MagicMock()) em = MagicMock() - em.decrypt.return_value = "plaintext" mgr._encryption = em - payload = {"environments": [{"password": "already-encrypted-token"}]} + # 96-char base64 string that passes is_fernet_token() (decodes to 72 zero bytes) + fernet_like = "A" * 96 + payload = {"environments": [{"password": fernet_like}]} mgr._encrypt_env_passwords(payload) em.encrypt.assert_not_called() # #endregion test_encrypt_skips_already_encrypted @@ -184,13 +185,15 @@ class TestDecryptEnvPasswords: # #endregion test_decrypt_empty_password # #region test_decrypt_success [C:2] [TYPE Function] - # @BRIEF Encrypted password is decrypted in-place. + # @BRIEF Fernet-format password is decrypted in-place. def test_decrypt_success(self): mgr = _build_manager(MagicMock()) em = MagicMock() em.decrypt.return_value = "decrypted" mgr._encryption = em - config = _make_config([_make_env(password="encrypted-token")]) + # 96-char base64 string that passes is_fernet_token() (decodes to 72 zero bytes) + fernet_like = "A" * 96 + config = _make_config([_make_env(password=fernet_like)]) mgr._decrypt_env_passwords(config) assert config.environments[0].password == "decrypted" # #endregion test_decrypt_success diff --git a/backend/tests/test_core/test_connection_service_edge.py b/backend/tests/test_core/test_connection_service_edge.py index 3c91a9d2..3e4568f2 100644 --- a/backend/tests/test_core/test_connection_service_edge.py +++ b/backend/tests/test_core/test_connection_service_edge.py @@ -30,28 +30,28 @@ def svc(mock_cm): class TestEncryptionEdgeCases: - def test_encrypt_encryption_none_fallback(self, svc): - """When _get_encryption returns None, password passes through.""" + def test_encrypt_encryption_none_raises(self, svc): + """_get_encryption returns None → RuntimeError (no silent plaintext).""" with patch.object(svc, '_get_encryption', return_value=None): - result = svc._encrypt_password("my_pass") - assert result == "my_pass" + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY not available"): + svc._encrypt_password("my_pass") - def test_encrypt_exception_fallback(self, svc): - """Encryption raises exception → password passes through.""" + def test_encrypt_exception_raises(self, svc): + """Encryption raises exception → RuntimeError (no silent plaintext).""" enc = MagicMock() enc.encrypt.side_effect = RuntimeError("crypto fail") with patch.object(svc, '_get_encryption', return_value=enc): - result = svc._encrypt_password("my_pass") - assert result == "my_pass" + with pytest.raises(RuntimeError, match="encryption failed"): + svc._encrypt_password("my_pass") - def test_decrypt_none_fallback(self, svc): - """_get_encryption returns None → password passes through.""" + def test_decrypt_none_raises(self, svc): + """_get_encryption returns None → RuntimeError (no silent plaintext).""" with patch.object(svc, '_get_encryption', return_value=None): - result = svc._decrypt_password("encrypted") - assert result == "encrypted" + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY not available"): + svc._decrypt_password("encrypted") def test_decrypt_exception_fallback(self, svc): - """Decryption raises → password passes through.""" + """Non-Fernet value with encryption available → returned as-is (legacy plaintext).""" enc = MagicMock() enc.decrypt.side_effect = RuntimeError("decrypt fail") with patch.object(svc, '_get_encryption', return_value=enc): @@ -66,11 +66,11 @@ class TestEncryptionEdgeCases: assert result == "********" enc.decrypt.assert_not_called() - def test_decrypt_masked_get_encryption_none(self, svc): - """MASKED_PASSWORD path even when encryption is None.""" + def test_decrypt_masked_get_encryption_none_raises(self, svc): + """MASKED_PASSWORD with encryption None → RuntimeError (encryption required).""" with patch.object(svc, '_get_encryption', return_value=None): - result = svc._decrypt_password("********") - assert result == "********" + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY not available"): + svc._decrypt_password("********") class TestUnsupportedDialect: diff --git a/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte b/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte index f9433d96..20eb5e7f 100644 --- a/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte +++ b/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte @@ -18,6 +18,8 @@ import DashboardDataGrid from "./DashboardDataGrid.svelte"; import GitManager from "$lib/components/git/GitManager.svelte"; import { gitService } from "../../../services/gitService"; + import { resolveGitStatusToken } from "../../../services/git-utils"; + import { formatDateTime } from "$lib/utils/dateFormat"; import { addToast } from "$lib/toasts.svelte.js"; // [/SECTION] @@ -48,7 +50,7 @@ // ── Column definitions ───────────────────────────────────────── let columns: Column[] = $derived([ { key: "title", label: $t.dashboard?.title || "Title", sortable: true }, - { key: "last_modified", label: $t.dashboard?.last_modified || "Last Modified", sortable: true }, + { key: "last_modified", label: $t.dashboard?.last_modified || "Last Modified", sortable: true, render: (item: DashboardMetadata) => formatDateTime(item.last_modified) }, { key: "status", label: $t.dashboard?.status || "Status", sortable: true, raw: true, render: (item: DashboardMetadata) => renderStatusBadge(item) }, ]); @@ -94,29 +96,9 @@ // #endregion invalidateRepositoryStatuses:Function // #region resolveRepositoryStatusToken:Function [TYPE Function] + // @BRIEF Delegates to shared resolveGitStatusToken (git-utils) — single source of truth for grid + modal. function resolveRepositoryStatusToken(status: any): string { - const syncState = String(status?.sync_state || "").toUpperCase(); - if (syncState === "DIVERGED") return "diverged"; - if (syncState === "BEHIND_REMOTE") return "behind_remote"; - if (syncState === "AHEAD_REMOTE") return "ahead_remote"; - if (syncState === "CHANGES") return "changes"; - if (syncState === "SYNCED") return "synced"; - const syncStatus = String(status?.sync_status || "").toUpperCase(); - if (syncStatus === "NO_REPO") return "no_repo"; - if (syncStatus === "ERROR") return "error"; - if (syncStatus === "DIFF") return "changes"; - if (syncStatus === "OK") return "synced"; - const aheadCount = Number(status?.ahead_count || 0); - const behindCount = Number(status?.behind_count || 0); - if (aheadCount > 0 && behindCount > 0) return "diverged"; - if (behindCount > 0) return "behind_remote"; - if (aheadCount > 0) return "ahead_remote"; - const hasChanges = - Boolean(status?.is_dirty) || - (status?.untracked_files?.length || 0) > 0 || - (status?.modified_files?.length || 0) > 0 || - (status?.staged_files?.length || 0) > 0; - return hasChanges ? "changes" : "synced"; + return resolveGitStatusToken(status); } // #endregion resolveRepositoryStatusToken:Function diff --git a/frontend/src/lib/components/git/BranchSelector.svelte b/frontend/src/lib/components/git/BranchSelector.svelte index cfc76482..f2107609 100644 --- a/frontend/src/lib/components/git/BranchSelector.svelte +++ b/frontend/src/lib/components/git/BranchSelector.svelte @@ -18,7 +18,7 @@ import { onMount } from 'svelte'; import { BranchModel } from '$lib/models/BranchModel.svelte.ts'; import { t } from '$lib/i18n/index.svelte.js'; - import { Button, Select, Input } from '$lib/ui'; + import { Button, Input } from '$lib/ui'; let { dashboardId, @@ -29,6 +29,10 @@ const model = new BranchModel({ dashboardId, envId, currentBranch, onChange: onchange }); + // Split branches into local (no origin/ prefix) and remote (origin/ prefix) + let localBranches = $derived(model.branches.filter(b => !b.name.startsWith('origin/'))); + let remoteBranches = $derived(model.branches.filter(b => b.name.startsWith('origin/'))); + // Sync prop changes to model $effect(() => { model.dashboardId = dashboardId; @@ -50,12 +54,27 @@
-
- Remote Changes (Theirs) + {$t.git?.conflict?.remote_changes || 'Remote Changes (Theirs)'}
                                         resolve(conflict.file_path, "theirs")}
                                 >
-                                    Keep Theirs
+{$t.git?.conflict?.keep_theirs || 'Keep Theirs'}
                                 
                             
@@ -164,13 +164,13 @@ onclick={() => (show = false)} class="px-4 py-2 text-text-muted hover:bg-surface-muted rounded transition-colors" > - Cancel + {$t.git?.conflict?.cancel || 'Cancel'}
diff --git a/frontend/src/lib/components/git/GitHelpPanel.svelte b/frontend/src/lib/components/git/GitHelpPanel.svelte new file mode 100644 index 00000000..ea6b40a1 --- /dev/null +++ b/frontend/src/lib/components/git/GitHelpPanel.svelte @@ -0,0 +1,66 @@ + + + + + + + + + + +
+ + + {#if open} +
+
    + {#each steps as step, i} +
  1. + + {i + 1} + + {step.icon} +
    +

    {step.title}

    +

    {step.desc}

    +
    +
  2. + {/each} +
+
+ {/if} +
+ \ No newline at end of file diff --git a/frontend/src/lib/components/git/GitInitPanel.svelte b/frontend/src/lib/components/git/GitInitPanel.svelte index e55a8db7..0a796b8f 100644 --- a/frontend/src/lib/components/git/GitInitPanel.svelte +++ b/frontend/src/lib/components/git/GitInitPanel.svelte @@ -42,24 +42,30 @@ bind:value={remoteUrl} placeholder={$t.git?.remote_url_placeholder} /> - +
+ +

{$t.git?.create_repo_desc}

+
- +
+ +

{$t.git?.init_repo_desc}

+
diff --git a/frontend/src/lib/components/git/GitManager.svelte b/frontend/src/lib/components/git/GitManager.svelte index 10776856..841d798f 100644 --- a/frontend/src/lib/components/git/GitManager.svelte +++ b/frontend/src/lib/components/git/GitManager.svelte @@ -39,6 +39,8 @@ let { dashboardId, envId = null, dashboardTitle = '', show = $bindable(false) } = $props(); + let deployConfirmInput = $state(''); + const model = new GitManagerModel({ dashboardId, envId, dashboardTitle }); $effect(() => { @@ -50,6 +52,10 @@ }); function closeModal() { show = false; model.clearGitError(); } + + $effect(() => { + if (model.showDeployConfirm) deployConfirmInput = ''; + }); function handleBackdropClick(e) { if (e.target === e.currentTarget) closeModal(); } onMount(() => { @@ -71,9 +77,21 @@

{dashboardTitle} · slug: {dashboardId}

- +
+ + +
@@ -94,7 +112,7 @@

{model.gitError.message || model.gitError}

{#if model.gitError.files?.length}
- Файлы, которые будут перезаписаны ({model.gitError.files.length}) + {($t.git?.error_files_overwritten || 'Файлы, которые будут перезаписаны ({count})').replace('{count}', String(model.gitError.files.length))}