diff --git a/.env.example b/.env.example index cf8d2fd5..296977d5 100644 --- a/.env.example +++ b/.env.example @@ -151,9 +151,6 @@ RATE_LIMIT_STANDARD=100/minute # Rate limit for AI/expensive operations like chat (default: 20/minute) RATE_LIMIT_AI=20/minute -# Rate limit for WebSocket connections (default: 30/minute) -RATE_LIMIT_WEBSOCKET=30/minute - # Storage backend for rate limiting (default: memory) # Options: memory (single instance) or redis (distributed) RATE_LIMIT_STORAGE=memory diff --git a/CLAUDE.md b/CLAUDE.md index ae4d1be2..28edddfa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -368,15 +368,6 @@ CODEFRAME_BOOTSTRAP_TOKEN= # Out-of-band secret gating the # account"; `cf auth register # --bootstrap-token` sends the header. -# Test-only endpoints (#753) — default OFF -CODEFRAME_ENABLE_TEST_ENDPOINTS=1 # Registers the integration-test-only - # POST /test/broadcast route (pushes a WS - # broadcast to all subscribers). Read once - # at import time; unset = route absent - # (404, not in OpenAPI). Never set in - # production — leave unset except in CI / - # WebSocket integration test runs. - # Experimental cloud engine (#966) — default OFF (refuse) CODEFRAME_ENABLE_CLOUD_ENGINE=1 # Unlock `--engine cloud` (the E2B adapter). # Off by default and off the advertised @@ -518,8 +509,10 @@ JWT_LIFETIME_SECONDS=86400 # JWT validity window; default 24h (was 7d # tells the client to drop its copy; # verification is stateless signature # checking with no server-side denylist - # (the `sessions` table is unused), so a - # leaked token stays valid for up to this + # (there is no session store at all — the + # vestigial `sessions` table was dropped + # in #968), so a leaked token stays valid + # for up to this # lifetime. This value IS the mitigation — # lower it if tokens may leak. diff --git a/codeframe/auth/manager.py b/codeframe/auth/manager.py index e1fea57d..047a284a 100644 --- a/codeframe/auth/manager.py +++ b/codeframe/auth/manager.py @@ -362,7 +362,8 @@ def get_jwt_strategy() -> JWTStrategy: **Known limitation — logout does not revoke.** ``POST /auth/jwt/logout`` only tells the client to drop its copy; the token itself stays valid until it expires, because verification is stateless signature-checking with no - server-side denylist (the ``sessions`` table is unused). A leaked token is + server-side denylist (there is no session store at all — the vestigial + ``sessions`` table was dropped in #968). A leaked token is therefore usable for up to JWT_LIFETIME_SECONDS. The mitigating control is that lifetime — default 24h (#657), and now actually configurable — plus the web UI's CSP against XSS exfiltration. Real revocation needs a diff --git a/codeframe/config/rate_limits.py b/codeframe/config/rate_limits.py index d44c3e34..4f582a07 100644 --- a/codeframe/config/rate_limits.py +++ b/codeframe/config/rate_limits.py @@ -9,7 +9,6 @@ RATE_LIMIT_AUTH: Rate limit for authentication endpoints (default: 10/minute) RATE_LIMIT_STANDARD: Rate limit for standard API endpoints (default: 100/minute) RATE_LIMIT_AI: Rate limit for AI/expensive operations (default: 20/minute) - RATE_LIMIT_WEBSOCKET: Rate limit for WebSocket connections (default: 30/minute) RATE_LIMIT_STORAGE: Storage backend - memory or redis (default: memory) RATE_LIMIT_TRUSTED_PROXIES: Comma-separated trusted proxy IPs/CIDRs REDIS_URL: Redis connection URL for distributed rate limiting (optional) @@ -32,7 +31,6 @@ class RateLimitConfig: auth_limit: Rate limit for authentication endpoints standard_limit: Rate limit for standard API endpoints ai_limit: Rate limit for AI/expensive operations - websocket_limit: Rate limit for WebSocket connections enabled: Whether rate limiting is enabled storage: Storage backend ('memory' or 'redis') redis_url: Redis connection URL for distributed rate limiting @@ -42,7 +40,6 @@ class RateLimitConfig: auth_limit: str = "10/minute" standard_limit: str = "100/minute" ai_limit: str = "20/minute" - websocket_limit: str = "30/minute" enabled: bool = True storage: str = "memory" redis_url: Optional[str] = None @@ -128,7 +125,6 @@ def from_global_config(cls) -> "RateLimitConfig": auth_limit=global_config.rate_limit_auth, standard_limit=global_config.rate_limit_standard, ai_limit=global_config.rate_limit_ai, - websocket_limit=global_config.rate_limit_websocket, enabled=enabled, storage=storage, redis_url=redis_url, diff --git a/codeframe/core/__init__.py b/codeframe/core/__init__.py index 9f9e0f03..598119f9 100644 --- a/codeframe/core/__init__.py +++ b/codeframe/core/__init__.py @@ -1,11 +1,9 @@ """Core components for CodeFRAME orchestration.""" -from codeframe.core.config import Config from codeframe.core.models import Task, TaskStatus, AgentMaturity from codeframe.core.stall_detector import StallAction, StallDetectedError, StallDetector __all__ = [ - "Config", "Task", "TaskStatus", "AgentMaturity", diff --git a/codeframe/core/config.py b/codeframe/core/config.py index aac182c7..a3b82d4f 100644 --- a/codeframe/core/config.py +++ b/codeframe/core/config.py @@ -1,7 +1,8 @@ """Configuration management for CodeFRAME. This module provides two configuration systems: -1. Legacy v1 config (JSON-based): ProjectConfig, GlobalConfig +1. Process config (environment-driven): GlobalConfig, read from the environment + and ``.env`` — secrets, rate limits, CORS, log level. 2. v2 environment config (YAML-based): EnvironmentConfig v2 environment config is stored in .codeframe/config.yaml and controls: @@ -11,7 +12,6 @@ - Lint tools (ruff, eslint, prettier) """ -import json import logging from dataclasses import ( dataclass, @@ -24,10 +24,10 @@ from typing import Any, Optional import yaml -from pydantic import BaseModel, Field, field_validator +from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from codeframe.core.atomic_io import atomic_write_json, atomic_write_text +from codeframe.core.atomic_io import atomic_write_text logger = logging.getLogger(__name__) @@ -605,70 +605,14 @@ def get_default_environment_config() -> EnvironmentConfig: # ============================================================================= -# v1 Legacy Configuration (JSON-based) +# Process configuration (environment-driven) # ============================================================================= - - -class ProviderConfig(BaseModel): - """LLM provider configuration.""" - - lead_agent: str = "claude" - backend_agent: str = "claude" - frontend_agent: str = "gpt4" - test_agent: str = "claude" - review_agent: str = "gpt4" - - -class AgentPolicyConfig(BaseModel): - """Global agent management policies.""" - - require_review_below_maturity: str = "supporting" - allow_full_autonomy: bool = False - - -class InterruptionConfig(BaseModel): - """Interruption mode configuration.""" - - enabled: bool = True - sync_blockers: list[str] = Field(default_factory=lambda: ["requirement", "security"]) - async_blockers: list[str] = Field(default_factory=lambda: ["technical", "external"]) - auto_continue: bool = True - - -class NotificationChannelConfig(BaseModel): - """Notification channel configuration.""" - - enabled: bool = True - channels: list[str] = Field(default_factory=list) - webhook_url: Optional[str] = None - batch_interval: Optional[int] = None - - -class NotificationsConfig(BaseModel): - """Multi-channel notification configuration.""" - - sync_blockers: NotificationChannelConfig = Field(default_factory=NotificationChannelConfig) - async_blockers: NotificationChannelConfig = Field(default_factory=NotificationChannelConfig) - - -class CheckpointConfig(BaseModel): - """Checkpoint configuration.""" - - auto_save_interval: int = 1800 # seconds - pre_compactification: bool = True - per_task_completion: bool = True - - -class ProjectConfig(BaseModel): - """Project-specific configuration.""" - - project_name: str - project_type: str = "python" - providers: ProviderConfig = Field(default_factory=ProviderConfig) - agent_policy: AgentPolicyConfig = Field(default_factory=AgentPolicyConfig) - interruption_mode: InterruptionConfig = Field(default_factory=InterruptionConfig) - notifications: NotificationsConfig = Field(default_factory=NotificationsConfig) - checkpoints: CheckpointConfig = Field(default_factory=CheckpointConfig) +# +# The JSON-file config layer that used to live here — ProjectConfig and its +# ProviderConfig / AgentPolicyConfig / InterruptionConfig / NotificationsConfig / +# CheckpointConfig members, read and written by a `Config` manager against +# .codeframe/config.json — was deleted in #968. Nothing in the product read it; +# workspace settings live in .codeframe/config.yaml via EnvironmentConfig above. class GlobalConfig(BaseSettings): @@ -693,7 +637,6 @@ class GlobalConfig(BaseSettings): # file access and agent execution. Set API_HOST=0.0.0.0 to expose it. api_host: str = Field("127.0.0.1", alias="API_HOST") api_port: int = Field(8080, alias="API_PORT") - cors_origins: str = Field("http://localhost:3000,http://localhost:5173", alias="CORS_ORIGINS") # Logging configuration log_level: str = Field("INFO", alias="LOG_LEVEL") @@ -718,7 +661,6 @@ class GlobalConfig(BaseSettings): rate_limit_auth: str = Field("10/minute", alias="RATE_LIMIT_AUTH") rate_limit_standard: str = Field("100/minute", alias="RATE_LIMIT_STANDARD") rate_limit_ai: str = Field("20/minute", alias="RATE_LIMIT_AI") - rate_limit_websocket: str = Field("30/minute", alias="RATE_LIMIT_WEBSOCKET") # Comma-separated list of trusted proxy IPs/CIDRs (e.g., "10.0.0.0/8,172.16.0.0/12") rate_limit_trusted_proxies: str = Field("", alias="RATE_LIMIT_TRUSTED_PROXIES") @@ -753,59 +695,6 @@ def validate_rate_limit_storage(cls, v: str) -> str: raise ValueError(f"RATE_LIMIT_STORAGE must be one of {allowed}, got: {v}") return v - def get_cors_origins_list(self) -> list[str]: - """Parse CORS origins from comma-separated string.""" - return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] - - def validate_required_for_sprint(self, sprint: int = 1) -> None: - """Validate that required configuration is present for a given sprint. - - Args: - sprint: Sprint number (1-8) - - Raises: - ValueError: If required configuration is missing - """ - errors = [] - - if sprint >= 1: - # Sprint 1 requires Anthropic API key for Lead Agent - if not self.anthropic_api_key: - errors.append( - "ANTHROPIC_API_KEY is required for Sprint 1 (Lead Agent with Claude).\n" - " Get your API key at: https://console.anthropic.com/\n" - " Then add it to your .env file (see .env.example)" - ) - - if sprint >= 4: - # Sprint 4+ may require OpenAI for multi-agent - if not self.openai_api_key and not self.anthropic_api_key: - errors.append( - "At least one AI provider API key is required.\n" - " ANTHROPIC_API_KEY or OPENAI_API_KEY must be set." - ) - - if sprint >= 5: - # Sprint 5+ may require notification services - pass # Notifications are optional, will use webhook fallback - - if errors: - error_msg = "\n\n".join(errors) - raise ValueError( - f"\n{'='*70}\nCONFIGURATION ERROR\n{'='*70}\n\n{error_msg}\n\n{'='*70}\n" - ) - - def ensure_directories(self) -> None: - """Ensure required directories exist.""" - # Create database directory - db_path = Path(self.database_path) - db_path.parent.mkdir(parents=True, exist_ok=True) - - # Create log directory if log file is specified - if self.log_file: - log_path = Path(self.log_file) - log_path.parent.mkdir(parents=True, exist_ok=True) - def load_environment(env_file: str = ".env") -> None: """Load environment variables from .env file. @@ -830,102 +719,6 @@ def load_environment(env_file: str = ".env") -> None: load_env_files(explicit_file=env_path) -class Config: - """Configuration manager for CodeFRAME.""" - - def __init__(self, project_dir: Path): - self.project_dir = project_dir - self.config_dir = project_dir / ".codeframe" - self.config_file = self.config_dir / "config.json" - self._project_config: Optional[ProjectConfig] = None - self._global_config: Optional[GlobalConfig] = None - - # Load environment variables - load_environment() - - def load(self) -> ProjectConfig: - """Load project configuration.""" - if self._project_config: - return self._project_config - - if not self.config_file.exists(): - raise FileNotFoundError(f"Config not found: {self.config_file}") - - with open(self.config_file) as f: - data = json.load(f) - self._project_config = ProjectConfig(**data) - - return self._project_config - - def save(self, config: ProjectConfig) -> None: - """Save project configuration.""" - self.config_dir.mkdir(parents=True, exist_ok=True) - # Atomic (#954) — see save_environment_config. - atomic_write_json(self.config_file, config.model_dump()) - self._project_config = config - - def get_global(self) -> GlobalConfig: - """Load global configuration from environment variables. - - Returns: - GlobalConfig instance with values from environment - - Raises: - ValueError: If required configuration is missing - """ - if not self._global_config: - self._global_config = GlobalConfig() - return self._global_config - - def validate_for_sprint(self, sprint: int = 1) -> None: - """Validate configuration for a specific sprint. - - Args: - sprint: Sprint number to validate for - - Raises: - ValueError: If required configuration is missing - """ - global_config = self.get_global() - global_config.validate_required_for_sprint(sprint) - global_config.ensure_directories() - - def set(self, key: str, value: Any) -> None: - """Set configuration value using dot notation.""" - config = self.load() - keys = key.split(".") - obj = config.model_dump() - - # Navigate to the correct nested dict - current = obj - for k in keys[:-1]: - if k not in current: - current[k] = {} - current = current[k] - - # Set the value - current[keys[-1]] = value - - # Reload and save - self._project_config = ProjectConfig(**obj) - self.save(self._project_config) - - def get(self, key: str) -> Any: - """Get configuration value using dot notation.""" - config = self.load() - keys = key.split(".") - obj = config.model_dump() - - current = obj - for k in keys: - if k not in current: - return None - current = current[k] - - return current - - -# Module-level singleton for GlobalConfig _global_config: Optional[GlobalConfig] = None diff --git a/codeframe/core/env_provenance.py b/codeframe/core/env_provenance.py index 19d309e2..18e5a890 100644 --- a/codeframe/core/env_provenance.py +++ b/codeframe/core/env_provenance.py @@ -188,7 +188,6 @@ def vet_env_base_url(env_var: str) -> Optional[str]: # Whether the guards run at all "CODEFRAME_AUTH_REQUIRED", # `false` disables authentication "CODEFRAME_DEPLOYMENT_MODE", # flips hosted-mode gating - "CODEFRAME_ENABLE_TEST_ENDPOINTS", # arms /test/broadcast (#753) "WORKSPACE_ROOT", # the workspace allowlist (#655/#896) # Where code is found and run "PATH", diff --git a/codeframe/lib/rate_limiter.py b/codeframe/lib/rate_limiter.py index 8f2f9b2e..5afe928a 100644 --- a/codeframe/lib/rate_limiter.py +++ b/codeframe/lib/rate_limiter.py @@ -379,15 +379,15 @@ def wrapper(func: Callable) -> Callable: return decorator -# Rate limit decorators for each category -def rate_limit_auth() -> Callable: - """Decorator for authentication endpoint rate limits. - - Default: 10 requests/minute (configurable via RATE_LIMIT_AUTH) - """ - return _create_rate_limit_decorator("auth_limit")() - - +# Rate limit decorators for each category. +# +# There is deliberately no `rate_limit_auth` decorator (#968): the fastapi-users +# auth routes are mounted via library factory functions, so a decorator — which +# binds at import time and must wrap an endpoint we own — cannot reach them. +# `enforce_auth_rate_limit` below is the dependency that actually throttles them. +# Nor a `rate_limit_websocket` one: slowapi needs an HTTP Request and returns a +# 429 response, neither of which a WebSocket handler has, and connection rate is +# already bounded at the `POST /auth/stream-ticket` mint step. def rate_limit_standard() -> Callable: """Decorator for standard API endpoint rate limits. @@ -404,14 +404,6 @@ def rate_limit_ai() -> Callable: return _create_rate_limit_decorator("ai_limit")() -def rate_limit_websocket() -> Callable: - """Decorator for WebSocket connection rate limits. - - Default: 30 connections/minute (configurable via RATE_LIMIT_WEBSOCKET) - """ - return _create_rate_limit_decorator("websocket_limit")() - - def get_auth_rate_limit_key(request: Request) -> str: """Rate-limit key for auth endpoints (issue #644). diff --git a/codeframe/platform_store/database.py b/codeframe/platform_store/database.py index 52337334..0cf1fead 100644 --- a/codeframe/platform_store/database.py +++ b/codeframe/platform_store/database.py @@ -1,8 +1,8 @@ """Control-plane database management for CodeFRAME. -The global database is a **control-plane store** only: auth (users/accounts/ -sessions/verification), API keys, audit logs, interactive sessions, and token -usage. All v2 domain data (tasks/blockers/PRD/...) lives in the per-workspace +The global database is a **control-plane store** only: auth (users), API keys, +audit logs, interactive sessions, and token usage. All v2 domain data +(tasks/blockers/PRD/...) lives in the per-workspace ``.codeframe/state.db`` via ``codeframe.core.workspace`` — not here. The class acts as a thin facade, delegating to the surviving control-plane diff --git a/codeframe/platform_store/schema_manager.py b/codeframe/platform_store/schema_manager.py index 71ab0887..50b16990 100644 --- a/codeframe/platform_store/schema_manager.py +++ b/codeframe/platform_store/schema_manager.py @@ -38,6 +38,19 @@ def _migration_001_interactive_sessions_user_id(cursor: sqlite3.Cursor) -> None: ) +def _migration_002_drop_dead_auth_tables(cursor: sqlite3.Cursor) -> None: + """Drop the three BetterAuth tables no query ever touched (#968). + + ``create_schema`` is idempotent CREATE-IF-NOT-EXISTS against a long-lived + ``state.db``, so deleting the DDL alone would leave these sitting in every + deployed database forever — including their ``REFERENCES users(id) ON DELETE + CASCADE`` foreign keys, which are still enforced. SQLite drops a table's + indexes with the table, so no separate DROP INDEX is needed. + """ + for table in ("accounts", "sessions", "verification"): + cursor.execute(f"DROP TABLE IF EXISTS {table}") + + class SchemaManager: """Manages database schema creation and migrations. @@ -63,12 +76,13 @@ class SchemaManager: """ #: Version a fully-migrated database reports via ``PRAGMA user_version``. - SCHEMA_VERSION = 1 + SCHEMA_VERSION = 2 #: Ordered ``(target_version, callable(cursor))`` pairs. Each callable must #: be idempotent — it also runs once against a freshly created database. MIGRATIONS = [ (1, _migration_001_interactive_sessions_user_id), + (2, _migration_002_drop_dead_auth_tables), ] def __init__(self, conn: sqlite3.Connection): @@ -88,7 +102,7 @@ def create_schema(self) -> None: """ cursor = self.conn.cursor() - # Authentication tables (users, accounts, sessions, verification, api_keys) + # Authentication tables (users, api_keys) self._create_auth_tables(cursor) # Audit log table @@ -135,63 +149,10 @@ def _create_auth_tables(self, cursor: sqlite3.Cursor) -> None: """ ) - # Accounts table (BetterAuth compatible - stores passwords and OAuth) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS accounts ( - id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - account_id TEXT NOT NULL, - provider_id TEXT NOT NULL, - password TEXT, - access_token TEXT, - refresh_token TEXT, - id_token TEXT, - access_token_expires_at TIMESTAMP, - refresh_token_expires_at TIMESTAMP, - scope TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, provider_id) - ) - """ - ) - - # Create index on user_id for faster login lookups - cursor.execute( - """ - CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id) - """ - ) - - # Sessions table (BetterAuth compatible) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - token TEXT UNIQUE NOT NULL, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - expires_at TIMESTAMP NOT NULL, - ip_address TEXT, - user_agent TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """ - ) - - # Verification table (BetterAuth compatible) - # Used for email verification tokens when requireEmailVerification is enabled - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS verification ( - id TEXT PRIMARY KEY, - identifier TEXT NOT NULL, - value TEXT NOT NULL, - expires_at TIMESTAMP NOT NULL - ) - """ - ) + # NOTE (#968): `accounts`, `sessions` and `verification` used to be created + # here for a BetterAuth migration that never happened. No query ever touched + # them — auth is stateless JWT (users) plus `api_keys` — so they are dropped + # by migration 002 rather than recreated. # API Keys table for programmatic access cursor.execute( @@ -334,13 +295,9 @@ def _create_indexes(self, cursor: sqlite3.Cursor) -> None: "ON audit_logs(resource_type, resource_id, timestamp DESC)" ) - # Authentication indexes (api_keys/accounts indexes are created inline - # with their tables in _create_auth_tables) + # Authentication indexes (the api_keys index is created inline with its + # table in _create_auth_tables) cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)") - cursor.execute( - "CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)" - ) # Workspace registry indexes (issue #601) cursor.execute( diff --git a/codeframe/ui/dependencies.py b/codeframe/ui/dependencies.py index a2cd8386..b66d6487 100644 --- a/codeframe/ui/dependencies.py +++ b/codeframe/ui/dependencies.py @@ -116,12 +116,11 @@ def get_v2_workspace( request: Request = None, auth: Dict[str, Any] = Depends(require_auth), ) -> Workspace: - """Get v2 Workspace from path or server default. + """Get v2 Workspace from path or the server's working directory. This dependency resolves a Workspace from either: 1. An explicit workspace_path query parameter - 2. The server's default workspace (from app.state.default_workspace_path) - 3. The server's current working directory + 2. The server's current working directory Args: workspace_path: Optional explicit path to workspace @@ -141,13 +140,12 @@ async def endpoint(workspace: Workspace = Depends(get_v2_workspace)): # Use workspace here ... """ - # Resolve workspace path + # Resolve workspace path. (#968 removed a middle branch that read a + # server-configured default off app.state; nothing ever assigned that + # attribute, so the branch was unreachable and the default was always cwd.) if workspace_path: path = Path(workspace_path).resolve() - elif request and getattr(request.app.state, "default_workspace_path", None): - path = Path(request.app.state.default_workspace_path).resolve() else: - # Fall back to current working directory path = Path.cwd() # Enforce the workspace allowlist (issue #655). diff --git a/codeframe/ui/models.py b/codeframe/ui/models.py index 4964f8a6..a4ef5615 100644 --- a/codeframe/ui/models.py +++ b/codeframe/ui/models.py @@ -1,907 +1,15 @@ -"""Pydantic models for CodeFRAME API requests and responses. - -Task: cf-11.1 - Request/Response models for project creation -Enhanced: cf-119 - OpenAPI documentation with examples +"""Pydantic models for the CodeFRAME v2 API. + +Scope is deliberately narrow: the settings/API-key surface consumed by +``codeframe.ui.routers.settings_v2``. Every other v2 router declares its own +request/response models locally. The v1-era shapes that used to live here — +projects, checkpoints, agent assignments, activity, issues, blockers — were +deleted in #968; they described endpoints that no longer exist and were kept +alive only by a test of themselves. """ -from enum import Enum -from pydantic import BaseModel, Field, model_validator, ConfigDict -from typing import Literal, Optional, List - - -class SourceType(str, Enum): - """Supported project source types.""" - - GIT_REMOTE = "git_remote" - LOCAL_PATH = "local_path" - UPLOAD = "upload" - EMPTY = "empty" - - -class ProjectCreateRequest(BaseModel): - """Request model for creating a new project.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "name": "my-web-app", - "description": "A modern web application with React frontend and FastAPI backend", - "source_type": "git_remote", - "source_location": "https://github.com/example/my-web-app.git", - "source_branch": "main", - "workspace_name": "my-web-app-workspace" - } - } - ) - - # Required - name: str = Field( - ..., - min_length=1, - max_length=100, - description="Project name (unique identifier, 1-100 characters)" - ) - description: str = Field( - ..., - min_length=1, - max_length=500, - description="Project description explaining the purpose and scope (1-500 characters)" - ) - - # Optional - source configuration - source_type: Optional[SourceType] = Field( - default=SourceType.EMPTY, - description="Source type for project initialization: 'git_remote' (clone from URL), " - "'local_path' (link existing directory), 'upload' (upload files), " - "'empty' (start fresh)" - ) - source_location: Optional[str] = Field( - default=None, - description="Source location - Git URL for 'git_remote', filesystem path for 'local_path', " - "or upload filename for 'upload'. Required unless source_type is 'empty'." - ) - source_branch: Optional[str] = Field( - default="main", - description="Git branch to clone (only used when source_type is 'git_remote')" - ) - - # Optional - workspace naming (auto-generated if not provided) - workspace_name: Optional[str] = Field( - default=None, - description="Custom workspace directory name. If not provided, auto-generated from project name." - ) - - @model_validator(mode="after") - def validate_source(self): - """Validate source_location is provided when source_type requires it.""" - if self.source_type != SourceType.EMPTY and not self.source_location: - raise ValueError(f"source_location required when source_type={self.source_type}") - return self - - -class ReviewRequest(BaseModel): - """Request model for triggering code review. - - Sprint 9 - User Story 1: Review Agent API (T056) - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "task_id": 42, - "project_id": 1, - "files_modified": [ - "src/components/Button.tsx", - "src/utils/validation.ts", - "tests/components/Button.test.tsx" - ] - } - } - ) - - task_id: int = Field(..., description="Task ID associated with the code changes to review") - project_id: int = Field(..., description="Project ID containing the task") - files_modified: List[str] = Field( - ..., - description="List of file paths that were modified and should be reviewed" - ) - - -class QualityGatesRequest(BaseModel): - """Request model for triggering quality gates. - - Sprint 10 - Phase 3: Quality Gates API (T065) - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "gate_types": ["tests", "linting", "type_check"] - } - } - ) - - gate_types: Optional[List[str]] = Field( - default=None, - description="Optional list of gate types to run. If not provided, all gates run. " - "Valid values: 'tests' (run test suite), 'type_check' (mypy/tsc), " - "'coverage' (code coverage), 'code_review' (AI review), 'linting' (ruff/eslint)" - ) - - -class ProjectResponse(BaseModel): - """Response model for project data. - - Task: cf-11.1 - Create ProjectResponse model - Updated cf-17.1: Added phase field for project phase tracking - """ - - model_config = ConfigDict( - from_attributes=True, - json_schema_extra={ - "example": { - "id": 1, - "name": "my-web-app", - "status": "running", - "phase": "active", - "created_at": "2026-02-03T10:30:00Z", - "config": { - "tech_stack": "Python with FastAPI, React frontend", - "git_initialized": True - } - } - } - ) - - id: int = Field(..., description="Unique project ID (auto-generated)") - name: str = Field(..., description="Project name as specified during creation") - status: str = Field( - ..., - description="Project execution status: 'init' (created), 'running' (agents active), " - "'paused' (manually paused), 'completed' (all tasks done), 'failed' (error state)" - ) - phase: str = Field( - default="discovery", - description="Project lifecycle phase: 'discovery' (analyzing codebase), " - "'planning' (generating tasks), 'active' (development in progress), " - "'review' (code review), 'complete' (finished)" - ) - created_at: str = Field(..., description="ISO 8601 timestamp of project creation (e.g., '2026-02-03T10:30:00Z')") - config: Optional[dict] = Field( - default=None, - description="Optional project configuration including tech_stack, git settings, etc." - ) - - -class CheckpointCreateRequest(BaseModel): - """Request model for creating a checkpoint (Sprint 10 Phase 4, T093).""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "name": "pre-refactor-auth", - "description": "Checkpoint before refactoring authentication module", - "trigger": "manual" - } - } - ) - - name: str = Field( - ..., - min_length=1, - max_length=100, - description="Checkpoint name for identification (1-100 characters)" - ) - description: Optional[str] = Field( - None, - max_length=500, - description="Optional description explaining the checkpoint purpose (max 500 characters)" - ) - trigger: str = Field( - default="manual", - description="Trigger type: 'manual' (user-initiated), 'auto' (scheduled), " - "'phase_transition' (automatic on phase change)" - ) - - -class CheckpointResponse(BaseModel): - """Response model for a checkpoint (Sprint 10 Phase 4, T092-T094).""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": 5, - "project_id": 1, - "name": "pre-refactor-auth", - "description": "Checkpoint before refactoring authentication module", - "trigger": "manual", - "git_commit": "a1b2c3d4e5f6", - "database_backup_path": "/backups/project_1/checkpoint_5.db", - "context_snapshot_path": "/backups/project_1/checkpoint_5_context.json", - "metadata": { - "task_count": 15, - "completed_tasks": 8, - "phase": "active" - }, - "created_at": "2026-02-03T14:30:00Z" - } - } - ) - - id: int = Field(..., description="Unique checkpoint ID") - project_id: int = Field(..., description="Project this checkpoint belongs to") - name: str = Field(..., description="Checkpoint name") - description: Optional[str] = Field(None, description="Optional checkpoint description") - trigger: str = Field(..., description="What triggered checkpoint creation (manual/auto/phase_transition)") - git_commit: str = Field(..., description="Git commit hash at checkpoint time") - database_backup_path: str = Field(..., description="Path to database backup file") - context_snapshot_path: str = Field(..., description="Path to context snapshot file") - metadata: dict = Field(..., description="Checkpoint metadata including task counts, phase, etc.") - created_at: str = Field(..., description="ISO 8601 timestamp of checkpoint creation") - - -class RestoreCheckpointRequest(BaseModel): - """Request model for restoring a checkpoint (Sprint 10 Phase 4, T096-T097).""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "confirm_restore": True - } - } - ) - - confirm_restore: bool = Field( - default=False, - description="If False, returns diff preview only. If True, actually restores the checkpoint. " - "Use False first to review changes before committing to restore." - ) - - -class CheckpointDiffResponse(BaseModel): - """Response model for checkpoint diff (Sprint 10 Phase 4).""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "files_changed": 12, - "insertions": 245, - "deletions": 89, - "diff": "diff --git a/src/auth.py b/src/auth.py\n--- a/src/auth.py\n+++ b/src/auth.py\n@@ -10,6 +10,8 @@..." - } - } - ) - - files_changed: int = Field(..., description="Number of files changed since checkpoint") - insertions: int = Field(..., description="Total number of lines inserted since checkpoint") - deletions: int = Field(..., description="Total number of lines deleted since checkpoint") - diff: str = Field(..., description="Full git diff output showing all changes since checkpoint") - - -# Multi-Agent Per Project API Models (Phase 3) - - -class AgentAssignmentRequest(BaseModel): - """Request model for assigning an agent to a project.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "agent_id": "backend-agent-001", - "role": "primary_backend" - } - } - ) - - agent_id: str = Field( - ..., - min_length=1, - max_length=100, - description="Agent ID to assign to the project (1-100 characters)" - ) - role: str = Field( - default="worker", - min_length=1, - max_length=50, - description="Agent's role in this project. Common roles: 'lead' (orchestrator), " - "'primary_backend', 'frontend', 'test', 'code_reviewer', 'worker' (default)" - ) - - -class AgentRoleUpdateRequest(BaseModel): - """Request model for updating an agent's role on a project.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "role": "code_reviewer" - } - } - ) - - role: str = Field( - ..., - min_length=1, - max_length=50, - description="New role for the agent. Common roles: 'lead', 'primary_backend', " - "'secondary_backend', 'frontend', 'test', 'code_reviewer'" - ) - - -class AgentMetricsResponse(BaseModel): - """Response model for agent maturity metrics.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "task_count": 25, - "completed_count": 22, - "completion_rate": 0.88, - "avg_test_pass_rate": 0.95, - "self_correction_rate": 0.76, - "maturity_score": 0.85, - "last_assessed": "2026-02-03T15:00:00Z" - } - } - ) - - task_count: Optional[int] = Field(None, description="Total number of tasks ever assigned to this agent") - completed_count: Optional[int] = Field(None, description="Number of tasks successfully completed") - completion_rate: Optional[float] = Field( - None, - description="Task completion rate as decimal (0.0-1.0). Calculated as completed_count/task_count." - ) - avg_test_pass_rate: Optional[float] = Field( - None, - description="Average test pass rate across all completed tasks (0.0-1.0)" - ) - self_correction_rate: Optional[float] = Field( - None, - description="Rate of first-attempt success without requiring fixes (0.0-1.0)" - ) - maturity_score: Optional[float] = Field( - None, - description="Weighted overall maturity score combining all metrics (0.0-1.0)" - ) - last_assessed: Optional[str] = Field( - None, - description="ISO 8601 timestamp of when metrics were last calculated" - ) - - -class AgentAssignmentResponse(BaseModel): - """Response model for agent assignment data.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "agent_id": "backend-agent-001", - "type": "backend", - "provider": "claude", - "maturity_level": "senior", - "status": "working", - "current_task_id": 42, - "last_heartbeat": "2026-02-03T15:30:00Z", - "metrics": { - "task_count": 25, - "completed_count": 22, - "completion_rate": 0.88, - "maturity_score": 0.85 - }, - "assignment_id": 7, - "role": "primary_backend", - "assigned_at": "2026-02-01T09:00:00Z", - "unassigned_at": None, - "is_active": True - } - } - ) - - agent_id: str = Field(..., description="Unique agent identifier") - type: str = Field( - ..., - description="Agent specialization type: 'lead' (orchestrator), 'backend', 'frontend', 'test', 'review'" - ) - provider: Optional[str] = Field( - None, - description="LLM provider powering the agent: 'claude' (Anthropic), 'gpt4' (OpenAI)" - ) - maturity_level: Optional[str] = Field( - None, - description="Agent maturity level based on performance: 'junior', 'mid', 'senior', 'expert'" - ) - status: Optional[str] = Field( - None, - description="Current agent status: 'idle' (waiting for tasks), 'working' (executing task), " - "'blocked' (waiting for human input), 'offline' (not available)" - ) - current_task_id: Optional[int] = Field( - None, - description="ID of the task currently being executed (null if idle)" - ) - last_heartbeat: Optional[str] = Field( - None, - description="ISO 8601 timestamp of last agent activity" - ) - metrics: Optional[AgentMetricsResponse] = Field( - None, - description="Agent performance metrics (null if never assessed)" - ) - assignment_id: int = Field(..., description="Unique ID for this project-agent assignment") - role: str = Field(..., description="Agent's assigned role within this specific project") - assigned_at: str = Field(..., description="ISO 8601 timestamp when agent was assigned to project") - unassigned_at: Optional[str] = Field( - None, - description="ISO 8601 timestamp when agent was removed from project (null if still active)" - ) - is_active: bool = Field(..., description="True if agent is currently assigned and active on project") - - -class ProjectAssignmentResponse(BaseModel): - """Response model for project assignment data (from agent perspective).""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "project_id": 1, - "name": "my-web-app", - "description": "A modern web application", - "status": "running", - "phase": "active", - "role": "primary_backend", - "assigned_at": "2026-02-01T09:00:00Z", - "unassigned_at": None, - "is_active": True - } - } - ) - - project_id: int = Field(..., description="Unique project identifier") - name: str = Field(..., description="Project name") - description: Optional[str] = Field(None, description="Project description/purpose") - status: str = Field(..., description="Project execution status (init/running/paused/completed/failed)") - phase: str = Field(..., description="Project lifecycle phase (discovery/planning/active/review/complete)") - role: str = Field(..., description="Agent's assigned role within this project") - assigned_at: str = Field(..., description="ISO 8601 timestamp of assignment") - unassigned_at: Optional[str] = Field(None, description="ISO 8601 timestamp of removal (null if active)") - is_active: bool = Field(..., description="True if this assignment is currently active") - - -# ============================================================================ -# Core Endpoint Response Models (Phase 2 OpenAPI Documentation) -# ============================================================================ - - -class TaskResponse(BaseModel): - """Response model for task data.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": 42, - "project_id": 1, - "title": "Implement user authentication endpoint", - "description": "Create POST /api/auth/login endpoint with JWT token generation", - "status": "in_progress", - "priority": 2, - "workflow_step": 3, - "assigned_to": "backend-agent-001", - "depends_on": "41", - "requires_mcp": False, - "created_at": "2026-02-03T10:00:00Z", - "updated_at": "2026-02-03T14:30:00Z" - } - } - ) - - id: int = Field(..., description="Unique task ID") - project_id: int = Field(..., description="Project this task belongs to") - title: str = Field(..., description="Task title/summary") - description: str = Field(default="", description="Detailed task description") - status: str = Field( - ..., - description="Task status: 'pending' (awaiting assignment), 'assigned' (agent assigned), " - "'in_progress' (being worked on), 'blocked' (waiting for human), " - "'completed' (done), 'failed' (error)" - ) - priority: int = Field( - ..., - description="Task priority (0=critical, 1=high, 2=medium, 3=low, 4=backlog)" - ) - workflow_step: int = Field(default=1, description="Workflow step number for ordering") - assigned_to: Optional[str] = Field(None, description="Agent ID currently assigned to task") - depends_on: Optional[str] = Field(None, description="Comma-separated list of task IDs this depends on") - requires_mcp: bool = Field(default=False, description="Whether task requires MCP server access") - created_at: str = Field(..., description="ISO 8601 timestamp of task creation") - updated_at: Optional[str] = Field(None, description="ISO 8601 timestamp of last update") - - -class TaskListResponse(BaseModel): - """Response model for paginated task list.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "tasks": [ - { - "id": 42, - "project_id": 1, - "title": "Implement user authentication", - "status": "in_progress", - "priority": 2 - } - ], - "total": 25 - } - } - ) - - tasks: List[dict] = Field(..., description="List of task objects") - total: int = Field(..., description="Total number of tasks matching filter (before pagination)") - - -class ProjectListResponse(BaseModel): - """Response model for project list.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "projects": [ - { - "id": 1, - "name": "my-web-app", - "status": "running", - "phase": "active", - "created_at": "2026-02-01T09:00:00Z" - } - ] - } - } - ) - - projects: List[dict] = Field(..., description="List of project objects accessible to the user") - - -class ProjectStatusResponse(BaseModel): - """Response model for project status endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "project_id": 1, - "name": "my-web-app", - "status": "running", - "phase": "active", - "workflow_step": 3, - "progress": { - "total_tasks": 25, - "completed_tasks": 15, - "in_progress_tasks": 3, - "blocked_tasks": 1, - "completion_percentage": 60.0 - } - } - } - ) - - project_id: int = Field(..., description="Project ID") - name: str = Field(..., description="Project name") - status: str = Field(..., description="Project execution status") - phase: str = Field(..., description="Project lifecycle phase") - workflow_step: int = Field(default=1, description="Current workflow step") - progress: dict = Field(..., description="Progress metrics including task counts and completion percentage") - - -class ActivityItemResponse(BaseModel): - """Response model for a single activity log item.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": 100, - "action": "task_completed", - "timestamp": "2026-02-03T14:30:00Z", - "details": { - "task_id": 42, - "task_title": "Implement authentication", - "agent_id": "backend-agent-001" - } - } - } - ) - - id: int = Field(..., description="Activity log entry ID") - action: str = Field(..., description="Action type (task_created, task_completed, blocker_created, etc.)") - timestamp: str = Field(..., description="ISO 8601 timestamp of when action occurred") - details: Optional[dict] = Field(None, description="Additional action-specific details") - - -class ActivityListResponse(BaseModel): - """Response model for activity log list.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "activity": [ - { - "id": 100, - "action": "task_completed", - "timestamp": "2026-02-03T14:30:00Z", - "details": {"task_id": 42} - } - ] - } - } - ) - - activity: List[dict] = Field(..., description="List of activity log items, most recent first") - - -class PRDResponse(BaseModel): - """Response model for PRD (Product Requirements Document) endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "project_id": "1", - "prd_content": "# My Web App PRD\n\n## Overview\nA modern web application...", - "generated_at": "2026-02-01T10:00:00Z", - "updated_at": "2026-02-03T14:00:00Z", - "status": "available" - } - } - ) - - project_id: str = Field(..., description="Project ID (as string for API consistency)") - prd_content: str = Field(..., description="PRD content in Markdown format") - generated_at: str = Field(..., description="ISO 8601 timestamp of initial PRD generation") - updated_at: str = Field(..., description="ISO 8601 timestamp of last PRD update") - status: str = Field( - ..., - description="PRD status: 'available' (ready to use), 'generating' (being created), " - "'not_found' (no PRD exists)" - ) - - -class IssueResponse(BaseModel): - """Response model for a single issue.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": "issue-1", - "issue_number": "1", - "title": "User authentication not working", - "description": "Login endpoint returns 500 error", - "status": "open", - "priority": 1, - "depends_on": [], - "proposed_by": "human", - "created_at": "2026-02-03T09:00:00Z", - "updated_at": "2026-02-03T14:00:00Z", - "completed_at": None - } - } - ) - - id: str = Field(..., description="Issue ID") - issue_number: str = Field(..., description="Human-readable issue number") - title: str = Field(..., description="Issue title") - description: str = Field(..., description="Detailed issue description") - status: str = Field(..., description="Issue status (open, in_progress, resolved, closed)") - priority: int = Field(..., description="Issue priority (0=critical to 4=backlog)") - depends_on: List[str] = Field(default_factory=list, description="List of issue IDs this depends on") - proposed_by: str = Field(..., description="Who created the issue: 'agent' or 'human'") - created_at: str = Field(..., description="ISO 8601 creation timestamp") - updated_at: str = Field(..., description="ISO 8601 last update timestamp") - completed_at: Optional[str] = Field(None, description="ISO 8601 completion timestamp (null if open)") - - -class IssuesListResponse(BaseModel): - """Response model for issues list endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "issues": [ - { - "id": "issue-1", - "issue_number": "1", - "title": "User authentication not working", - "status": "open" - } - ], - "total_issues": 5, - "total_tasks": 25 - } - } - ) - - issues: List[dict] = Field(..., description="List of issue objects") - total_issues: int = Field(..., description="Total number of issues") - total_tasks: int = Field(..., description="Total number of tasks across all issues") - - -class SessionStateResponse(BaseModel): - """Response model for session state endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "last_session": { - "summary": "Completed authentication module, started on API endpoints", - "timestamp": "2026-02-02T18:00:00Z" - }, - "next_actions": [ - "Complete /api/users endpoint", - "Add input validation", - "Write unit tests" - ], - "progress_pct": 45.5, - "active_blockers": [ - {"id": 3, "title": "Need database credentials"} - ] - } - } - ) - - last_session: dict = Field( - ..., - description="Summary of the last session including timestamp and what was accomplished" - ) - next_actions: List[str] = Field( - default_factory=list, - description="List of recommended next actions for the current session" - ) - progress_pct: float = Field( - default=0.0, - description="Overall project progress as percentage (0.0-100.0)" - ) - active_blockers: List[dict] = Field( - default_factory=list, - description="List of active blockers requiring human attention" - ) - - -class AgentStartResponse(BaseModel): - """Response model for agent start/pause/resume operations.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "message": "Starting Lead Agent for project 1", - "status": "starting" - } - } - ) - - message: str = Field(..., description="Human-readable status message") - status: str = Field( - ..., - description="Operation status: 'starting' (agent launching), 'running' (already active), " - "'completed' (discovery finished), 'paused', 'resumed'" - ) - - -class BlockerResponse(BaseModel): - """Response model for blocker data.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": 5, - "project_id": 1, - "task_id": 42, - "blocker_type": "SYNC", - "title": "Database credentials needed", - "description": "Cannot connect to production database without credentials", - "status": "PENDING", - "priority": "high", - "created_at": "2026-02-03T10:00:00Z", - "expires_at": "2026-02-03T16:00:00Z", - "resolved_at": None, - "answer": None - } - } - ) - - id: int = Field(..., description="Unique blocker ID") - project_id: int = Field(..., description="Project this blocker belongs to") - task_id: Optional[int] = Field(None, description="Task that created the blocker (if any)") - blocker_type: str = Field( - ..., - description="Blocker type: 'SYNC' (blocks task execution), 'ASYNC' (can continue with workaround)" - ) - title: str = Field(..., description="Brief blocker title") - description: str = Field(..., description="Detailed description of what's blocking progress") - status: str = Field( - ..., - description="Blocker status: 'PENDING' (awaiting resolution), 'RESOLVED' (answered), " - "'EXPIRED' (timed out)" - ) - priority: str = Field(..., description="Blocker priority: 'critical', 'high', 'medium', 'low'") - created_at: str = Field(..., description="ISO 8601 creation timestamp") - expires_at: Optional[str] = Field(None, description="ISO 8601 expiration timestamp (for timed blockers)") - resolved_at: Optional[str] = Field(None, description="ISO 8601 resolution timestamp") - answer: Optional[str] = Field(None, description="Human-provided answer/resolution") - - -class BlockerListResponse(BaseModel): - """Response model for blocker list endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "blockers": [ - { - "id": 5, - "title": "Database credentials needed", - "status": "PENDING", - "blocker_type": "SYNC" - } - ], - "total": 3, - "pending_count": 2, - "sync_count": 1, - "async_count": 2 - } - } - ) - - blockers: List[dict] = Field(..., description="List of blocker objects") - total: int = Field(..., description="Total number of blockers") - pending_count: int = Field(..., description="Number of blockers in PENDING status") - sync_count: int = Field(..., description="Number of SYNC (blocking) blockers") - async_count: int = Field(..., description="Number of ASYNC (non-blocking) blockers") - - -class BlockerMetricsResponse(BaseModel): - """Response model for blocker metrics endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "avg_resolution_time_seconds": 3600.5, - "expiration_rate_percent": 15.0, - "total_blockers": 20, - "resolved_count": 15, - "expired_count": 3, - "pending_count": 2, - "sync_count": 8, - "async_count": 12 - } - } - ) - - avg_resolution_time_seconds: Optional[float] = Field( - None, - description="Average time to resolve blockers in seconds (null if no resolved blockers)" - ) - expiration_rate_percent: float = Field( - ..., - description="Percentage of blockers that expired without resolution" - ) - total_blockers: int = Field(..., description="Total number of blockers ever created") - resolved_count: int = Field(..., description="Number of blockers successfully resolved") - expired_count: int = Field(..., description="Number of blockers that expired") - pending_count: int = Field(..., description="Number of blockers currently pending") - sync_count: int = Field(..., description="Total SYNC blockers") - async_count: int = Field(..., description="Total ASYNC blockers") - - -class ErrorResponse(BaseModel): - """Generic error response model for API errors.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "detail": "Project 99 not found" - } - } - ) - - detail: str = Field(..., description="Error message describing what went wrong") - +from pydantic import BaseModel, Field +from typing import List, Literal, Optional # ============================================================================ # Settings (issue #554) diff --git a/codeframe/ui/response_models.py b/codeframe/ui/response_models.py index e3ebfa50..d2a07f9f 100644 --- a/codeframe/ui/response_models.py +++ b/codeframe/ui/response_models.py @@ -1,80 +1,25 @@ -"""Standardized API response models for CodeFRAME v2. +"""Standardized API *error* responses for CodeFRAME v2. -This module provides consistent response and error formats for all v2 API endpoints. +Success payloads are returned bare — each v2 router declares its own response +model and FastAPI serializes it. There is no success envelope: one was defined +here originally (``ApiResponse``/``api_response``/``PaginatedResponse``) but no +endpoint ever adopted it, so #968 deleted it rather than keep advertising a +house style the codebase does not follow. -Standard Response Format: -{ - "success": true, - "data": { ... }, - "message": "Optional human-readable message" -} +What every router does share is the error shape: -Standard Error Format: -{ - "error": "Error description", - "detail": "Additional context", - "code": "ERROR_CODE" -} + {"error": "Error description", "detail": "Additional context", "code": "ERROR_CODE"} Usage: - from codeframe.ui.response_models import ApiResponse, api_response, ApiError - - @router.get("/items") - async def list_items() -> ApiResponse[list[Item]]: - items = get_items() - return api_response(items, message="Retrieved 5 items") - - # For errors, raise HTTPException with ApiError detail - raise HTTPException(status_code=404, detail=ApiError( - error="Item not found", - detail=f"No item with id {item_id}", - code="ITEM_NOT_FOUND" - ).model_dump()) -""" - -from typing import Any, Generic, Optional, TypeVar - -from pydantic import BaseModel, Field - -T = TypeVar("T") - - -# ============================================================================ -# Standard Response Models -# ============================================================================ - - -class ApiResponse(BaseModel, Generic[T]): - """Standard API response wrapper. - - All successful responses should use this format for consistency. - """ - - success: bool = Field(default=True, description="Whether the request succeeded") - data: T = Field(..., description="Response payload") - message: Optional[str] = Field(default=None, description="Optional human-readable message") - - -class ApiError(BaseModel): - """Standard API error format. - - Use this for HTTPException details to ensure consistent error responses. - """ + from codeframe.ui.response_models import api_error, ErrorCodes - error: str = Field(..., description="Error description") - detail: Optional[str] = Field(default=None, description="Additional context") - code: str = Field(..., description="Machine-readable error code") - - -class PaginatedResponse(BaseModel, Generic[T]): - """Standard paginated response wrapper.""" + raise HTTPException( + status_code=404, + detail=api_error("Item not found", ErrorCodes.NOT_FOUND, f"No item {item_id}"), + ) +""" - success: bool = Field(default=True) - data: list[T] = Field(..., description="List of items") - total: int = Field(..., description="Total number of items") - page: int = Field(..., description="Current page number") - page_size: int = Field(..., description="Items per page") - message: Optional[str] = Field(default=None) +from typing import Optional # ============================================================================ @@ -82,31 +27,6 @@ class PaginatedResponse(BaseModel, Generic[T]): # ============================================================================ -def api_response( - data: Any, - message: Optional[str] = None, -) -> dict: - """Create a standard API response dict. - - Args: - data: Response payload - message: Optional human-readable message - - Returns: - Dict in standard response format - - Example: - return api_response({"id": 1, "name": "test"}, message="Created successfully") - """ - response = { - "success": True, - "data": data, - } - if message: - response["message"] = message - return response - - def api_error( error: str, code: str, @@ -176,39 +96,6 @@ class ErrorCodes: SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE" -# ============================================================================ -# Common Success Messages -# ============================================================================ - - -def created_message(resource: str, id: Any = None) -> str: - """Generate a standard created message.""" - if id: - return f"{resource} created successfully (id: {id})" - return f"{resource} created successfully" - - -def updated_message(resource: str, id: Any = None) -> str: - """Generate a standard updated message.""" - if id: - return f"{resource} updated successfully (id: {id})" - return f"{resource} updated successfully" - - -def deleted_message(resource: str, id: Any = None) -> str: - """Generate a standard deleted message.""" - if id: - return f"{resource} deleted successfully (id: {id})" - return f"{resource} deleted successfully" - - -def retrieved_message(resource: str, count: Optional[int] = None) -> str: - """Generate a standard retrieval message.""" - if count is not None: - return f"Retrieved {count} {resource}(s)" - return f"{resource} retrieved successfully" - - def internal_error(exc: BaseException, *, operation: str, logger=None) -> dict: """Log an unexpected exception and return a client-safe error body (#934). diff --git a/codeframe/ui/routers/prd_v2.py b/codeframe/ui/routers/prd_v2.py index fbb47e08..39e0cf18 100644 --- a/codeframe/ui/routers/prd_v2.py +++ b/codeframe/ui/routers/prd_v2.py @@ -313,7 +313,7 @@ async def _stress_test_event_stream( browser ``EventSource`` can display them via its message handler. Stops early if the client disconnects, so an abandoned stream does not keep - issuing LLM calls — mirroring ``event_stream_generator`` in streaming_v2. + issuing LLM calls — mirroring ``event_stream_generator`` in ui/streaming_utils.py. """ from codeframe.core.prd_stress_test import stress_test_prd_stream diff --git a/codeframe/ui/routers/tasks_v2.py b/codeframe/ui/routers/tasks_v2.py index c59a1318..8d07ddb6 100644 --- a/codeframe/ui/routers/tasks_v2.py +++ b/codeframe/ui/routers/tasks_v2.py @@ -891,7 +891,7 @@ def _spawn_agent_worker( database (#1005). """ from codeframe.core.models import ErrorEvent - from codeframe.ui.routers.streaming_v2 import get_event_publisher + from codeframe.ui.streaming_utils import get_event_publisher publisher = get_event_publisher() @@ -1268,7 +1268,7 @@ async def stream_task_events( detail=api_error("Task not found", ErrorCodes.NOT_FOUND, f"No task with id {task_id}"), ) - from codeframe.ui.routers.streaming_v2 import ( + from codeframe.ui.streaming_utils import ( event_stream_generator, format_sse_event, get_event_publisher, diff --git a/codeframe/ui/server.py b/codeframe/ui/server.py index c17de366..abeeaa46 100644 --- a/codeframe/ui/server.py +++ b/codeframe/ui/server.py @@ -38,13 +38,12 @@ session_chat_ws, settings_v2, terminal_ws, - streaming_v2, tasks_v2, templates_v2, workspace_v2, ) from codeframe.auth import router as auth_router -from codeframe.auth.dependencies import require_auth, require_method_scope +from codeframe.auth.dependencies import require_method_scope from codeframe.platform_store.database import Database from codeframe.lib.rate_limiter import ( get_rate_limiter, @@ -847,39 +846,6 @@ async def health_check(): } -# ============================================================================ -# Test-Only Endpoints (for WebSocket integration tests) -# ============================================================================ -# -# Gated behind CODEFRAME_ENABLE_TEST_ENDPOINTS (#753): /test/broadcast lets any -# *authenticated* principal push arbitrary JSON to every WebSocket subscriber, -# so it must never be reachable in production. Registered only when the flag is -# set; integration tests set it explicitly. The flag is read once at import -# time, so the route is genuinely absent (not in OpenAPI, 404 on request) when -# unset, rather than gated inside the handler. -if os.getenv("CODEFRAME_ENABLE_TEST_ENDPOINTS"): - - @app.post("/test/broadcast", dependencies=[Depends(require_auth)]) - async def test_broadcast(message: dict, project_id: int = None): - """Trigger a WebSocket broadcast for testing purposes. - - This endpoint is only intended for use in integration tests to trigger - broadcasts from the server subprocess. In production, broadcasts are - triggered by actual server-side events. - - Args: - message: The message dict to broadcast - project_id: Optional project ID for filtered broadcasts - - Returns: - Success confirmation - """ - from codeframe.ui.shared import manager - - await manager.broadcast(message, project_id=project_id) - return {"status": "broadcast_sent", "project_id": project_id} - - # ============================================================================ # Router Mounting (v2 only) # ============================================================================ @@ -919,7 +885,6 @@ async def test_broadcast(message: dict, project_id: int = None): app.include_router(review_v2.router, dependencies=_AUTH) # /api/v2/review app.include_router(schedule_v2.router, dependencies=_AUTH) # /api/v2/schedule app.include_router(settings_v2.router, dependencies=_AUTH) # /api/v2/settings -app.include_router(streaming_v2.router, dependencies=_AUTH) # /api/v2/tasks/{id}/stream (SSE) app.include_router(tasks_v2.router, dependencies=_AUTH) # /api/v2/tasks app.include_router(templates_v2.router, dependencies=_AUTH) # /api/v2/templates app.include_router(workspace_v2.router, dependencies=_AUTH) # /api/v2/workspaces diff --git a/codeframe/ui/shared.py b/codeframe/ui/shared.py index 6c17caf9..5832e645 100644 --- a/codeframe/ui/shared.py +++ b/codeframe/ui/shared.py @@ -1,10 +1,12 @@ -"""Shared state and utilities for FastAPI server. +"""Shared WebSocket state for the FastAPI server. -This module contains shared state that multiple routers need access to, -preventing circular import issues. +Lives outside the routers so ``session_chat_ws`` can import it without a +circular import. The v1 ``ConnectionManager`` / ``WebSocketSubscriptionManager`` +broadcast pair was removed in #968 — nothing ever called ``connect()``, so the +subscriber list was permanently empty and every broadcast was a no-op. """ -from typing import Dict, List, Optional, Set +from typing import Dict, Optional from fastapi import WebSocket import asyncio import logging @@ -12,174 +14,6 @@ logger = logging.getLogger(__name__) -class WebSocketSubscriptionManager: - """Manage WebSocket subscriptions for project-filtered broadcasts. - - This manager tracks which WebSocket connections are subscribed to which - projects, enabling filtered broadcasts that only send events to clients - subscribed to the relevant project. - - Thread Safety: - All methods use asyncio.Lock for thread-safe operations, consistent - with the ConnectionManager pattern. - - Data Structure: - subscriptions: Dict[WebSocket, Set[int]] - Maps each websocket to a set of project_ids it's subscribed to. - This allows a single client to subscribe to multiple projects. - """ - - def __init__(self): - self._subscriptions: Dict[WebSocket, Set[int]] = {} - self._subscriptions_lock = asyncio.Lock() - - async def subscribe(self, websocket: WebSocket, project_id: int) -> None: - """Add a project subscription for a websocket. - - Args: - websocket: WebSocket connection to subscribe - project_id: Project ID to subscribe to - """ - async with self._subscriptions_lock: - if websocket not in self._subscriptions: - self._subscriptions[websocket] = set() - - if project_id not in self._subscriptions[websocket]: - self._subscriptions[websocket].add(project_id) - logger.debug(f"WebSocket subscribed to project {project_id}") - else: - logger.debug(f"WebSocket already subscribed to project {project_id}") - - async def unsubscribe(self, websocket: WebSocket, project_id: int) -> None: - """Remove a project subscription for a websocket. - - Args: - websocket: WebSocket connection to unsubscribe - project_id: Project ID to unsubscribe from - """ - async with self._subscriptions_lock: - if websocket in self._subscriptions: - self._subscriptions[websocket].discard(project_id) - logger.debug(f"WebSocket unsubscribed from project {project_id}") - - # Clean up empty subscription sets - if not self._subscriptions[websocket]: - del self._subscriptions[websocket] - - async def get_subscribers(self, project_id: int) -> List[WebSocket]: - """Get list of websockets subscribed to a project. - - Args: - project_id: Project ID to get subscribers for - - Returns: - List of WebSocket connections subscribed to the project - """ - async with self._subscriptions_lock: - subscribers = [ - ws for ws, projects in self._subscriptions.items() - if project_id in projects - ] - return subscribers - - async def cleanup(self, websocket: WebSocket) -> None: - """Remove all subscriptions for a websocket (called on disconnect). - - Args: - websocket: WebSocket connection to clean up - """ - async with self._subscriptions_lock: - if websocket in self._subscriptions: - project_count = len(self._subscriptions[websocket]) - del self._subscriptions[websocket] - logger.debug(f"Cleaned up {project_count} subscriptions for disconnected WebSocket") - - async def get_subscriptions(self, websocket: WebSocket) -> Set[int]: - """Get all project_ids a websocket is subscribed to. - - Args: - websocket: WebSocket connection to check - - Returns: - Set of project_ids the websocket is subscribed to (empty set if none) - """ - async with self._subscriptions_lock: - return self._subscriptions.get(websocket, set()).copy() - - -class ConnectionManager: - """Manage WebSocket connections for real-time updates with project-based filtering.""" - - def __init__(self): - self.active_connections: List[WebSocket] = [] - self._connections_lock = asyncio.Lock() - self.subscription_manager = WebSocketSubscriptionManager() - - async def connect(self, websocket: WebSocket): - await websocket.accept() - async with self._connections_lock: - self.active_connections.append(websocket) - - async def disconnect(self, websocket: WebSocket): - # Clean up subscriptions first - await self.subscription_manager.cleanup(websocket) - - # Then remove from active connections - async with self._connections_lock: - if websocket in self.active_connections: - self.active_connections.remove(websocket) - - async def _send_to_connection(self, connection: WebSocket, message: dict) -> Optional[WebSocket]: - """Send message to a single connection, returning connection on failure. - - Args: - connection: WebSocket connection to send to - message: Message dict to send - - Returns: - The connection object if sending failed (for cleanup), None on success - """ - try: - await connection.send_json(message) - return None - except Exception: - # Return connection for cleanup - return connection - - async def broadcast(self, message: dict, project_id: Optional[int] = None): - """Broadcast message to connected clients concurrently. - - Args: - message: Message dict to broadcast - project_id: Optional project ID for filtered broadcasts. - If None, broadcasts to all connected clients (backward compatible). - If provided, only broadcasts to clients subscribed to that project. - """ - # Determine which connections should receive the message - if project_id is None: - # Backward compatible: broadcast to all connections - async with self._connections_lock: - connections = self.active_connections.copy() - else: - # Filtered broadcast: only to subscribers of this project - connections = await self.subscription_manager.get_subscribers(project_id) - - # Send to all connections concurrently (no lock held during I/O) - tasks = [ - asyncio.create_task(self._send_to_connection(conn, message)) - for conn in connections - ] - - # Wait for all sends to complete - results = await asyncio.gather(*tasks, return_exceptions=True) - - # Disconnect any connections that failed - for result in results: - if result is not None: - # This was a failed connection, disconnect it - await self.disconnect(result) - - class SessionChatManager: """Track active WebSocket connections for per-session agent chat. @@ -245,8 +79,5 @@ async def reset_interrupt(self, session_id: str) -> None: event.clear() -# Global ConnectionManager instance -manager = ConnectionManager() - # Global SessionChatManager instance session_chat_manager = SessionChatManager() diff --git a/codeframe/ui/routers/streaming_v2.py b/codeframe/ui/streaming_utils.py similarity index 86% rename from codeframe/ui/routers/streaming_v2.py rename to codeframe/ui/streaming_utils.py index 13729dd0..0272ccf0 100644 --- a/codeframe/ui/routers/streaming_v2.py +++ b/codeframe/ui/streaming_utils.py @@ -1,29 +1,26 @@ """SSE streaming utilities for real-time task execution events. -This module provides shared SSE utilities (formatting, event generation, -publisher management) used by streaming consumers. - -The actual SSE endpoint for tasks is in tasks_v2.py: - GET /api/v2/tasks/{task_id}/stream (requires workspace_path only) +Formatting, event generation and publisher management shared by streaming +consumers. The SSE endpoint itself lives in ``tasks_v2.py`` — +``GET /api/v2/tasks/{task_id}/stream`` — because it must be reachable by a +browser ``EventSource`` (no custom auth headers). + +This used to sit in ``routers/`` as ``streaming_v2.py`` and carry an ``APIRouter`` +with no route decorators on it, which ``server.py`` dutifully mounted as a no-op. +#968 moved it here: it is a utility module, and now it cannot be re-mounted. """ import asyncio import logging from typing import AsyncGenerator, Callable, Optional -from fastapi import APIRouter, Request -from fastapi.responses import StreamingResponse # noqa: F401 — re-exported +from fastapi import Request from codeframe.core.models import ExecutionEvent from codeframe.core.streaming import EventPublisher logger = logging.getLogger(__name__) -router = APIRouter( - prefix="/api/v2/tasks", - tags=["streaming"], -) - # Global event publisher instance # In production, this should be dependency-injected _event_publisher: Optional[EventPublisher] = None @@ -164,10 +161,3 @@ async def event_stream_generator( if not publisher._subscribers[task_id]: del publisher._subscribers[task_id] logger.info(f"Closing SSE stream for task {task_id}") - - -# NOTE: The SSE stream endpoint for tasks is defined in tasks_v2.py -# (GET /api/v2/tasks/{task_id}/stream) which only requires workspace_path -# and is compatible with browser EventSource (no custom auth headers needed). -# This module retains the shared utilities (format_sse_event, format_sse_comment, -# event_stream_generator, get_event_publisher) used by other streaming consumers. diff --git a/docs/AGENT_SYSTEM_REFERENCE.md b/docs/AGENT_SYSTEM_REFERENCE.md index 1ea01b30..1adb44d4 100644 --- a/docs/AGENT_SYSTEM_REFERENCE.md +++ b/docs/AGENT_SYSTEM_REFERENCE.md @@ -113,6 +113,6 @@ CLI (typer) ─┬── core.* ─── adapters.* Server (fastapi) ─┘ ``` -16 v2 router modules: `blockers_v2`, `prd_v2`, `tasks_v2`, `workspace_v2`, `batches_v2`, `streaming_v2`, `api_key_v2`, `discovery_v2`, `checkpoints_v2`, `schedule_v2`, `templates_v2`, `git_v2`, `review_v2`, `pr_v2`, `environment_v2`, `proof_v2`. +v2 router modules include: `blockers_v2`, `prd_v2`, `tasks_v2`, `workspace_v2`, `batches_v2`, `api_key_v2`, `discovery_v2`, `checkpoints_v2`, `schedule_v2`, `templates_v2`, `git_v2`, `review_v2`, `pr_v2`, `environment_v2`, `proof_v2`. See `docs/PHASE_2_DEVELOPER_GUIDE.md` for full router details. diff --git a/docs/PHASE_2_DEVELOPER_GUIDE.md b/docs/PHASE_2_DEVELOPER_GUIDE.md index 258f21cf..0acf3340 100644 --- a/docs/PHASE_2_DEVELOPER_GUIDE.md +++ b/docs/PHASE_2_DEVELOPER_GUIDE.md @@ -107,8 +107,7 @@ async def list_resources( The `get_v2_workspace()` dependency resolves workspace from: 1. `workspace_path` query parameter (explicit) -2. Server's `default_workspace_path` state (configured) -3. Current working directory (fallback) +2. Current working directory (fallback) ### 4. Core Module Delegation diff --git a/pyproject.toml b/pyproject.toml index 67846b1d..b896c748 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,21 +60,22 @@ dependencies = [ "hypothesis>=6.148.0", "fastapi-users[sqlalchemy]>=15.0.2", "filelock>=3.0", - "python-jose[cryptography]>=3.4.0", - "passlib[argon2]>=1.7.4", + # bcrypt: api_keys.py hashes/verifies with it directly (passlib was removed + # in #968 — it had no importer, and bcrypt was arriving only transitively + # via fastapi-users -> pwdlib). + "bcrypt>=4.2.0", "keyring>=24.0.0", "jinja2>=3.1.6", "slowapi>=0.1.9", "rapidfuzz>=3.0.0", # Security floors — pin transitive deps to patched versions for Dependabot CVE alerts (#659). - # These are pulled by fastapi / fastapi-users / mcp / python-jose / keyring; floored here so + # These are pulled by fastapi / fastapi-users / mcp / keyring; floored here so # `uv lock` resolves to non-vulnerable versions regardless of the parents' lower bounds. "starlette>=1.0.1", # Range-header FileResponse O(n^2) DoS (forces a compatible fastapi) "python-multipart>=0.0.27", # arbitrary file write + DoS in multipart parsing "pyjwt>=2.12.0", # accepts unknown `crit` header extensions "cryptography>=46.0.7", # subgroup attack on SECT curves "urllib3>=2.7.0", # header leak across redirects, decompression bombs - "pyasn1>=0.6.3", # decoder DoS / unbounded recursion "mcp>=1.23.0", # DNS-rebinding protection off by default "idna>=3.15", # DoS in IDNA processing ] diff --git a/test_config_manual.py b/test_config_manual.py deleted file mode 100644 index 452186c6..00000000 --- a/test_config_manual.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env python3 -"""Manual test script for configuration (no pytest required).""" - -import os -import sys -import tempfile -from pathlib import Path - -# Add current directory to path -sys.path.insert(0, str(Path(__file__).parent)) - -from codeframe.core.config import Config, GlobalConfig, load_environment - - -def test_default_values(): - """Test default values.""" - print("✓ Testing default values...") - config = GlobalConfig() - assert config.database_path == ".codeframe/state.db" - assert config.api_host == "0.0.0.0" - assert config.api_port == 8080 - assert config.log_level == "INFO" - print(" ✓ All defaults correct") - - -def test_cors_origins(): - """Test CORS origins parsing.""" - print("✓ Testing CORS origins parsing...") - config = GlobalConfig(cors_origins="http://localhost:3000, http://localhost:5173") - origins = config.get_cors_origins_list() - assert len(origins) == 2 - assert "http://localhost:3000" in origins - print(" ✓ CORS parsing works") - - -def test_log_level_validation(): - """Test log level validation.""" - print("✓ Testing log level validation...") - - # Valid - config = GlobalConfig(log_level="DEBUG") - assert config.log_level == "DEBUG" - - # Case insensitive - config = GlobalConfig(log_level="info") - assert config.log_level == "INFO" - - # Invalid should raise - try: - GlobalConfig(log_level="INVALID") - assert False, "Should have raised ValueError" - except ValueError as e: - assert "LOG_LEVEL must be one of" in str(e) - - print(" ✓ Log level validation works") - - -def test_port_validation(): - """Test port validation.""" - print("✓ Testing port validation...") - - # Valid - config = GlobalConfig(api_port=3000) - assert config.api_port == 3000 - - # Invalid (too low) - try: - GlobalConfig(api_port=0) - assert False, "Should have raised ValueError" - except ValueError as e: - assert "API_PORT must be between" in str(e) - - print(" ✓ Port validation works") - - -def test_sprint_validation(): - """Test sprint validation.""" - print("✓ Testing sprint validation...") - - # With API key - should pass - config = GlobalConfig(anthropic_api_key="sk-test-key") - config.validate_required_for_sprint(sprint=1) - print(" ✓ Validation passes with API key") - - # Without API key - should fail - config = GlobalConfig() - try: - config.validate_required_for_sprint(sprint=1) - assert False, "Should have raised ValueError" - except ValueError as e: - assert "ANTHROPIC_API_KEY is required" in str(e) - print(" ✓ Validation fails without API key") - - -def test_ensure_directories(): - """Test directory creation.""" - print("✓ Testing directory creation...") - - with tempfile.TemporaryDirectory() as tmp_dir: - tmp_path = Path(tmp_dir) - db_path = tmp_path / "test_db" / "state.db" - log_path = tmp_path / "logs" / "test.log" - - config = GlobalConfig(database_path=str(db_path), log_file=str(log_path)) - config.ensure_directories() - - assert db_path.parent.exists() - assert log_path.parent.exists() - print(" ✓ Directories created successfully") - - -def test_config_manager(): - """Test Config manager.""" - print("✓ Testing Config manager...") - - with tempfile.TemporaryDirectory() as tmp_dir: - tmp_path = Path(tmp_dir) - config = Config(tmp_path) - - assert config.project_dir == tmp_path - assert config.config_dir == tmp_path / ".codeframe" - print(" ✓ Config manager initialized") - - -def test_environment_loading(): - """Test environment file loading.""" - print("✓ Testing environment file loading...") - - with tempfile.TemporaryDirectory() as tmp_dir: - tmp_path = Path(tmp_dir) - env_file = tmp_path / ".env" - env_file.write_text("ANTHROPIC_API_KEY=sk-test-env-file") - - # Save current directory and change to temp - original_cwd = Path.cwd() - original_key = os.getenv("ANTHROPIC_API_KEY") - - try: - os.chdir(tmp_path) - load_environment() - - config = GlobalConfig() - assert config.anthropic_api_key == "sk-test-env-file" - print(" ✓ Environment loaded from .env file") - - finally: - os.chdir(original_cwd) - # Restore or remove environment variable - if original_key: - os.environ["ANTHROPIC_API_KEY"] = original_key - else: - os.environ.pop("ANTHROPIC_API_KEY", None) - - -def main(): - """Run all tests.""" - print("\n" + "=" * 70) - print("CONFIGURATION TESTS") - print("=" * 70 + "\n") - - try: - test_default_values() - test_cors_origins() - test_log_level_validation() - test_port_validation() - test_sprint_validation() - test_ensure_directories() - test_config_manager() - test_environment_loading() - - print("\n" + "=" * 70) - print("✅ ALL TESTS PASSED") - print("=" * 70 + "\n") - return 0 - - except Exception as e: - print("\n" + "=" * 70) - print(f"❌ TEST FAILED: {e}") - print("=" * 70 + "\n") - import traceback - - traceback.print_exc() - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 1f36aa17..23f64410 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -3,7 +3,7 @@ import os from pathlib import Path import pytest -from codeframe.core.config import Config, GlobalConfig, load_environment +from codeframe.core.config import GlobalConfig, load_environment class TestGlobalConfig: @@ -30,14 +30,6 @@ def test_default_values(self, monkeypatch): assert config.debug is False assert config.default_provider == "claude" - def test_cors_origins_parsing(self): - """Test CORS origins list parsing.""" - config = GlobalConfig(cors_origins="http://localhost:3000, http://localhost:5173") - origins = config.get_cors_origins_list() - assert len(origins) == 2 - assert "http://localhost:3000" in origins - assert "http://localhost:5173" in origins - def test_log_level_validation(self, monkeypatch): """Test log level validation.""" # Valid log level (use env var as that's how BaseSettings works) @@ -72,42 +64,9 @@ def test_port_validation(self, monkeypatch): with pytest.raises(ValueError, match="API_PORT must be between"): GlobalConfig(_env_file=None) - def test_sprint_1_validation_success(self, monkeypatch): - """Test Sprint 1 validation with API key.""" - # Set API key via env var (that's how BaseSettings works) - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test-key") - - config = GlobalConfig(_env_file=None) - # Should not raise - config.validate_required_for_sprint(sprint=1) - - def test_sprint_1_validation_failure(self, monkeypatch): - """Test Sprint 1 validation without API key.""" - # Ensure no API key in environment - monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - - config = GlobalConfig(_env_file=None) - with pytest.raises(ValueError, match="ANTHROPIC_API_KEY is required"): - config.validate_required_for_sprint(sprint=1) - - def test_ensure_directories(self, tmp_path, monkeypatch): - """Test that ensure_directories creates required paths.""" - db_path = tmp_path / "test_db" / "state.db" - log_path = tmp_path / "logs" / "test.log" - - # Set paths via env var (that's how BaseSettings works) - monkeypatch.setenv("DATABASE_PATH", str(db_path)) - monkeypatch.setenv("LOG_FILE", str(log_path)) - - config = GlobalConfig(_env_file=None) - config.ensure_directories() - - assert db_path.parent.exists() - assert log_path.parent.exists() - -class TestConfig: - """Test Config manager class.""" +class TestEnvironmentLoading: + """Test environment variable loading.""" def test_load_environment(self, tmp_path, monkeypatch): """Test environment file loading.""" @@ -128,50 +87,6 @@ def test_load_environment(self, tmp_path, monkeypatch): finally: os.chdir(original_cwd) - def test_config_initialization(self, tmp_path): - """Test Config initialization.""" - config = Config(tmp_path) - assert config.project_dir == tmp_path - assert config.config_dir == tmp_path / ".codeframe" - assert config.config_file == tmp_path / ".codeframe" / "config.json" - - def test_validate_for_sprint(self, tmp_path, monkeypatch): - """Test sprint validation through Config.""" - # Set API key in environment - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-key") - - config = Config(tmp_path) - # Should not raise with API key set - config.validate_for_sprint(sprint=1) - - def test_validate_for_sprint_missing_key(self, tmp_path, monkeypatch): - """Test sprint validation fails without API key.""" - # Ensure no API key in environment - monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - - # Prevent load_environment from reading .env files - monkeypatch.setattr("codeframe.core.config.load_environment", lambda *args, **kwargs: None) - - config = Config(tmp_path) - # Override _global_config to ensure fresh GlobalConfig without env file - config._global_config = None - - # Patch get_global to not read from .env - from codeframe.core.config import GlobalConfig - - def patched_get_global(): - if not config._global_config: - config._global_config = GlobalConfig(_env_file=None) - return config._global_config - - monkeypatch.setattr(config, "get_global", patched_get_global) - - with pytest.raises(ValueError, match="ANTHROPIC_API_KEY is required"): - config.validate_for_sprint(sprint=1) - - -class TestEnvironmentLoading: - """Test environment variable loading.""" def test_load_from_env_file(self, tmp_path, monkeypatch): """Test loading from .env file.""" diff --git a/tests/config/test_rate_limits.py b/tests/config/test_rate_limits.py index 14358962..7734c5a7 100644 --- a/tests/config/test_rate_limits.py +++ b/tests/config/test_rate_limits.py @@ -20,7 +20,6 @@ def test_default_values(self): assert config.auth_limit == "10/minute" assert config.standard_limit == "100/minute" assert config.ai_limit == "20/minute" - assert config.websocket_limit == "30/minute" assert config.enabled is True assert config.storage == "memory" assert config.redis_url is None @@ -41,7 +40,6 @@ def test_from_global_config_with_defaults(self): "RATE_LIMIT_AUTH", "RATE_LIMIT_STANDARD", "RATE_LIMIT_AI", - "RATE_LIMIT_WEBSOCKET", "RATE_LIMIT_STORAGE", "RATE_LIMIT_TRUSTED_PROXIES", "REDIS_URL", @@ -55,7 +53,6 @@ def test_from_global_config_with_defaults(self): assert config.auth_limit == "10/minute" assert config.standard_limit == "100/minute" assert config.ai_limit == "20/minute" - assert config.websocket_limit == "30/minute" assert config.enabled is True assert config.storage == "memory" @@ -77,7 +74,6 @@ def test_from_global_config_custom_values(self): "RATE_LIMIT_AUTH": "5/minute", "RATE_LIMIT_STANDARD": "200/minute", "RATE_LIMIT_AI": "10/minute", - "RATE_LIMIT_WEBSOCKET": "50/minute", "RATE_LIMIT_STORAGE": "redis", "REDIS_URL": "redis://localhost:6379/0", "RATE_LIMIT_TRUSTED_PROXIES": "10.0.0.1,172.16.0.0/12", @@ -90,7 +86,6 @@ def test_from_global_config_custom_values(self): assert config.auth_limit == "5/minute" assert config.standard_limit == "200/minute" assert config.ai_limit == "10/minute" - assert config.websocket_limit == "50/minute" assert config.enabled is True assert config.storage == "redis" assert config.redis_url == "redis://localhost:6379/0" diff --git a/tests/core/test_config_credentials.py b/tests/core/test_config_credentials.py index 6d166f2a..7fb8bada 100644 --- a/tests/core/test_config_credentials.py +++ b/tests/core/test_config_credentials.py @@ -82,29 +82,3 @@ def test_get_credential_returns_none_when_missing(self): manager = CredentialManager() value = manager.get_credential(CredentialProvider.LLM_ANTHROPIC) assert value is None - - -class TestValidateCredentialsForSprint: - """Tests for sprint-based credential validation.""" - - def test_sprint_1_requires_anthropic(self): - """Sprint 1 requires Anthropic API key.""" - from codeframe.core.config import GlobalConfig - - # Create a config with no Anthropic key by mocking the property - config = GlobalConfig() - config.anthropic_api_key = None - - with pytest.raises(ValueError) as exc_info: - config.validate_required_for_sprint(1) - - assert "ANTHROPIC_API_KEY" in str(exc_info.value) - - def test_sprint_1_passes_with_key(self): - """Sprint 1 passes when Anthropic key is set.""" - from codeframe.core.config import GlobalConfig - - with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-valid-key"}): - config = GlobalConfig() - # Should not raise - config.validate_required_for_sprint(1) diff --git a/tests/core/test_dead_v1_layers_968.py b/tests/core/test_dead_v1_layers_968.py new file mode 100644 index 00000000..b4dd5b23 --- /dev/null +++ b/tests/core/test_dead_v1_layers_968.py @@ -0,0 +1,283 @@ +"""Issue #968 — the dead v1 backend layers stay deleted. + +The bulk of #968 is deletion, so most of what is worth testing is structural: a +symbol that came back, or a router that got re-mounted, is the regression. The +one behavioural change is the platform-store migration that drops the three +never-queried auth tables — ``accounts``, ``sessions`` and ``verification`` — +which needs a real ``MIGRATIONS`` entry because ``create_schema`` is idempotent +CREATE-IF-NOT-EXISTS against a long-lived ``state.db`` and would otherwise leave +them orphaned forever. +""" + +import re +import sqlite3 +from pathlib import Path + +import pytest + +from codeframe.platform_store.database import Database +from codeframe.platform_store.schema_manager import SchemaManager + +pytestmark = pytest.mark.v2 + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEAD_AUTH_TABLES = ("accounts", "sessions", "verification") +#: Note these belong to `sessions`/`accounts`, NOT to the live +#: `interactive_sessions` table, whose own indexes must survive. +DEAD_AUTH_INDEXES = ( + "idx_sessions_user_id", + "idx_sessions_expires_at", + "idx_accounts_user_id", +) + + +def _tables(conn: sqlite3.Connection) -> set: + return { + r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'") + } + + +def _indexes(conn: sqlite3.Connection) -> set: + return { + r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'index'") + } + + +# --- the migration ---------------------------------------------------------- + + +def test_fresh_database_has_no_dead_auth_tables(tmp_path): + """A fresh install builds none of the three, and none of their indexes. + + The index half is the sharp edge: ``_create_indexes`` used to build + ``idx_sessions_user_id`` / ``idx_sessions_expires_at`` *outside* the DDL + block, so removing only the CREATE TABLE statements would make every fresh + ``Database.initialize()`` raise ``no such table: sessions``. + """ + db = Database(tmp_path / "fresh.db") + db.initialize() + try: + assert _tables(db.conn).isdisjoint(DEAD_AUTH_TABLES) + assert _indexes(db.conn).isdisjoint(DEAD_AUTH_INDEXES) + # users/api_keys are the live auth surface and must survive. + assert {"users", "api_keys"} <= _tables(db.conn) + finally: + db.close() + + +def test_legacy_database_gets_the_dead_auth_tables_dropped(tmp_path): + """A pre-#968 database carrying the three tables is migrated, not left alone.""" + db_path = tmp_path / "legacy.db" + conn = sqlite3.connect(db_path) + conn.execute( + "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE " + "NOT NULL, name TEXT, hashed_password TEXT NOT NULL, is_active INTEGER DEFAULT 1, " + "is_superuser INTEGER DEFAULT 0, is_verified INTEGER DEFAULT 0, " + "email_verified INTEGER DEFAULT 0, image TEXT, " + "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " + "updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)" + ) + conn.execute( + "CREATE TABLE accounts (id TEXT PRIMARY KEY, user_id INTEGER NOT NULL " + "REFERENCES users(id) ON DELETE CASCADE, account_id TEXT NOT NULL, " + "provider_id TEXT NOT NULL, password TEXT)" + ) + conn.execute( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, token TEXT UNIQUE NOT NULL, " + "user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, " + "expires_at TIMESTAMP NOT NULL)" + ) + conn.execute( + "CREATE TABLE verification (id TEXT PRIMARY KEY, identifier TEXT NOT NULL, " + "value TEXT NOT NULL, expires_at TIMESTAMP NOT NULL)" + ) + conn.execute("CREATE INDEX idx_sessions_user_id ON sessions(user_id)") + conn.execute("PRAGMA user_version = 1") + conn.commit() + conn.close() + + db = Database(db_path) + db.initialize() + try: + assert _tables(db.conn).isdisjoint(DEAD_AUTH_TABLES) + # SQLite drops a table's indexes with the table. + assert _indexes(db.conn).isdisjoint(DEAD_AUTH_INDEXES) + assert db.conn.execute("PRAGMA user_version").fetchone()[0] == ( + SchemaManager.SCHEMA_VERSION + ) + assert SchemaManager.SCHEMA_VERSION >= 2 + finally: + db.close() + + # Idempotent: re-initializing an already-migrated DB is a no-op, not an error. + again = Database(db_path) + again.initialize() + try: + assert _tables(again.conn).isdisjoint(DEAD_AUTH_TABLES) + finally: + again.close() + + +# --- structural: the deletions stay deleted --------------------------------- + + +def test_ui_models_keeps_only_the_live_settings_surface(): + from codeframe.ui import models + + for gone in ( + "SourceType", + "ProjectCreateRequest", + "ReviewRequest", + "ProjectResponse", + "TaskResponse", + "TaskListResponse", + "BlockerResponse", + "BlockerListResponse", + "CheckpointResponse", + "ErrorResponse", + ): + assert not hasattr(models, gone), f"dead v1 model {gone} is back in ui/models.py" + + # What settings_v2 imports must survive. + for live in ( + "AGENT_TYPES", + "KEY_PROVIDERS", + "AgentSettingsResponse", + "AgentTypeModelConfig", + "KeyProvider", + "KeyStatusResponse", + "StoreKeyRequest", + "UpdateAgentSettingsRequest", + "VerifyKeyRequest", + "VerifyKeyResponse", + ): + assert hasattr(models, live), f"live settings model {live} was deleted" + + +def test_response_models_keeps_only_the_envelope_routers_actually_use(): + from codeframe.ui import response_models + + for gone in ( + "ApiResponse", + "ApiError", + "PaginatedResponse", + "api_response", + "created_message", + "updated_message", + "deleted_message", + "retrieved_message", + ): + assert not hasattr(response_models, gone), f"unused envelope {gone} is back" + + for live in ("api_error", "ErrorCodes", "internal_error"): + assert hasattr(response_models, live) + + +def test_broadcast_connection_managers_are_gone(): + from codeframe.ui import shared + + assert not hasattr(shared, "ConnectionManager") + assert not hasattr(shared, "WebSocketSubscriptionManager") + assert not hasattr(shared, "manager") + # The live chat manager stays — session_chat_ws depends on it. + assert hasattr(shared, "SessionChatManager") + assert hasattr(shared, "session_chat_manager") + + +def test_test_broadcast_route_and_its_gate_are_gone(): + server_src = (REPO_ROOT / "codeframe" / "ui" / "server.py").read_text() + assert "/test/broadcast" not in server_src + assert "CODEFRAME_ENABLE_TEST_ENDPOINTS" not in server_src + + provenance_src = (REPO_ROOT / "codeframe" / "core" / "env_provenance.py").read_text() + assert "CODEFRAME_ENABLE_TEST_ENDPOINTS" not in provenance_src + + +def test_streaming_module_is_a_utility_not_a_mounted_router(): + """The SSE helpers are live; the never-decorated router object is not.""" + from codeframe.ui import streaming_utils + + for live in ( + "get_event_publisher", + "set_event_publisher", + "format_sse_event", + "format_sse_comment", + "event_stream_generator", + ): + assert hasattr(streaming_utils, live) + + assert not hasattr(streaming_utils, "router") + assert not (REPO_ROOT / "codeframe" / "ui" / "routers" / "streaming_v2.py").exists() + + server_src = (REPO_ROOT / "codeframe" / "ui" / "server.py").read_text() + assert "streaming_v2" not in server_src + + +def test_v1_json_config_is_gone(): + from codeframe.core import config + + for gone in ( + "Config", + "ProjectConfig", + "ProviderConfig", + "AgentPolicyConfig", + "InterruptionConfig", + "NotificationChannelConfig", + "CheckpointConfig", + ): + assert not hasattr(config, gone), f"v1 config symbol {gone} is back" + + # The live half must survive: rate limiting and the server lifespan use it. + for live in ( + "GlobalConfig", + "load_environment", + "get_global_config", + "reset_global_config", + ): + assert hasattr(config, live) + + import codeframe.core as core_pkg + + assert not hasattr(core_pkg, "Config") + assert "Config" not in getattr(core_pkg, "__all__", []) + + +def test_unused_rate_limit_decorators_are_gone(): + from codeframe.lib import rate_limiter + + assert not hasattr(rate_limiter, "rate_limit_auth") + assert not hasattr(rate_limiter, "rate_limit_websocket") + # The decorators that are actually applied to routes stay. + assert hasattr(rate_limiter, "rate_limit_standard") + assert hasattr(rate_limiter, "rate_limit_ai") + # Auth throttling lives in the dependency, not the decorator. + assert hasattr(rate_limiter, "enforce_auth_rate_limit") + + +def test_default_workspace_path_is_no_longer_read(): + """The unreachable `app.state.default_workspace_path` branch is gone. + + Asserts on the attribute *access*, not the name — the surrounding comment + still mentions it to explain why the branch was removed. + """ + deps_src = (REPO_ROOT / "codeframe" / "ui" / "dependencies.py").read_text() + assert "app.state.default_workspace_path" not in deps_src + assert '"default_workspace_path"' not in deps_src + + +def test_dead_dependencies_are_removed_from_pyproject(): + """Parses the declared requirements rather than grepping the file. + + A raw substring check would trip over the comment that explains *why* + passlib went away. + """ + import tomllib + + with open(REPO_ROOT / "pyproject.toml", "rb") as fh: + deps = tomllib.load(fh)["project"]["dependencies"] + + names = {re.split(r"[\[<>=!~ ]", d, maxsplit=1)[0].lower() for d in deps} + assert "python-jose" not in names + assert "passlib" not in names + # api_keys.py imports bcrypt directly, so it must be declared, not transitive. + assert "bcrypt" in names diff --git a/tests/core/test_repo_env_override_904.py b/tests/core/test_repo_env_override_904.py index 8f2acf05..c9ebf285 100644 --- a/tests/core/test_repo_env_override_904.py +++ b/tests/core/test_repo_env_override_904.py @@ -35,7 +35,6 @@ "CODEFRAME_ALLOW_CONFIG_BASE_URL", "CODEFRAME_ALLOW_UNRESTRICTED_WORKSPACES", "CODEFRAME_ALLOW_PRIVATE_WEBHOOKS", - "CODEFRAME_ENABLE_TEST_ENDPOINTS", "CODEFRAME_DEPLOYMENT_MODE", "CORS_ALLOWED_ORIGINS", "JWT_LIFETIME_SECONDS", @@ -110,7 +109,6 @@ class TestSecuritySteeringKeysAreNeverTakenFromARepo: "CODEFRAME_ALLOW_CONFIG_BASE_URL", # would reopen #903 by itself "CODEFRAME_ALLOW_UNRESTRICTED_WORKSPACES", "CODEFRAME_ALLOW_PRIVATE_WEBHOOKS", - "CODEFRAME_ENABLE_TEST_ENDPOINTS", "CODEFRAME_DEPLOYMENT_MODE", "CORS_ALLOWED_ORIGINS", "JWT_LIFETIME_SECONDS", diff --git a/tests/lib/test_rate_limiter.py b/tests/lib/test_rate_limiter.py index dc5df7b9..ddc6bd52 100644 --- a/tests/lib/test_rate_limiter.py +++ b/tests/lib/test_rate_limiter.py @@ -394,20 +394,6 @@ def test_rate_limit_ai_decorator_exists(self): decorator = rate_limit_ai() assert callable(decorator) - def test_rate_limit_auth_decorator_exists(self): - """rate_limit_auth decorator should exist and be callable.""" - from codeframe.lib.rate_limiter import rate_limit_auth - - decorator = rate_limit_auth() - assert callable(decorator) - - def test_rate_limit_websocket_decorator_exists(self): - """rate_limit_websocket decorator should exist and be callable.""" - from codeframe.lib.rate_limiter import rate_limit_websocket - - decorator = rate_limit_websocket() - assert callable(decorator) - class TestRateLimiterDisabled: """Tests for disabled rate limiting behavior.""" diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index ebf47c00..54a7ef6f 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -7,7 +7,6 @@ import jwt import os -import secrets import signal import socket import subprocess @@ -21,16 +20,10 @@ import requests import shutil -# Both legacy WebSocket suites are now collected normally instead of being hidden -# here: -# * test_websocket_subscriptions.py is a pure unit suite (WebSocketSubscription -# manager / ConnectionManager with mock sockets — no server, no get_db) and -# passes as-is. -# * test_websocket_integration.py targets the removed v1 `/ws` project- -# subscription protocol (no such route or subscribe handler exists on the v2 -# server), so it carries a truthful module-level skip until it is rewritten -# against the v2 workspace-scoped streaming API — not the false "server is a -# stub" reason it used to hide behind. +# Both legacy WebSocket suites were deleted in #968 along with the v1 +# ConnectionManager / WebSocketSubscriptionManager they were the only consumers +# of. The live WebSocket routes (`/ws/sessions/{id}/chat` and `.../terminal`) are +# covered by their own suites. from codeframe.platform_store.database import Database # noqa: E402 @@ -90,32 +83,6 @@ def wait_for_server(url: str, timeout: float = 10.0) -> bool: return False -def create_test_session_token(db: Database, user_id: int = 1) -> str: - """Create a session token for WebSocket authentication. - - Args: - db: Database instance - user_id: User ID for the session - - Returns: - Session token string - """ - token = secrets.token_urlsafe(32) - session_id = f"test-session-{secrets.token_hex(8)}" - expires_at = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat() - - db.conn.execute( - """ - INSERT INTO sessions (id, token, user_id, expires_at) - VALUES (?, ?, ?, ?) - """, - (session_id, token, user_id, expires_at), - ) - db.conn.commit() - - return token - - @pytest.fixture(scope="module") def running_server(): """Start a real FastAPI server for WebSocket testing. diff --git a/tests/ui/test_models.py b/tests/ui/test_models.py deleted file mode 100644 index 96233fb0..00000000 --- a/tests/ui/test_models.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Tests for API models (project refactoring).""" - -import pytest -from pydantic import ValidationError -from codeframe.ui.models import ( - SourceType, - ProjectCreateRequest, -) - - -def test_source_type_enum_values(): - """Verify SourceType enum has correct values.""" - assert SourceType.GIT_REMOTE == "git_remote" - assert SourceType.LOCAL_PATH == "local_path" - assert SourceType.UPLOAD == "upload" - assert SourceType.EMPTY == "empty" - - -def test_project_create_request_minimal(): - """Verify minimal valid request (name + description only).""" - request = ProjectCreateRequest(name="Test Project", description="A test project") - - assert request.name == "Test Project" - assert request.description == "A test project" - assert request.source_type == SourceType.EMPTY - assert request.source_location is None - assert request.source_branch == "main" - - -def test_project_create_request_git_remote(): - """Verify git_remote request requires source_location.""" - request = ProjectCreateRequest( - name="Test", - description="Test", - source_type=SourceType.GIT_REMOTE, - source_location="https://github.com/user/repo.git", - ) - - assert request.source_type == SourceType.GIT_REMOTE - assert request.source_location == "https://github.com/user/repo.git" - - -def test_project_create_request_validation_error(): - """Verify source_location required when source_type != empty.""" - with pytest.raises(ValidationError) as exc_info: - ProjectCreateRequest( - name="Test", - description="Test", - source_type=SourceType.GIT_REMOTE, - # Missing source_location - ) - - errors = exc_info.value.errors() - assert any("source_location" in str(e) for e in errors) - - -def test_project_create_request_name_required(): - """Verify name is required.""" - with pytest.raises(ValidationError): - ProjectCreateRequest(description="Test") - - -def test_project_create_request_description_required(): - """Verify description is required.""" - with pytest.raises(ValidationError): - ProjectCreateRequest(name="Test") diff --git a/tests/ui/test_streaming_router.py b/tests/ui/test_streaming_router.py index 9e2bbea4..e320b0ba 100644 --- a/tests/ui/test_streaming_router.py +++ b/tests/ui/test_streaming_router.py @@ -31,7 +31,7 @@ class TestSSEEventFormat: def test_progress_event_sse_format(self): """ProgressEvent should format correctly for SSE.""" - from codeframe.ui.routers.streaming_v2 import format_sse_event + from codeframe.ui.streaming_utils import format_sse_event event = ProgressEvent( task_id="task-1", @@ -57,7 +57,7 @@ def test_progress_event_sse_format(self): def test_output_event_sse_format(self): """OutputEvent should include stream and line in SSE format.""" - from codeframe.ui.routers.streaming_v2 import format_sse_event + from codeframe.ui.streaming_utils import format_sse_event event = OutputEvent( task_id="task-1", @@ -75,7 +75,7 @@ def test_output_event_sse_format(self): def test_completion_event_sse_format(self): """CompletionEvent should include status and duration.""" - from codeframe.ui.routers.streaming_v2 import format_sse_event + from codeframe.ui.streaming_utils import format_sse_event event = CompletionEvent( task_id="task-1", @@ -95,7 +95,7 @@ def test_completion_event_sse_format(self): def test_heartbeat_event_sse_format(self): """HeartbeatEvent should be minimal in SSE format.""" - from codeframe.ui.routers.streaming_v2 import format_sse_event + from codeframe.ui.streaming_utils import format_sse_event event = HeartbeatEvent(task_id="task-1") @@ -107,7 +107,7 @@ def test_heartbeat_event_sse_format(self): def test_sse_comment_format(self): """SSE comments should start with colon.""" - from codeframe.ui.routers.streaming_v2 import format_sse_comment + from codeframe.ui.streaming_utils import format_sse_comment comment = format_sse_comment("heartbeat") @@ -122,22 +122,12 @@ class TestStreamingRouterEndpoint: NOTE: The SSE stream endpoint (GET /api/v2/tasks/{task_id}/stream) lives in tasks_v2.py. It only requires workspace_path, making it compatible with browser EventSource which cannot send custom auth headers. - streaming_v2.py provides shared utilities only (no endpoints). + ui/streaming_utils.py provides shared utilities only (no endpoints). """ - def test_streaming_router_has_no_endpoints(self): - """streaming_v2 router should have no endpoints (utilities only).""" - from codeframe.ui.routers.streaming_v2 import router - - assert len(router.routes) == 0 - - -class TestEventPublisherGlobal: - """Tests for global EventPublisher management.""" - def test_get_event_publisher_singleton(self): """get_event_publisher should return the same instance.""" - from codeframe.ui.routers.streaming_v2 import ( + from codeframe.ui.streaming_utils import ( get_event_publisher, set_event_publisher, ) @@ -156,7 +146,7 @@ def test_get_event_publisher_singleton(self): def test_set_event_publisher(self): """set_event_publisher should override the global instance.""" from codeframe.core.streaming import EventPublisher - from codeframe.ui.routers.streaming_v2 import ( + from codeframe.ui.streaming_utils import ( get_event_publisher, set_event_publisher, ) @@ -182,7 +172,7 @@ class TestStreamCompletionRaceGuard: async def test_after_subscribe_terminal_emits_synthetic_and_stops(self): """If the run is already terminal, emit a synthetic completion and end.""" from codeframe.core.streaming import EventPublisher - from codeframe.ui.routers.streaming_v2 import event_stream_generator + from codeframe.ui.streaming_utils import event_stream_generator publisher = EventPublisher() terminal = CompletionEvent(task_id="t1", status="completed", duration_seconds=1.0) @@ -209,7 +199,7 @@ async def test_completion_published_after_subscribe_is_delivered(self): in the queue and is delivered instead of being lost. """ from codeframe.core.streaming import EventPublisher - from codeframe.ui.routers.streaming_v2 import event_stream_generator + from codeframe.ui.streaming_utils import event_stream_generator publisher = EventPublisher() gen = event_stream_generator( diff --git a/tests/ui/test_v2_auth_enforcement.py b/tests/ui/test_v2_auth_enforcement.py index 3dc26de4..0e1aec12 100644 --- a/tests/ui/test_v2_auth_enforcement.py +++ b/tests/ui/test_v2_auth_enforcement.py @@ -39,8 +39,8 @@ ("proof", "/api/v2/proof/requirements"), ("review", "/api/v2/review/diff"), ("schedule", "/api/v2/schedule"), - # streaming_v2 holds SSE utilities only; the SSE route itself is mounted - # under the tasks prefix — assert the auth dependency fires on it too. + # The SSE helpers live in ui/streaming_utils.py; the SSE route itself is + # mounted under the tasks prefix — assert the auth dependency fires on it too. ("streaming-sse", "/api/v2/tasks/abc/stream"), ("settings", "/api/v2/settings"), ("tasks", "/api/v2/tasks"), @@ -49,25 +49,17 @@ ] -def _build_auth_app(tmp_path, monkeypatch, *, enable_test_endpoints): +def _build_auth_app(tmp_path, monkeypatch): """Reload the real server app with auth enforcement enabled. Provisions a dedicated initialized database with a test user (id=1) so the JWT lookup path works in any environment — never rely on a dev machine's ambient DATABASE_PATH (this fixture originally did, and passed locally while failing in CI with "no such table: users"). - - ``enable_test_endpoints`` controls CODEFRAME_ENABLE_TEST_ENDPOINTS (#753), - which the server reads at import time to decide whether to register the - test-only ``/test/broadcast`` route. """ db_path = tmp_path / "state.db" monkeypatch.setenv("DATABASE_PATH", str(db_path)) monkeypatch.setenv("CODEFRAME_AUTH_REQUIRED", "true") - if enable_test_endpoints: - monkeypatch.setenv("CODEFRAME_ENABLE_TEST_ENDPOINTS", "1") - else: - monkeypatch.delenv("CODEFRAME_ENABLE_TEST_ENDPOINTS", raising=False) reset_auth_engine() db = Database(db_path) @@ -84,9 +76,9 @@ def _build_auth_app(tmp_path, monkeypatch, *, enable_test_endpoints): db.conn.commit() db.close() - # server module is import-time; reload it so the CODEFRAME_ENABLE_TEST_ENDPOINTS - # gate is re-evaluated. The require_auth dependency reads the env at request - # time, so a freshly constructed TestClient over the app honors the monkeypatch. + # The server module builds `app` at import time; reload it so this test's + # environment is the one it sees. The require_auth dependency reads the env at + # request time, so a freshly constructed TestClient honors the monkeypatch. from codeframe.ui import server importlib.reload(server) @@ -126,17 +118,8 @@ def _pop_credential_overrides(app) -> None: @pytest.fixture def auth_app(tmp_path, monkeypatch): - """Real server app with auth enforcement and test endpoints enabled.""" - app = _build_auth_app(tmp_path, monkeypatch, enable_test_endpoints=True) - yield app - _pop_credential_overrides(app) - reset_auth_engine() - - -@pytest.fixture -def auth_app_no_test_endpoints(tmp_path, monkeypatch): - """Real server app with auth enforcement but NO test endpoints (#753).""" - app = _build_auth_app(tmp_path, monkeypatch, enable_test_endpoints=False) + """Real server app with auth enforcement enabled.""" + app = _build_auth_app(tmp_path, monkeypatch) yield app _pop_credential_overrides(app) reset_auth_engine() @@ -226,26 +209,6 @@ def test_freshly_minted_ticket_not_401_on_sse_path(self, auth_app): reset_stream_tickets() -def test_test_broadcast_requires_auth(auth_app): - # When the flag enables the endpoint, it still enforces auth. - client = TestClient(auth_app, raise_server_exceptions=False) - resp = client.post("/test/broadcast", json={"message": {"x": 1}}) - assert resp.status_code == 401 - - -def test_test_broadcast_gated_off_without_flag(auth_app_no_test_endpoints): - """#753: without CODEFRAME_ENABLE_TEST_ENDPOINTS the route is not registered, - so even a valid authenticated principal cannot trigger a broadcast.""" - client = TestClient(auth_app_no_test_endpoints, raise_server_exceptions=False) - token = create_test_jwt_token(user_id=1) - resp = client.post( - "/test/broadcast", - json={"message": {"x": 1}}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 404 - - class TestPublicEndpointsStayOpen: def test_root(self, auth_app): client = TestClient(auth_app, raise_server_exceptions=False) diff --git a/tests/ui/test_websocket_integration.py b/tests/ui/test_websocket_integration.py deleted file mode 100644 index 9123e93a..00000000 --- a/tests/ui/test_websocket_integration.py +++ /dev/null @@ -1,877 +0,0 @@ -""" -Integration tests for end-to-end WebSocket subscription workflow. - -This test suite validates the complete WebSocket subscription lifecycle including: -- Full subscription workflow (connect → subscribe → receive filtered messages → unsubscribe) -- Multi-client scenarios with independent subscriptions -- Subscribe/unsubscribe flow and message filtering -- Disconnect cleanup and subscription recovery -- Backward compatibility with unfiltered broadcasts -- Invalid message handling and error responses - -The tests use real WebSocket connections via the `websockets` library to -validate message routing correctness against a running FastAPI server. - -NOTE: These tests target the *removed* v1 `/ws` project-subscription protocol -(``{"type": "subscribe", "project_id": N}`` → filtered broadcasts via -``/test/broadcast``). The v2 server exposes no `/ws` route and no such subscribe -handler — streaming is now workspace-scoped via streaming_v2.py — so this suite -cannot pass without a full rewrite against the v2 streaming API. The unit-level -behaviour of the subscription/connection managers is already covered by -test_websocket_subscriptions.py. This module is collected (not hidden in -conftest) but skipped with a truthful reason rather than the old, false -"serve command is a stub" claim. -""" - -import asyncio -import json -import pytest -import requests -import websockets - -# Truthful skip: the v1 /ws project-subscription protocol these tests drive was -# removed in the v2 refactor. Rewrite against the v2 workspace-scoped streaming -# API to revive them. -pytestmark = pytest.mark.skip( - reason="Targets removed v1 /ws project-subscription protocol; needs rewrite " - "against the v2 workspace-scoped streaming API (see test_websocket_subscriptions.py " - "for the current unit-level coverage)." -) - - -async def trigger_broadcast(server_url: str, message: dict, project_id: int = None): - """Trigger a broadcast via the test API endpoint. - - Args: - server_url: Base server URL (e.g., http://localhost:8080) - message: Message dict to broadcast - project_id: Optional project ID for filtered broadcasts - """ - url = f"{server_url}/test/broadcast" - params = {"project_id": project_id} if project_id is not None else {} - response = requests.post(url, json=message, params=params, timeout=5.0) - response.raise_for_status() - return response.json() - - -async def drain_proactive_messages(websocket, expected_project_id: int = None): - """Drain proactive messages sent after subscription. - - After subscribing, the server sends: - 1. subscribed - subscription confirmation - 2. connection_ack - proactive connection acknowledgment - 3. project_status - proactive project state snapshot - - This helper reads these messages and returns the last one received. - Use this after calling subscribe to clear the proactive messages - before testing broadcast message reception. - - Args: - websocket: WebSocket connection - expected_project_id: Optional project ID to verify in messages - - Returns: - List of message types received during drain - """ - proactive_types = {"subscribed", "connection_ack", "project_status"} - drained = [] - - # Read up to 3 proactive messages (subscribed, connection_ack, project_status) - for _ in range(3): - try: - data = json.loads(await asyncio.wait_for(websocket.recv(), timeout=0.5)) - msg_type = data.get("type") - drained.append(msg_type) - - if expected_project_id is not None: - assert data.get("project_id") == expected_project_id, \ - f"Expected project_id {expected_project_id}, got {data.get('project_id')}" - - # Stop if we get a non-proactive message (shouldn't happen normally) - if msg_type not in proactive_types: - break - except asyncio.TimeoutError: - # No more messages - break - - return drained - - -class TestFullSubscriptionWorkflow: - """Test complete subscription workflow: connect → subscribe → receive → unsubscribe.""" - - @pytest.mark.asyncio - async def test_connect_and_subscribe_single_project(self, running_server, ws_url): - """Test connecting and subscribing to a single project.""" - async with websockets.connect(ws_url) as websocket: - # Send subscribe message - await websocket.send(json.dumps({"type": "subscribe", "project_id": 1})) - - # Receive subscription confirmation - data = json.loads(await websocket.recv()) - assert data["type"] == "subscribed" - assert data["project_id"] == 1 - - @pytest.mark.asyncio - async def test_receive_filtered_broadcast_after_subscribe(self, running_server, ws_url, server_url): - """Test that client receives broadcasts for subscribed project.""" - async with websockets.connect(ws_url) as websocket: - # Subscribe to project 1 - await websocket.send(json.dumps({"type": "subscribe", "project_id": 1})) - - # Drain proactive messages (subscribed, connection_ack, project_status) - drained = await drain_proactive_messages(websocket, expected_project_id=1) - assert "subscribed" in drained - - # Trigger broadcast via test API endpoint - await trigger_broadcast( - server_url, - {"type": "task_status_changed", "task_id": 42, "status": "completed"}, - project_id=1 - ) - - # Client should receive the message - data = json.loads(await websocket.recv()) - assert data["type"] == "task_status_changed" - assert data["task_id"] == 42 - assert data["status"] == "completed" - - @pytest.mark.asyncio - async def test_unsubscribe_stops_receiving_messages(self, running_server, ws_url, server_url): - """Test that client stops receiving messages after unsubscribe.""" - async with websockets.connect(ws_url) as websocket: - # Subscribe to project 1 - await websocket.send(json.dumps({"type": "subscribe", "project_id": 1})) - - # Drain proactive messages - await drain_proactive_messages(websocket, expected_project_id=1) - - # Unsubscribe from project 1 - await websocket.send(json.dumps({"type": "unsubscribe", "project_id": 1})) - unsubscribe_response = json.loads(await websocket.recv()) - assert unsubscribe_response["type"] == "unsubscribed" - assert unsubscribe_response["project_id"] == 1 - - # Broadcast to project 1 should NOT be received - await trigger_broadcast(server_url, - {"type": "task_status_changed", "task_id": 99, "status": "failed"}, - project_id=1 - ) - - # Client should NOT receive this message - # Use asyncio.wait_for with timeout to verify no message arrives - try: - await asyncio.wait_for(websocket.recv(), timeout=0.2) - pytest.fail("Should not receive message after unsubscribe") - except asyncio.TimeoutError: - # Expected: no message received - pass - - @pytest.mark.asyncio - async def test_disconnect_cleanup(self, running_server, ws_url, server_url): - """Test that disconnect properly cleans up subscriptions.""" - # Create first WebSocket connection - websocket1 = await websockets.connect(ws_url) - - try: - # Subscribe to projects - await websocket1.send(json.dumps({"type": "subscribe", "project_id": 1})) - await drain_proactive_messages(websocket1, expected_project_id=1) - - await websocket1.send(json.dumps({"type": "subscribe", "project_id": 2})) - await drain_proactive_messages(websocket1, expected_project_id=2) - - # Verify subscriptions work by receiving a broadcast - await trigger_broadcast( - server_url, - {"type": "test_message", "data": "before_disconnect"}, - project_id=1 - ) - msg = json.loads(await websocket1.recv()) - assert msg["type"] == "test_message" - - # Disconnect - await websocket1.close() - - # Give server time to process disconnect - await asyncio.sleep(0.2) - - # Create second connection and subscribe to same project - websocket2 = await websockets.connect(ws_url) - await websocket2.send(json.dumps({"type": "subscribe", "project_id": 1})) - await drain_proactive_messages(websocket2, expected_project_id=1) - - # Trigger broadcast - only websocket2 should receive it - await trigger_broadcast( - server_url, - {"type": "test_message", "data": "after_disconnect"}, - project_id=1 - ) - - # websocket2 receives the message - msg = json.loads(await websocket2.recv()) - assert msg["data"] == "after_disconnect" - - await websocket2.close() - - finally: - if websocket1.close_code is None: - await websocket1.close() - - -class TestMultiClientScenario: - """Test multi-client scenarios with independent subscriptions.""" - - @pytest.mark.asyncio - async def test_three_clients_with_independent_subscriptions(self, running_server, ws_url, server_url): - """Test 3 clients with different project subscriptions.""" - # Create 3 WebSocket connections - ws1 = await websockets.connect(ws_url) - ws2 = await websockets.connect(ws_url) - ws3 = await websockets.connect(ws_url) - - try: - # Client 1: subscribe to project 1 - await ws1.send(json.dumps({"type": "subscribe", "project_id": 1})) - await drain_proactive_messages(ws1, expected_project_id=1) - - # Client 2: subscribe to project 2 - await ws2.send(json.dumps({"type": "subscribe", "project_id": 2})) - await drain_proactive_messages(ws2, expected_project_id=2) - - # Client 3: subscribe to both projects - await ws3.send(json.dumps({"type": "subscribe", "project_id": 1})) - await drain_proactive_messages(ws3, expected_project_id=1) - await ws3.send(json.dumps({"type": "subscribe", "project_id": 2})) - await drain_proactive_messages(ws3, expected_project_id=2) - - # Broadcast to project 1 - await trigger_broadcast(server_url, - {"type": "task_status_changed", "project_id": 1, "task_id": 101}, - project_id=1 - ) - - # Client 1 should receive (subscribed to project 1) - data1 = json.loads(await ws1.recv()) - assert data1["project_id"] == 1 - assert data1["task_id"] == 101 - - # Client 3 should receive (subscribed to project 1) - data3 = json.loads(await ws3.recv()) - assert data3["project_id"] == 1 - assert data3["task_id"] == 101 - - # Broadcast to project 2 - await trigger_broadcast(server_url, - {"type": "task_status_changed", "project_id": 2, "task_id": 202}, - project_id=2 - ) - - # Client 2 should receive (subscribed to project 2) - data2 = json.loads(await ws2.recv()) - assert data2["project_id"] == 2 - assert data2["task_id"] == 202 - - # Client 3 should receive (subscribed to project 2) - data3 = json.loads(await ws3.recv()) - assert data3["project_id"] == 2 - assert data3["task_id"] == 202 - - finally: - # Cleanup - await ws1.close() - await ws2.close() - await ws3.close() - - @pytest.mark.asyncio - async def test_broadcast_isolation_between_projects(self, running_server, ws_url, server_url): - """Test that broadcasts to one project don't leak to other project subscribers.""" - ws1 = await websockets.connect(ws_url) - ws2 = await websockets.connect(ws_url) - - try: - # Client 1: subscribe to project 1 only - await ws1.send(json.dumps({"type": "subscribe", "project_id": 1})) - await drain_proactive_messages(ws1, expected_project_id=1) - - # Client 2: subscribe to project 2 only - await ws2.send(json.dumps({"type": "subscribe", "project_id": 2})) - await drain_proactive_messages(ws2, expected_project_id=2) - - # Broadcast to project 1 - await trigger_broadcast(server_url, - {"type": "test_result", "project_id": 1, "status": "passed"}, - project_id=1 - ) - - # Client 1 receives message - data1 = json.loads(await ws1.recv()) - assert data1["project_id"] == 1 - - # Client 2 should NOT receive anything (timeout) - try: - await asyncio.wait_for(ws2.recv(), timeout=0.2) - pytest.fail("Client 2 should not receive project 1 broadcasts") - except asyncio.TimeoutError: - pass - - finally: - await ws1.close() - await ws2.close() - - -class TestSubscribeUnsubscribeFlow: - """Test subscribe/unsubscribe flow and message filtering.""" - - @pytest.mark.asyncio - async def test_subscribe_to_multiple_projects_sequentially(self, running_server, ws_url, server_url): - """Test subscribing to multiple projects one after another.""" - async with websockets.connect(ws_url) as websocket: - # Subscribe to project 1 and drain proactive messages - await websocket.send(json.dumps({"type": "subscribe", "project_id": 1})) - drained1 = await drain_proactive_messages(websocket, expected_project_id=1) - assert "subscribed" in drained1 - - # Subscribe to project 2 and drain proactive messages - await websocket.send(json.dumps({"type": "subscribe", "project_id": 2})) - drained2 = await drain_proactive_messages(websocket, expected_project_id=2) - assert "subscribed" in drained2 - - # Subscribe to project 3 and drain proactive messages - await websocket.send(json.dumps({"type": "subscribe", "project_id": 3})) - drained3 = await drain_proactive_messages(websocket, expected_project_id=3) - assert "subscribed" in drained3 - - # Verify all subscriptions are active by triggering broadcasts - # and confirming client receives messages from all projects - await trigger_broadcast( - server_url, - {"type": "test_msg", "project_id": 1}, - project_id=1 - ) - msg1 = json.loads(await websocket.recv()) - assert msg1["project_id"] == 1 - - await trigger_broadcast( - server_url, - {"type": "test_msg", "project_id": 2}, - project_id=2 - ) - msg2 = json.loads(await websocket.recv()) - assert msg2["project_id"] == 2 - - await trigger_broadcast( - server_url, - {"type": "test_msg", "project_id": 3}, - project_id=3 - ) - msg3 = json.loads(await websocket.recv()) - assert msg3["project_id"] == 3 - - @pytest.mark.asyncio - async def test_resubscribe_to_same_project(self, running_server, ws_url, server_url): - """Test that resubscribing to same project doesn't cause issues.""" - async with websockets.connect(ws_url) as websocket: - # Subscribe to project 1 - await websocket.send(json.dumps({"type": "subscribe", "project_id": 1})) - drained1 = await drain_proactive_messages(websocket, expected_project_id=1) - assert "subscribed" in drained1 - - # Subscribe to same project again - await websocket.send(json.dumps({"type": "subscribe", "project_id": 1})) - drained2 = await drain_proactive_messages(websocket, expected_project_id=1) - assert "subscribed" in drained2 - - # Verify subscription still works by triggering a broadcast - await trigger_broadcast( - server_url, - {"type": "test_msg", "data": "test"}, - project_id=1 - ) - msg = json.loads(await websocket.recv()) - assert msg["type"] == "test_msg" - assert msg["data"] == "test" - - @pytest.mark.asyncio - async def test_unsubscribe_then_resubscribe(self, running_server, ws_url, server_url): - """Test unsubscribing and then resubscribing to project.""" - async with websockets.connect(ws_url) as websocket: - # Subscribe to project 1 - await websocket.send(json.dumps({"type": "subscribe", "project_id": 1})) - await drain_proactive_messages(websocket, expected_project_id=1) - - # Unsubscribe - await websocket.send(json.dumps({"type": "unsubscribe", "project_id": 1})) - resp2 = json.loads(await websocket.recv()) - assert resp2["type"] == "unsubscribed" - - # Resubscribe - await websocket.send(json.dumps({"type": "subscribe", "project_id": 1})) - drained = await drain_proactive_messages(websocket, expected_project_id=1) - assert "subscribed" in drained - - # Verify subscription is active by triggering a broadcast - await trigger_broadcast( - server_url, - {"type": "test_msg", "data": "resubscribed"}, - project_id=1 - ) - msg = json.loads(await websocket.recv()) - assert msg["type"] == "test_msg" - assert msg["data"] == "resubscribed" - - -class TestDisconnectCleanup: - """Test disconnect cleanup and subscription removal.""" - - @pytest.mark.asyncio - async def test_disconnect_removes_all_subscriptions(self, running_server, ws_url, server_url): - """Test that disconnect removes all subscriptions for a client.""" - # Create connection - websocket = await websockets.connect(ws_url) - - # Subscribe to multiple projects - for project_id in [1, 2, 3]: - await websocket.send(json.dumps({"type": "subscribe", "project_id": project_id})) - await drain_proactive_messages(websocket, expected_project_id=project_id) - - # Verify subscriptions work before disconnect - await trigger_broadcast( - server_url, - {"type": "test_msg", "data": "before_disconnect"}, - project_id=1 - ) - msg = json.loads(await websocket.recv()) - assert msg["data"] == "before_disconnect" - - # Disconnect - await websocket.close() - await asyncio.sleep(0.2) # Give server time to process disconnect - - # Create new connection (without subscribing) - websocket2 = await websockets.connect(ws_url) - try: - # Trigger broadcast - new connection shouldn't receive it (not subscribed) - await trigger_broadcast( - server_url, - {"type": "test_msg", "data": "after_disconnect"}, - project_id=1 - ) - - # Should timeout (no message received) - try: - await asyncio.wait_for(websocket2.recv(), timeout=0.2) - pytest.fail("New connection should not receive message without subscription") - except asyncio.TimeoutError: - pass # Expected - finally: - await websocket2.close() - - @pytest.mark.asyncio - async def test_disconnect_during_subscription_cleanup(self, running_server, ws_url, server_url): - """Test that disconnect properly cleans up even with active subscriptions.""" - websocket_refs = [] - - # Create multiple connections with subscriptions - for i in range(3): - ws = await websockets.connect(ws_url) - websocket_refs.append(ws) - - # Subscribe to project 1 - await ws.send(json.dumps({"type": "subscribe", "project_id": 1})) - await drain_proactive_messages(ws, expected_project_id=1) - - # Trigger broadcast - all 3 should receive - await trigger_broadcast( - server_url, - {"type": "test_msg", "data": "all_connected"}, - project_id=1 - ) - - # All 3 clients should receive the message - for ws in websocket_refs: - msg = json.loads(await ws.recv()) - assert msg["data"] == "all_connected" - - # Disconnect first client - await websocket_refs[0].close() - await asyncio.sleep(0.2) - - # Trigger broadcast - only 2 remaining should receive - await trigger_broadcast( - server_url, - {"type": "test_msg", "data": "two_remaining"}, - project_id=1 - ) - - # Only remaining 2 clients receive - for ws in websocket_refs[1:]: - msg = json.loads(await ws.recv()) - assert msg["data"] == "two_remaining" - - # Cleanup remaining - for ws in websocket_refs[1:]: - await ws.close() - - await asyncio.sleep(0.2) - - # Trigger broadcast - no one should receive - await trigger_broadcast( - server_url, - {"type": "test_msg", "data": "all_disconnected"}, - project_id=1 - ) - # No assertions needed - just verify no errors (no receivers is OK) - - -class TestBackwardCompatibility: - """Test backward compatibility with unfiltered broadcasts.""" - - @pytest.mark.asyncio - async def test_broadcast_without_project_id_reaches_all_clients(self, running_server, ws_url, server_url): - """Test that broadcasts without project_id reach all connected clients.""" - ws1 = await websockets.connect(ws_url) - ws2 = await websockets.connect(ws_url) - - try: - # Don't subscribe - just connected - - # Broadcast WITHOUT project_id (backward compatible) - await trigger_broadcast(server_url, - {"type": "agent_started", "agent_id": "lead-1"} - # Note: no project_id parameter - ) - - # Both clients should receive the message - data1 = json.loads(await ws1.recv()) - assert data1["type"] == "agent_started" - - data2 = json.loads(await ws2.recv()) - assert data2["type"] == "agent_started" - - finally: - await ws1.close() - await ws2.close() - - @pytest.mark.asyncio - async def test_mixed_subscription_and_unsubscribed_clients(self, running_server, ws_url, server_url): - """Test mix of subscribed and unsubscribed clients with broadcasts.""" - # Client 1: subscribed to project 1 - ws1 = await websockets.connect(ws_url) - await ws1.send(json.dumps({"type": "subscribe", "project_id": 1})) - await drain_proactive_messages(ws1, expected_project_id=1) - - # Client 2: not subscribed to anything - ws2 = await websockets.connect(ws_url) - - try: - # Broadcast to project 1 (filtered) - await trigger_broadcast(server_url, - {"type": "task_status_changed", "task_id": 42}, - project_id=1 - ) - - # Client 1 should receive (subscribed) - data1 = json.loads(await ws1.recv()) - assert data1["type"] == "task_status_changed" - - # Client 2 should NOT receive (not subscribed) - try: - await asyncio.wait_for(ws2.recv(), timeout=0.2) - pytest.fail("Unsubscribed client should not receive filtered broadcasts") - except asyncio.TimeoutError: - pass - - # Now broadcast to ALL (no project_id) - await trigger_broadcast(server_url, - {"type": "agent_status_changed", "agent_id": "worker-1"} - # No project_id - ) - - # Both should receive unfiltered broadcast - data1 = json.loads(await ws1.recv()) - assert data1["type"] == "agent_status_changed" - - data2 = json.loads(await ws2.recv()) - assert data2["type"] == "agent_status_changed" - - finally: - await ws1.close() - await ws2.close() - - -class TestInvalidMessageHandling: - """Test error handling for invalid subscribe messages.""" - - @pytest.mark.asyncio - async def test_subscribe_with_invalid_project_id_string(self, running_server, ws_url): - """Test error handling when project_id is string instead of int.""" - async with websockets.connect(ws_url) as websocket: - # Send subscribe with string project_id - await websocket.send(json.dumps({"type": "subscribe", "project_id": "invalid"})) - - # Should receive error response - response = json.loads(await websocket.recv()) - assert response["type"] == "error" - assert "invalid" in response["error"].lower() or "project_id" in response["error"].lower() - - @pytest.mark.asyncio - async def test_subscribe_with_null_project_id(self, running_server, ws_url): - """Test error handling when project_id is null.""" - async with websockets.connect(ws_url) as websocket: - # Send subscribe with null project_id - await websocket.send(json.dumps({"type": "subscribe", "project_id": None})) - - # Should receive error response - response = json.loads(await websocket.recv()) - assert response["type"] == "error" - - @pytest.mark.asyncio - async def test_subscribe_with_missing_project_id(self, running_server, ws_url): - """Test error handling when project_id is missing.""" - async with websockets.connect(ws_url) as websocket: - # Send subscribe without project_id - await websocket.send(json.dumps({"type": "subscribe"})) - - # Should receive error response - response = json.loads(await websocket.recv()) - assert response["type"] == "error" - - @pytest.mark.asyncio - async def test_malformed_json_handling(self, running_server, ws_url): - """Test error handling for malformed JSON.""" - async with websockets.connect(ws_url) as websocket: - # Send malformed JSON - await websocket.send("{ invalid json") - - # Should receive error response - response = json.loads(await websocket.recv()) - assert response["type"] == "error" - assert "JSON" in response["error"] or "json" in response["error"] - - @pytest.mark.asyncio - async def test_invalid_message_type(self, running_server, ws_url): - """Test handling of unknown message types.""" - async with websockets.connect(ws_url) as websocket: - # Send message with unknown type - await websocket.send(json.dumps({"type": "unknown_type", "data": "something"})) - - # Connection should still work - send a valid message after - await websocket.send(json.dumps({"type": "ping"})) - response = json.loads(await websocket.recv()) - assert response["type"] == "pong" - - -class TestWebSocketSubscriptionManager: - """Unit tests for WebSocketSubscriptionManager class.""" - - @pytest.mark.asyncio - async def test_subscribe_new_websocket(self): - """Test subscribing a new websocket.""" - from unittest.mock import AsyncMock - from codeframe.ui.shared import WebSocketSubscriptionManager - - manager = WebSocketSubscriptionManager() - ws = AsyncMock() - - await manager.subscribe(ws, project_id=1) - - subs = await manager.get_subscriptions(ws) - assert 1 in subs - assert len(subs) == 1 - - @pytest.mark.asyncio - async def test_subscribe_multiple_projects(self): - """Test subscribing to multiple projects.""" - from unittest.mock import AsyncMock - from codeframe.ui.shared import WebSocketSubscriptionManager - - manager = WebSocketSubscriptionManager() - ws = AsyncMock() - - await manager.subscribe(ws, 1) - await manager.subscribe(ws, 2) - await manager.subscribe(ws, 3) - - subs = await manager.get_subscriptions(ws) - assert subs == {1, 2, 3} - - @pytest.mark.asyncio - async def test_unsubscribe_removes_project(self): - """Test unsubscribing from a project.""" - from unittest.mock import AsyncMock - from codeframe.ui.shared import WebSocketSubscriptionManager - - manager = WebSocketSubscriptionManager() - ws = AsyncMock() - - await manager.subscribe(ws, 1) - await manager.subscribe(ws, 2) - - await manager.unsubscribe(ws, 1) - - subs = await manager.get_subscriptions(ws) - assert 1 not in subs - assert 2 in subs - - @pytest.mark.asyncio - async def test_unsubscribe_all_removes_websocket(self): - """Test that unsubscribing from all projects removes websocket.""" - from unittest.mock import AsyncMock - from codeframe.ui.shared import WebSocketSubscriptionManager - - manager = WebSocketSubscriptionManager() - ws = AsyncMock() - - await manager.subscribe(ws, 1) - await manager.unsubscribe(ws, 1) - - subs = await manager.get_subscriptions(ws) - assert len(subs) == 0 - - @pytest.mark.asyncio - async def test_cleanup_removes_all_subscriptions(self): - """Test cleanup removes all subscriptions for a websocket.""" - from unittest.mock import AsyncMock - from codeframe.ui.shared import WebSocketSubscriptionManager - - manager = WebSocketSubscriptionManager() - ws = AsyncMock() - - await manager.subscribe(ws, 1) - await manager.subscribe(ws, 2) - await manager.subscribe(ws, 3) - - await manager.cleanup(ws) - - subs = await manager.get_subscriptions(ws) - assert len(subs) == 0 - - @pytest.mark.asyncio - async def test_get_subscribers_for_project(self): - """Test getting all subscribers for a specific project.""" - from unittest.mock import AsyncMock - from codeframe.ui.shared import WebSocketSubscriptionManager - - manager = WebSocketSubscriptionManager() - - ws1 = AsyncMock() - ws2 = AsyncMock() - ws3 = AsyncMock() - - # ws1 and ws3 subscribe to project 1 - await manager.subscribe(ws1, 1) - await manager.subscribe(ws3, 1) - - # ws2 subscribes to project 2 - await manager.subscribe(ws2, 2) - - # Get subscribers for project 1 - subs_p1 = await manager.get_subscribers(1) - assert ws1 in subs_p1 - assert ws3 in subs_p1 - assert ws2 not in subs_p1 - assert len(subs_p1) == 2 - - # Get subscribers for project 2 - subs_p2 = await manager.get_subscribers(2) - assert ws2 in subs_p2 - assert ws1 not in subs_p2 - assert len(subs_p2) == 1 - - @pytest.mark.asyncio - async def test_unsubscribe_nonexistent_project(self): - """Test unsubscribing from a project that wasn't subscribed.""" - from unittest.mock import AsyncMock - from codeframe.ui.shared import WebSocketSubscriptionManager - - manager = WebSocketSubscriptionManager() - ws = AsyncMock() - - await manager.subscribe(ws, 1) - - # Unsubscribe from non-existent project - await manager.unsubscribe(ws, 99) - - # Original subscription should remain - subs = await manager.get_subscriptions(ws) - assert 1 in subs - - @pytest.mark.asyncio - async def test_cleanup_nonexistent_websocket(self): - """Test cleanup on websocket that has no subscriptions.""" - from unittest.mock import AsyncMock - from codeframe.ui.shared import WebSocketSubscriptionManager - - manager = WebSocketSubscriptionManager() - ws = AsyncMock() - - # Should not raise error - await manager.cleanup(ws) - - subs = await manager.get_subscriptions(ws) - assert len(subs) == 0 - - -class TestEdgeCases: - """Test edge cases and boundary conditions.""" - - @pytest.mark.asyncio - async def test_rapid_subscribe_unsubscribe(self, running_server, ws_url): - """Test rapid subscribe/unsubscribe operations.""" - async with websockets.connect(ws_url) as websocket: - # Rapidly subscribe and unsubscribe - for i in range(10): - await websocket.send(json.dumps({"type": "subscribe", "project_id": 1})) - # Drain proactive messages (subscribed, connection_ack, project_status) - await drain_proactive_messages(websocket, expected_project_id=1) - await websocket.send(json.dumps({"type": "unsubscribe", "project_id": 1})) - resp = json.loads(await websocket.recv()) - assert resp["type"] == "unsubscribed" - - # Connection should still be valid - await websocket.send(json.dumps({"type": "ping"})) - response = json.loads(await websocket.recv()) - assert response["type"] == "pong" - - @pytest.mark.asyncio - async def test_large_project_id(self, running_server, ws_url, server_url): - """Test that subscribing to non-existent project returns access denied.""" - async with websockets.connect(ws_url) as websocket: - large_id = 999999999 - - # Subscribe to large project ID that doesn't exist - await websocket.send(json.dumps({"type": "subscribe", "project_id": large_id})) - response = json.loads(await websocket.recv()) - - # Should get access denied error (project doesn't exist) - assert response["type"] == "error" - assert "access denied" in response.get("error", "").lower() - - @pytest.mark.asyncio - async def test_zero_project_id_rejected(self, running_server, ws_url): - """Test that project_id of 0 is rejected.""" - async with websockets.connect(ws_url) as websocket: - # Try to subscribe to project 0 - await websocket.send(json.dumps({"type": "subscribe", "project_id": 0})) - response = json.loads(await websocket.recv()) - # Should receive error - assert response["type"] == "error" - assert "positive" in response["error"].lower() - - @pytest.mark.asyncio - async def test_negative_project_id_rejected(self, running_server, ws_url): - """Test that negative project IDs are rejected.""" - async with websockets.connect(ws_url) as websocket: - # Try to subscribe to negative project ID - await websocket.send(json.dumps({"type": "subscribe", "project_id": -1})) - - # Should receive error - response = json.loads(await websocket.recv()) - assert response["type"] == "error" - assert "positive" in response["error"].lower() - - # Connection should still work - await websocket.send(json.dumps({"type": "ping"})) - pong = json.loads(await websocket.recv()) - assert pong["type"] == "pong" diff --git a/tests/ui/test_websocket_subscriptions.py b/tests/ui/test_websocket_subscriptions.py deleted file mode 100644 index d25d274c..00000000 --- a/tests/ui/test_websocket_subscriptions.py +++ /dev/null @@ -1,861 +0,0 @@ -""" -Comprehensive tests for WebSocketSubscriptionManager and ConnectionManager. - -Tests cover: -- WebSocketSubscriptionManager: subscription tracking, cleanup -- ConnectionManager: broadcast filtering, disconnect handling -- Edge cases and concurrency -- Thread safety with asyncio.Lock -""" - -import pytest -import asyncio -from unittest.mock import AsyncMock, MagicMock - -from codeframe.ui.shared import WebSocketSubscriptionManager, ConnectionManager - - -# ============================================================================ -# Fixtures -# ============================================================================ - - -@pytest.fixture -def mock_websocket(): - """Create a mock WebSocket connection.""" - ws = MagicMock() - ws.send_json = AsyncMock() - ws.accept = AsyncMock() - ws.close = AsyncMock() - return ws - - -@pytest.fixture -def mock_websockets(): - """Create multiple mock WebSocket connections.""" - return [MagicMock() for _ in range(3)] - - -@pytest.fixture -def subscription_manager(): - """Create a fresh WebSocketSubscriptionManager instance.""" - return WebSocketSubscriptionManager() - - -@pytest.fixture -def connection_manager(): - """Create a fresh ConnectionManager instance.""" - return ConnectionManager() - - -# ============================================================================ -# WebSocketSubscriptionManager Tests -# ============================================================================ - - -class TestWebSocketSubscriptionManagerSubscribe: - """Tests for subscribe() method.""" - - @pytest.mark.asyncio - async def test_subscribe_single_project(self, subscription_manager, mock_websocket): - """Subscribe websocket to one project.""" - project_id = 1 - await subscription_manager.subscribe(mock_websocket, project_id) - - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert project_id in subscriptions - assert len(subscriptions) == 1 - - @pytest.mark.asyncio - async def test_subscribe_multiple_projects(self, subscription_manager, mock_websocket): - """Subscribe websocket to multiple projects.""" - project_ids = [1, 2, 3, 4, 5] - - for project_id in project_ids: - await subscription_manager.subscribe(mock_websocket, project_id) - - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert subscriptions == set(project_ids) - assert len(subscriptions) == 5 - - @pytest.mark.asyncio - async def test_subscribe_duplicate(self, subscription_manager, mock_websocket): - """Subscribing twice to same project is idempotent.""" - project_id = 1 - - # Subscribe twice to the same project - await subscription_manager.subscribe(mock_websocket, project_id) - await subscription_manager.subscribe(mock_websocket, project_id) - - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert subscriptions == {project_id} - assert len(subscriptions) == 1 - - @pytest.mark.asyncio - async def test_subscribe_creates_set_for_new_websocket( - self, subscription_manager, mock_websocket - ): - """Subscribing to new websocket creates set.""" - project_id = 1 - - # Initially no subscriptions - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert len(subscriptions) == 0 - - # After subscribe, set is created - await subscription_manager.subscribe(mock_websocket, project_id) - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert len(subscriptions) == 1 - - -class TestWebSocketSubscriptionManagerUnsubscribe: - """Tests for unsubscribe() method.""" - - @pytest.mark.asyncio - async def test_unsubscribe_existing(self, subscription_manager, mock_websocket): - """Unsubscribe from subscribed project.""" - project_id = 1 - await subscription_manager.subscribe(mock_websocket, project_id) - - # Verify subscribed - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert project_id in subscriptions - - # Unsubscribe - await subscription_manager.unsubscribe(mock_websocket, project_id) - - # Verify unsubscribed - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert project_id not in subscriptions - - @pytest.mark.asyncio - async def test_unsubscribe_not_subscribed(self, subscription_manager, mock_websocket): - """Unsubscribe from project not subscribed to.""" - project_id = 1 - - # Unsubscribe without subscribing (should not raise) - await subscription_manager.unsubscribe(mock_websocket, project_id) - - # Verify still not subscribed - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert project_id not in subscriptions - - @pytest.mark.asyncio - async def test_unsubscribe_cleans_up_empty_subscriptions( - self, subscription_manager, mock_websocket - ): - """Unsubscribing from last project removes websocket entry.""" - project_id = 1 - - await subscription_manager.subscribe(mock_websocket, project_id) - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert len(subscriptions) == 1 - - # Unsubscribe from only project - await subscription_manager.unsubscribe(mock_websocket, project_id) - - # Should be removed completely - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert len(subscriptions) == 0 - - @pytest.mark.asyncio - async def test_unsubscribe_keeps_other_subscriptions( - self, subscription_manager, mock_websocket - ): - """Unsubscribing from one project keeps others intact.""" - projects = [1, 2, 3] - - # Subscribe to multiple projects - for project_id in projects: - await subscription_manager.subscribe(mock_websocket, project_id) - - # Unsubscribe from one - await subscription_manager.unsubscribe(mock_websocket, 2) - - # Should still have others - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert 1 in subscriptions - assert 2 not in subscriptions - assert 3 in subscriptions - - -class TestWebSocketSubscriptionManagerGetSubscribers: - """Tests for get_subscribers() method.""" - - @pytest.mark.asyncio - async def test_get_subscribers_none(self, subscription_manager, mock_websocket): - """No subscribers for project with no subscriptions.""" - project_id = 1 - - subscribers = await subscription_manager.get_subscribers(project_id) - assert subscribers == [] - - @pytest.mark.asyncio - async def test_get_subscribers_single( - self, subscription_manager, mock_websocket, mock_websockets - ): - """One subscriber for project.""" - project_id = 1 - - await subscription_manager.subscribe(mock_websocket, project_id) - - subscribers = await subscription_manager.get_subscribers(project_id) - assert len(subscribers) == 1 - assert mock_websocket in subscribers - - @pytest.mark.asyncio - async def test_get_subscribers_multiple(self, subscription_manager, mock_websockets): - """Multiple subscribers for same project.""" - project_id = 1 - - # Subscribe all to same project - for ws in mock_websockets: - await subscription_manager.subscribe(ws, project_id) - - subscribers = await subscription_manager.get_subscribers(project_id) - assert len(subscribers) == 3 - for ws in mock_websockets: - assert ws in subscribers - - @pytest.mark.asyncio - async def test_get_subscribers_mixed_subscriptions( - self, subscription_manager, mock_websockets - ): - """Get subscribers filters correctly with mixed subscriptions.""" - # Subscribe websockets to different projects - await subscription_manager.subscribe(mock_websockets[0], 1) # ws0 -> project 1 - await subscription_manager.subscribe(mock_websockets[1], 1) # ws1 -> project 1 - await subscription_manager.subscribe(mock_websockets[1], 2) # ws1 -> project 2 - await subscription_manager.subscribe(mock_websockets[2], 2) # ws2 -> project 2 - - # Get subscribers for project 1 - subscribers_1 = await subscription_manager.get_subscribers(1) - assert len(subscribers_1) == 2 - assert mock_websockets[0] in subscribers_1 - assert mock_websockets[1] in subscribers_1 - assert mock_websockets[2] not in subscribers_1 - - # Get subscribers for project 2 - subscribers_2 = await subscription_manager.get_subscribers(2) - assert len(subscribers_2) == 2 - assert mock_websockets[1] in subscribers_2 - assert mock_websockets[2] in subscribers_2 - assert mock_websockets[0] not in subscribers_2 - - -class TestWebSocketSubscriptionManagerCleanup: - """Tests for cleanup() method.""" - - @pytest.mark.asyncio - async def test_cleanup_removes_all(self, subscription_manager, mock_websocket): - """Cleanup removes all subscriptions for websocket.""" - projects = [1, 2, 3, 4, 5] - - # Subscribe to multiple projects - for project_id in projects: - await subscription_manager.subscribe(mock_websocket, project_id) - - # Verify subscribed to all - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert len(subscriptions) == 5 - - # Cleanup - await subscription_manager.cleanup(mock_websocket) - - # Should have no subscriptions - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert len(subscriptions) == 0 - - @pytest.mark.asyncio - async def test_cleanup_not_subscribed(self, subscription_manager, mock_websocket): - """Cleanup on unsubscribed websocket is safe.""" - # Cleanup without subscribing (should not raise) - await subscription_manager.cleanup(mock_websocket) - - # Should be empty - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert len(subscriptions) == 0 - - @pytest.mark.asyncio - async def test_cleanup_does_not_affect_others( - self, subscription_manager, mock_websockets - ): - """Cleanup for one websocket doesn't affect others.""" - project_id = 1 - - # Subscribe all to same project - for ws in mock_websockets: - await subscription_manager.subscribe(ws, project_id) - - # Verify all subscribed - subscribers = await subscription_manager.get_subscribers(project_id) - assert len(subscribers) == 3 - - # Cleanup one - await subscription_manager.cleanup(mock_websockets[0]) - - # Other two should still be subscribed - subscribers = await subscription_manager.get_subscribers(project_id) - assert len(subscribers) == 2 - assert mock_websockets[1] in subscribers - assert mock_websockets[2] in subscribers - assert mock_websockets[0] not in subscribers - - -class TestWebSocketSubscriptionManagerGetSubscriptions: - """Tests for get_subscriptions() method.""" - - @pytest.mark.asyncio - async def test_get_subscriptions_empty(self, subscription_manager, mock_websocket): - """Get subscriptions for unsubscribed websocket.""" - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - - assert isinstance(subscriptions, set) - assert len(subscriptions) == 0 - - @pytest.mark.asyncio - async def test_get_subscriptions_multiple(self, subscription_manager, mock_websocket): - """Get subscriptions for multi-project websocket.""" - projects = [10, 20, 30, 40] - - for project_id in projects: - await subscription_manager.subscribe(mock_websocket, project_id) - - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - - assert len(subscriptions) == 4 - assert subscriptions == set(projects) - - @pytest.mark.asyncio - async def test_get_subscriptions_returns_copy(self, subscription_manager, mock_websocket): - """get_subscriptions() returns copy, not reference.""" - project_id = 1 - await subscription_manager.subscribe(mock_websocket, project_id) - - subscriptions1 = await subscription_manager.get_subscriptions(mock_websocket) - subscriptions2 = await subscription_manager.get_subscriptions(mock_websocket) - - # Both should have the subscription - assert 1 in subscriptions1 - assert 1 in subscriptions2 - - # But they should be different objects - assert subscriptions1 is not subscriptions2 - - # Modifying one shouldn't affect the other - subscriptions1.add(999) - assert 999 not in subscriptions2 - - -# ============================================================================ -# ConnectionManager Tests - Broadcasting -# ============================================================================ - - -class TestConnectionManagerBroadcast: - """Tests for ConnectionManager.broadcast() method.""" - - @pytest.mark.asyncio - async def test_broadcast_no_project_id_backward_compat(self, connection_manager, mock_websockets): - """Broadcast to all connections without project_id (backward compatible).""" - # Setup - for ws in mock_websockets[:2]: - ws.send_json = AsyncMock() - ws.accept = AsyncMock() - - mock_ws1, mock_ws2 = mock_websockets[:2] - - # Connect both - await connection_manager.connect(mock_ws1) - await connection_manager.connect(mock_ws2) - - # Broadcast without project_id - message = {"type": "test", "data": "broadcast"} - await connection_manager.broadcast(message) - - # Both should receive - mock_ws1.send_json.assert_called_once_with(message) - mock_ws2.send_json.assert_called_once_with(message) - - @pytest.mark.asyncio - async def test_broadcast_with_project_id_subscribed(self, connection_manager, mock_websockets): - """Filtered broadcast to subscribers of project.""" - # Setup - for ws in mock_websockets: - ws.send_json = AsyncMock() - ws.accept = AsyncMock() - - mock_ws1, mock_ws2, mock_ws3 = mock_websockets - - # Connect all - await connection_manager.connect(mock_ws1) - await connection_manager.connect(mock_ws2) - await connection_manager.connect(mock_ws3) - - # Subscribe specific websockets to project 1 - await connection_manager.subscription_manager.subscribe(mock_ws1, 1) - await connection_manager.subscription_manager.subscribe(mock_ws2, 1) - await connection_manager.subscription_manager.subscribe(mock_ws3, 2) - - # Broadcast to project 1 - message = {"type": "test", "project_id": 1} - await connection_manager.broadcast(message, project_id=1) - - # Only subscribers to project 1 should receive - mock_ws1.send_json.assert_called_once_with(message) - mock_ws2.send_json.assert_called_once_with(message) - mock_ws3.send_json.assert_not_called() - - @pytest.mark.asyncio - async def test_broadcast_with_project_id_no_subscribers(self, connection_manager, mock_websocket): - """Broadcast with project_id but no subscribers.""" - # Connect but don't subscribe - await connection_manager.connect(mock_websocket) - - # Broadcast to project with no subscribers - message = {"type": "test", "project_id": 999} - await connection_manager.broadcast(message, project_id=999) - - # Should not receive - mock_websocket.send_json.assert_not_called() - - @pytest.mark.asyncio - async def test_broadcast_mixed_subscriptions(self, connection_manager, mock_websockets): - """Some connections subscribed, some not (project-based filtering).""" - for ws in mock_websockets: - ws.send_json = AsyncMock() - ws.accept = AsyncMock() - - mock_ws1, mock_ws2, mock_ws3 = mock_websockets - - # Connect all - await connection_manager.connect(mock_ws1) - await connection_manager.connect(mock_ws2) - await connection_manager.connect(mock_ws3) - - # Mix of subscriptions - await connection_manager.subscription_manager.subscribe(mock_ws1, 1) - await connection_manager.subscription_manager.subscribe(mock_ws1, 2) - await connection_manager.subscription_manager.subscribe(mock_ws2, 1) - # mock_ws3 not subscribed to anything - - # Broadcast to project 1 - message = {"type": "update", "project_id": 1} - await connection_manager.broadcast(message, project_id=1) - - # Only subscribers to project 1 - mock_ws1.send_json.assert_called_once_with(message) - mock_ws2.send_json.assert_called_once_with(message) - mock_ws3.send_json.assert_not_called() - - @pytest.mark.asyncio - async def test_broadcast_handles_send_error(self, connection_manager, mock_websockets): - """Broadcast handles send errors gracefully.""" - for ws in mock_websockets[:2]: - ws.accept = AsyncMock() - ws.close = AsyncMock() - - mock_ws1, mock_ws2 = mock_websockets[:2] - mock_ws1.send_json = AsyncMock(side_effect=Exception("Send failed")) - mock_ws2.send_json = AsyncMock() - - await connection_manager.connect(mock_ws1) - await connection_manager.connect(mock_ws2) - - # Broadcast without project_id (to all) - message = {"type": "test"} - await connection_manager.broadcast(message) - - # Both tried to send - mock_ws1.send_json.assert_called_once() - mock_ws2.send_json.assert_called_once() - - # Error client should be disconnected - assert mock_ws1 not in connection_manager.active_connections - - -# ============================================================================ -# ConnectionManager Tests - Lifecycle -# ============================================================================ - - -class TestConnectionManagerLifecycle: - """Tests for ConnectionManager connect/disconnect.""" - - @pytest.mark.asyncio - async def test_disconnect_cleans_up_subscriptions(self, connection_manager, mock_websocket): - """Disconnect calls subscription cleanup.""" - # Connect and subscribe - await connection_manager.connect(mock_websocket) - await connection_manager.subscription_manager.subscribe(mock_websocket, 1) - - # Verify subscribed - subscriptions = await connection_manager.subscription_manager.get_subscriptions( - mock_websocket - ) - assert len(subscriptions) == 1 - - # Disconnect - await connection_manager.disconnect(mock_websocket) - - # Should be cleaned up - subscriptions = await connection_manager.subscription_manager.get_subscriptions( - mock_websocket - ) - assert len(subscriptions) == 0 - - @pytest.mark.asyncio - async def test_disconnect_removes_from_active(self, connection_manager, mock_websocket): - """Disconnect removes from active connections.""" - await connection_manager.connect(mock_websocket) - assert len(connection_manager.active_connections) == 1 - - await connection_manager.disconnect(mock_websocket) - assert len(connection_manager.active_connections) == 0 - - @pytest.mark.asyncio - async def test_disconnect_idempotent(self, connection_manager, mock_websocket): - """Disconnect can be called multiple times safely.""" - await connection_manager.connect(mock_websocket) - - # Disconnect twice - await connection_manager.disconnect(mock_websocket) - await connection_manager.disconnect(mock_websocket) # Should not raise - - assert len(connection_manager.active_connections) == 0 - - -# ============================================================================ -# Concurrency and Thread Safety Tests -# ============================================================================ - - -class TestConcurrency: - """Tests for concurrent operations and thread safety.""" - - @pytest.mark.asyncio - async def test_concurrent_subscribe_unsubscribe(self, subscription_manager, mock_websocket): - """Concurrent subscribe/unsubscribe operations are safe.""" - projects = list(range(1, 11)) # 10 projects - - async def subscribe_all(): - for project_id in projects: - await subscription_manager.subscribe(mock_websocket, project_id) - - async def unsubscribe_all(): - for project_id in projects: - await subscription_manager.unsubscribe(mock_websocket, project_id) - - # Run concurrently - await asyncio.gather(subscribe_all(), unsubscribe_all()) - - # Should end in consistent state - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - # Depending on interleaving, might have some subscriptions left - # but shouldn't crash or corrupt state - assert isinstance(subscriptions, set) - - @pytest.mark.asyncio - async def test_concurrent_broadcast_to_same_project(self, connection_manager, mock_websocket): - """Concurrent broadcasts to same project don't corrupt state.""" - await connection_manager.connect(mock_websocket) - await connection_manager.subscription_manager.subscribe(mock_websocket, 1) - - # Broadcast concurrently - messages = [{"type": "msg", "num": i} for i in range(10)] - await asyncio.gather( - *[connection_manager.broadcast(msg, project_id=1) for msg in messages] - ) - - # All messages should be sent - assert mock_websocket.send_json.call_count == 10 - - @pytest.mark.asyncio - async def test_concurrent_connect_disconnect(self, connection_manager): - """Concurrent connect/disconnect operations are safe.""" - mock_sockets = [MagicMock() for _ in range(5)] - for ws in mock_sockets: - ws.send_json = AsyncMock() - ws.accept = AsyncMock() - ws.close = AsyncMock() - - async def connect_disconnect(ws): - await connection_manager.connect(ws) - await asyncio.sleep(0.001) # Small delay - await connection_manager.disconnect(ws) - - # Connect and disconnect concurrently - await asyncio.gather(*[connect_disconnect(ws) for ws in mock_sockets]) - - # Should end with no active connections - assert len(connection_manager.active_connections) == 0 - - @pytest.mark.asyncio - async def test_multiple_websockets_concurrent_operations(self, subscription_manager): - """Multiple websockets doing concurrent operations are safe.""" - sockets = [MagicMock() for _ in range(3)] - - async def socket_operations(ws, start_project): - projects = range(start_project, start_project + 5) - for project_id in projects: - await subscription_manager.subscribe(ws, project_id) - # Interleave other operations - await asyncio.sleep(0.001) - if project_id % 2 == 0: - await subscription_manager.unsubscribe(ws, project_id) - - # All sockets doing operations concurrently - await asyncio.gather( - socket_operations(sockets[0], 1), - socket_operations(sockets[1], 6), - socket_operations(sockets[2], 11), - ) - - # All should have consistent state - for ws in sockets: - subscriptions = await subscription_manager.get_subscriptions(ws) - assert isinstance(subscriptions, set) - # State is valid, no corruption - - -# ============================================================================ -# Edge Cases -# ============================================================================ - - -class TestEdgeCases: - """Tests for edge cases and error conditions.""" - - @pytest.mark.asyncio - async def test_subscribe_after_disconnect(self, subscription_manager, mock_websocket): - """Can subscribe to websocket after cleanup/disconnect.""" - project_id = 1 - - # Subscribe and cleanup - await subscription_manager.subscribe(mock_websocket, project_id) - await subscription_manager.cleanup(mock_websocket) - - # Should be able to subscribe again - project_id = 2 - await subscription_manager.subscribe(mock_websocket, project_id) - - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert project_id in subscriptions - - @pytest.mark.asyncio - async def test_large_number_of_subscriptions(self, subscription_manager, mock_websocket): - """Handle large number of project subscriptions.""" - projects = list(range(1, 101)) # 100 projects - - for project_id in projects: - await subscription_manager.subscribe(mock_websocket, project_id) - - subscriptions = await subscription_manager.get_subscriptions(mock_websocket) - assert len(subscriptions) == 100 - - # Get subscribers for each project - for project_id in projects: - subscribers = await subscription_manager.get_subscribers(project_id) - assert len(subscribers) == 1 - assert mock_websocket in subscribers - - @pytest.mark.asyncio - async def test_large_number_of_websockets(self, subscription_manager): - """Handle large number of websockets.""" - sockets = [MagicMock() for _ in range(50)] - project_id = 1 - - for ws in sockets: - await subscription_manager.subscribe(ws, project_id) - - # Get subscribers - subscribers = await subscription_manager.get_subscribers(project_id) - assert len(subscribers) == 50 - - for ws in sockets: - assert ws in subscribers - - @pytest.mark.asyncio - async def test_broadcast_with_empty_message(self, connection_manager, mock_websocket): - """Broadcast with empty message.""" - await connection_manager.connect(mock_websocket) - await connection_manager.subscription_manager.subscribe(mock_websocket, 1) - - # Broadcast empty message - message = {} - await connection_manager.broadcast(message, project_id=1) - - mock_websocket.send_json.assert_called_once_with(message) - - @pytest.mark.asyncio - async def test_broadcast_with_large_message(self, connection_manager, mock_websocket): - """Broadcast with large message payload.""" - await connection_manager.connect(mock_websocket) - await connection_manager.subscription_manager.subscribe(mock_websocket, 1) - - # Large message (1MB of data) - large_data = "x" * (1024 * 1024) - message = {"type": "large", "data": large_data} - - await connection_manager.broadcast(message, project_id=1) - - mock_websocket.send_json.assert_called_once() - - @pytest.mark.asyncio - async def test_get_subscribers_order_independent( - self, subscription_manager, mock_websockets - ): - """get_subscribers() returns correct set regardless of subscription order.""" - project_id = 1 - - # Subscribe in different orders - await subscription_manager.subscribe(mock_websockets[0], project_id) - await subscription_manager.subscribe(mock_websockets[1], project_id) - await subscription_manager.subscribe(mock_websockets[2], project_id) - - subscribers1 = await subscription_manager.get_subscribers(project_id) - - # Cleanup and resubscribe in different order - await subscription_manager.cleanup(mock_websockets[0]) - await subscription_manager.cleanup(mock_websockets[1]) - await subscription_manager.cleanup(mock_websockets[2]) - - await subscription_manager.subscribe(mock_websockets[2], project_id) - await subscription_manager.subscribe(mock_websockets[0], project_id) - await subscription_manager.subscribe(mock_websockets[1], project_id) - - subscribers2 = await subscription_manager.get_subscribers(project_id) - - # Should have same subscribers - assert set(subscribers1) == set(subscribers2) - - @pytest.mark.asyncio - async def test_broadcast_error_cleanup_is_atomic(self, connection_manager, mock_websockets): - """Broadcast error handling doesn't leave partial state.""" - for ws in mock_websockets[:2]: - ws.accept = AsyncMock() - ws.close = AsyncMock() - - mock_ws1, mock_ws2 = mock_websockets[:2] - mock_ws1.send_json = AsyncMock(side_effect=Exception("Error")) - mock_ws2.send_json = AsyncMock() - - await connection_manager.connect(mock_ws1) - await connection_manager.connect(mock_ws2) - - # Broadcast to both - message = {"type": "test"} - await connection_manager.broadcast(message) - - # ws1 should be disconnected despite error - assert mock_ws1 not in connection_manager.active_connections - # ws2 should still be connected - assert mock_ws2 in connection_manager.active_connections - - @pytest.mark.asyncio - async def test_subscription_manager_state_consistency( - self, subscription_manager, mock_websockets - ): - """Subscription state remains consistent through complex operations.""" - # Perform complex sequence of operations - await subscription_manager.subscribe(mock_websockets[0], 1) - await subscription_manager.subscribe(mock_websockets[0], 2) - await subscription_manager.subscribe(mock_websockets[1], 1) - - # Get snapshots - subs_0 = await subscription_manager.get_subscriptions(mock_websockets[0]) - subs_1 = await subscription_manager.get_subscriptions(mock_websockets[1]) - subs_p1 = await subscription_manager.get_subscribers(1) - subs_p2 = await subscription_manager.get_subscribers(2) - - # Verify consistency - assert 1 in subs_0 and 2 in subs_0 - assert 1 in subs_1 and 2 not in subs_1 - assert mock_websockets[0] in subs_p1 and mock_websockets[1] in subs_p1 - assert mock_websockets[0] in subs_p2 and mock_websockets[1] not in subs_p2 - - -# ============================================================================ -# Integration Tests -# ============================================================================ - - -class TestIntegration: - """Integration tests combining multiple components.""" - - @pytest.mark.asyncio - async def test_full_subscription_lifecycle(self, connection_manager, mock_websocket): - """Full lifecycle: connect, subscribe, broadcast, unsubscribe, disconnect.""" - # Connect - await connection_manager.connect(mock_websocket) - assert len(connection_manager.active_connections) == 1 - - # Subscribe - await connection_manager.subscription_manager.subscribe(mock_websocket, 1) - subscriptions = await connection_manager.subscription_manager.get_subscriptions( - mock_websocket - ) - assert 1 in subscriptions - - # Broadcast - should receive - await connection_manager.broadcast({"msg": "hello"}, project_id=1) - assert mock_websocket.send_json.call_count == 1 - - # Unsubscribe - await connection_manager.subscription_manager.unsubscribe(mock_websocket, 1) - subscriptions = await connection_manager.subscription_manager.get_subscriptions( - mock_websocket - ) - assert 1 not in subscriptions - - # Broadcast - should not receive - mock_websocket.send_json.reset_mock() - await connection_manager.broadcast({"msg": "hello again"}, project_id=1) - mock_websocket.send_json.assert_not_called() - - # Disconnect - await connection_manager.disconnect(mock_websocket) - assert len(connection_manager.active_connections) == 0 - - @pytest.mark.asyncio - async def test_multi_agent_scenario(self, connection_manager): - """Simulate multi-agent scenario with multiple projects.""" - # Simulate 3 agents (websockets) working on 2 projects - agent_sockets = [MagicMock() for _ in range(3)] - for ws in agent_sockets: - ws.send_json = AsyncMock() - ws.accept = AsyncMock() - ws.close = AsyncMock() - - # Connect all agents - for ws in agent_sockets: - await connection_manager.connect(ws) - - # Agent 1: subscribed to project 1 - await connection_manager.subscription_manager.subscribe(agent_sockets[0], 1) - - # Agent 2: subscribed to project 1 and 2 - await connection_manager.subscription_manager.subscribe(agent_sockets[1], 1) - await connection_manager.subscription_manager.subscribe(agent_sockets[1], 2) - - # Agent 3: subscribed to project 2 - await connection_manager.subscription_manager.subscribe(agent_sockets[2], 2) - - # Broadcast to project 1 - await connection_manager.broadcast({"event": "task_update"}, project_id=1) - - # Agent 1 and 2 should receive, not 3 - assert agent_sockets[0].send_json.call_count == 1 - assert agent_sockets[1].send_json.call_count == 1 - assert agent_sockets[2].send_json.call_count == 0 - - # Reset and broadcast to project 2 - for ws in agent_sockets: - ws.send_json.reset_mock() - - await connection_manager.broadcast({"event": "blocker_created"}, project_id=2) - - # Agent 2 and 3 should receive, not 1 - assert agent_sockets[0].send_json.call_count == 0 - assert agent_sockets[1].send_json.call_count == 1 - assert agent_sockets[2].send_json.call_count == 1 diff --git a/uv.lock b/uv.lock index fd331018..9269cf97 100644 --- a/uv.lock +++ b/uv.lock @@ -583,6 +583,7 @@ dependencies = [ { name = "aiosqlite" }, { name = "anthropic" }, { name = "bandit" }, + { name = "bcrypt" }, { name = "cryptography" }, { name = "fastapi" }, { name = "fastapi-users", extra = ["sqlalchemy"] }, @@ -595,8 +596,6 @@ dependencies = [ { name = "keyring" }, { name = "mcp" }, { name = "openai" }, - { name = "passlib", extra = ["argon2"] }, - { name = "pyasn1" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, @@ -604,7 +603,6 @@ dependencies = [ { name = "pytest-asyncio" }, { name = "pytest-json-report" }, { name = "python-dotenv" }, - { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, { name = "pyyaml" }, { name = "radon" }, @@ -658,6 +656,7 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.19.0" }, { name = "anthropic", specifier = ">=0.18.0" }, { name = "bandit", specifier = ">=1.8.6" }, + { name = "bcrypt", specifier = ">=4.2.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=26.3.1" }, { name = "cryptography", specifier = ">=46.0.7" }, { name = "e2b", marker = "extra == 'cloud'", specifier = ">=2.0.0" }, @@ -676,9 +675,7 @@ requires-dist = [ { name = "mcp", specifier = ">=1.23.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, { name = "openai", specifier = ">=1.12.0" }, - { name = "passlib", extras = ["argon2"], specifier = ">=1.7.4" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, - { name = "pyasn1", specifier = ">=0.6.3" }, { name = "pydantic", specifier = ">=2.6.0" }, { name = "pydantic-settings", specifier = ">=2.1.0" }, { name = "pyjwt", specifier = ">=2.12.0" }, @@ -691,7 +688,6 @@ requires-dist = [ { name = "pytest-json-report", marker = "extra == 'dev'", specifier = ">=1.5.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.3.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, - { name = "python-jose", extras = ["cryptography"], specifier = ">=3.4.0" }, { name = "python-multipart", specifier = ">=0.0.27" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "radon", specifier = ">=6.0.1" }, @@ -955,18 +951,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/ce/e402e2ecebe40ed9af20cddb862386f2ce20336e35c0dea257812129020e/e2b-2.20.0-py3-none-any.whl", hash = "sha256:66f6edcf6b742ca180f3aadcff7966fda86d68430fa6b2becdfa0fcc72224988", size = 296483, upload-time = "2026-04-02T19:20:30.573Z" }, ] -[[package]] -name = "ecdsa" -version = "0.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, -] - [[package]] name = "email-validator" version = "2.3.0" @@ -1336,7 +1320,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1909,20 +1893,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "passlib" -version = "1.7.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, -] - -[package.optional-dependencies] -argon2 = [ - { name = "argon2-cffi" }, -] - [[package]] name = "pathspec" version = "1.1.1" @@ -2097,15 +2067,6 @@ bcrypt = [ { name = "bcrypt" }, ] -[[package]] -name = "pyasn1" -version = "0.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, -] - [[package]] name = "pycparser" version = "2.23" @@ -2361,25 +2322,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] -[[package]] -name = "python-jose" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ecdsa" }, - { name = "pyasn1" }, - { name = "rsa" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, -] - -[package.optional-dependencies] -cryptography = [ - { name = "cryptography" }, -] - [[package]] name = "python-multipart" version = "0.0.32" @@ -2840,18 +2782,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/f4/09ffb3ebd0cbb9e2c7c9b84d252557ecf434cd71584ee1e32f66013824df/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:f7728653900035fb7b8d06e1e5900545d8088efc9d5d4545782da7df03ec803f", size = 564054, upload-time = "2025-11-16T14:50:37.733Z" }, ] -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, -] - [[package]] name = "ruff" version = "0.14.0"