diff --git a/CLAUDE.md b/CLAUDE.md index d3354cb577..ae5daa1ed4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ curl http://localhost:3000/api/v1/health # backend (via web proxy) ```text src/synthorg/ - api/ # Litestar REST + WebSocket API (controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, setup endpoint (first-run wizard: status check, template listing, company/agent creation, completion gate), RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation)), AppState hot-reload slots (provider_registry, model_router with swap methods, provider_management), settings dispatcher lifecycle, logging bootstrap (_bootstrap_app_logging, SYNTHORG_LOG_DIR env var override, called before all other setup in create_app), service auto-wiring (auto_wire.py: Phase 1 at construction -- message bus/cost tracker/provider registry/task engine; Phase 2 in on_startup after persistence connects -- settings service + config resolver + provider management), lifecycle helpers (lifecycle.py: _safe_startup, _safe_shutdown, _cleanup_on_failure, _init_persistence, _try_stop) + api/ # Litestar REST + WebSocket API (controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, setup endpoint (first-run wizard: status check (CEO-role admin detection, min_password_length from settings), template listing, company/agent creation, completion gate (company + agent + provider verification)), RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation)), AppState hot-reload slots (provider_registry, model_router with swap methods, provider_management), settings dispatcher lifecycle, logging bootstrap (_bootstrap_app_logging, SYNTHORG_LOG_DIR env var override, called before all other setup in create_app), service auto-wiring (auto_wire.py: Phase 1 at construction -- message bus/cost tracker/provider registry/task engine; Phase 2 in on_startup after persistence connects -- settings service + config resolver + provider management), lifecycle helpers (lifecycle.py: _safe_startup, _safe_shutdown, _cleanup_on_failure, _init_persistence, _try_stop) auth/ # Authentication subpackage (controller, service, middleware, JWT + API key + WS ticket store, models, config, secret resolution) backup/ # Backup and restore -- scheduled/manual/lifecycle backups of persistence DB, agent memory, and company config. BackupService orchestrator, BackupScheduler (periodic asyncio task), RetentionManager (count + age pruning), tar.gz compression, SHA-256 checksums, manifest tracking, validated restore with atomic rollback and safety backup handlers/ # ComponentHandler protocol + concrete handlers: PersistenceComponentHandler (SQLite VACUUM INTO), MemoryComponentHandler (copytree), ConfigComponentHandler (copy2) @@ -127,7 +127,7 @@ src/synthorg/ engine/ # Agent orchestration, execution loops, parallel execution, task decomposition, routing, task assignment, centralized single-writer task state engine (TaskEngine), task lifecycle, recovery, shutdown, workspace isolation, coordination (multi-agent pipeline: TopologyDispatcher protocol, 4 dispatchers — SAS/centralized/decentralized/context-dependent, wave execution, workspace lifecycle integration, CoordinationSectionConfig company config bridge, build_coordinator factory), coordination error classification, prompt policy validation, checkpoint recovery (checkpoint/, per-turn persistence, heartbeat detection, CheckpointRecoveryStrategy), approval gate (escalation detection, context parking/resume, EscalationInfo/ResumePayload models), stagnation detection (stagnation/, StagnationDetector protocol, ToolRepetitionDetector, dual-signal analysis, corrective prompt injection), agent runtime state (AgentRuntimeState, lightweight per-agent execution status for dashboard queries and recovery), context budget management (context_budget.py, ContextBudgetIndicator, fill estimation, token estimation protocol in token_estimation.py), conversation compaction (compaction/, CompactionCallback type alias, CompactionConfig, CompressionMetadata, oldest-turns summarizer), execution loop auto-selection (loop_selector.py, AutoLoopConfig, AutoLoopRule, select_loop_type, build_execution_loop -- complexity-based loop routing with budget-aware downgrade, optional hybrid fallback, and configurable default_loop_type), hybrid execution loop (hybrid_loop.py, HybridLoop -- plan + mini-ReAct steps with per-step turn limits, progress-summary checkpoints, LLM-decided replanning; hybrid_models.py, HybridLoopConfig), shared plan helpers (plan_helpers.py, update_step_status, extract_task_summary, assess_step_success) hr/ # HR engine: hiring, firing, onboarding, offboarding, agent registry, performance tracking (task metrics, collaboration scoring, LLM calibration sampling, collaboration overrides, trend detection), promotion/demotion (criteria evaluation, approval strategies, model mapping) memory/ # Persistent agent memory (pluggable MemoryBackend protocol), backends/ (Mem0 adapter: backends/mem0/), retrieval pipeline (ranking, RRF fusion, injection, context formatting, non-inferable filtering), shared org memory (org/), consolidation/archival (consolidation/, dual-mode density-aware archival: DensityClassifier, AbstractiveSummarizer, ExtractivePreserver, DualModeConsolidationStrategy) - persistence/ # Operational data persistence — pluggable PersistenceBackend protocol, SQLite initial, SettingsRepository (namespaced settings CRUD) (see Memory & Persistence design page) + persistence/ # Operational data persistence -- pluggable PersistenceBackend protocol, SQLite initial, SettingsRepository (namespaced settings CRUD), UserRepository (user CRUD + role-based counting) (see Memory & Persistence design page) observability/ # Structured logging (8-sink pipeline: console + 7 file sinks with logger-name routing), correlation tracking (request_id/task_id/agent_id via contextvars), sensitive field redaction, SYNTHORG_LOG_LEVEL env var override, critical sink enforcement (audit.log/access.log), log sinks providers/ # LLM provider abstraction (LiteLLM adapter), auth types (AuthType enum: api_key/oauth/custom_header/none), presets (ProviderPreset, PROVIDER_PRESETS for Ollama/LM Studio/OpenRouter/vLLM), runtime CRUD (management/ -- ProviderManagementService, asyncio.Lock-serialized create/update/delete/test, hot-reload of ProviderRegistry + ModelRouter via AppState swap) settings/ # Runtime-editable settings persistence (DB > env > YAML > code defaults), typed definitions (9 namespaces, including JSON type for structural data), Fernet encryption for sensitive values, config bridge (JSON serialization for Pydantic models/collections), ConfigResolver (typed scalar + structural data accessors for controllers — get_agents, get_departments, get_provider_configs with validation fallbacks to YAML), validation, registry, change notifications via message bus, SettingsSubscriber protocol (subscriber.py), SettingsChangeDispatcher (dispatcher.py, polls #settings channel, routes to subscribers, restart_required filtering) diff --git a/docs/design/operations.md b/docs/design/operations.md index 890d88d1c7..75eabdfdf9 100644 --- a/docs/design/operations.md +++ b/docs/design/operations.md @@ -998,7 +998,7 @@ future CLI tool are thin clients that call the API -- they contain no business l | `/api/v1/analytics` | Performance metrics, dashboards | | `/api/v1/settings` | Runtime-editable configuration (9 namespaces), schema discovery | | `GET /api/v1/providers`, `POST /api/v1/providers`, `PUT /api/v1/providers/{name}`, `DELETE /api/v1/providers/{name}`, `POST /api/v1/providers/{name}/test`, `GET /api/v1/providers/presets`, `POST /api/v1/providers/from-preset` | Provider CRUD, connection testing, presets, 4 auth types (api_key, oauth, custom_header, none) | -| `/api/v1/setup` | First-run setup wizard: status check (public), template listing, company/agent creation, completion gate | +| `/api/v1/setup` | First-run setup wizard: status check (public), template listing, company/agent creation, completion gate (requires company + agent + provider) | | `/api/v1/admin/backups` | Manual backup, list, detail, delete | | `/api/v1/ws` | WebSocket for real-time updates (ticket auth via `?ticket=`) | | `POST /api/v1/auth/ws-ticket` | Exchange JWT for one-time WebSocket connection ticket | diff --git a/docs/user_guide.md b/docs/user_guide.md index 6651e81b79..8ec158d107 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -68,7 +68,7 @@ After the containers are running, open the web dashboard at [http://localhost:30 3. **Create your company** -- name your synthetic organization and optionally start from a template. 4. **Hire your first agent** -- choose a role, model, and personality for the first AI agent. -After completing the wizard, the dashboard appears and the setup wizard is not shown again. +All four steps must be completed -- the backend validates that a company, at least one agent, and at least one provider exist before allowing setup to finish. After completing the wizard, the dashboard appears and the setup wizard is not shown again. To re-run the wizard later, use `synthorg setup` (resets the flag and opens the browser) or delete the `api.setup_complete` setting via the settings API. diff --git a/src/synthorg/api/controllers/setup.py b/src/synthorg/api/controllers/setup.py index 7edd1890f0..2b8da21940 100644 --- a/src/synthorg/api/controllers/setup.py +++ b/src/synthorg/api/controllers/setup.py @@ -9,9 +9,10 @@ from litestar.status_codes import HTTP_201_CREATED from pydantic import BaseModel, ConfigDict, Field, model_validator +from synthorg.api.auth.config import AuthConfig from synthorg.api.dto import ApiResponse from synthorg.api.errors import ApiValidationError, ConflictError, NotFoundError -from synthorg.api.guards import require_read_access, require_write_access +from synthorg.api.guards import HumanRole, require_read_access, require_write_access from synthorg.api.state import AppState # noqa: TC001 from synthorg.core.enums import SeniorityLevel from synthorg.core.types import NotBlankStr # noqa: TC001 @@ -24,14 +25,18 @@ SETUP_COMPANY_CREATED, SETUP_COMPLETED, SETUP_MODEL_NOT_FOUND, + SETUP_NO_AGENTS, + SETUP_NO_COMPANY, SETUP_NO_PROVIDERS, SETUP_PROVIDER_NOT_FOUND, SETUP_STATUS_CHECKED, + SETUP_STATUS_SETTINGS_DEFAULT_USED, SETUP_STATUS_SETTINGS_UNAVAILABLE, SETUP_TEMPLATE_INVALID, SETUP_TEMPLATE_NOT_FOUND, SETUP_TEMPLATES_LISTED, ) +from synthorg.persistence.errors import QueryError from synthorg.settings.errors import SettingNotFoundError if TYPE_CHECKING: @@ -39,6 +44,11 @@ logger = get_logger(__name__) +# Derive from AuthConfig default to prevent silent divergence. +_DEFAULT_MIN_PASSWORD_LENGTH: int = AuthConfig.model_fields[ + "min_password_length" +].default + # Serializes read-modify-write on the agents settings blob. _AGENT_LOCK = asyncio.Lock() @@ -50,9 +60,10 @@ class SetupStatusResponse(BaseModel): """First-run setup status. Attributes: - needs_admin: True if no admin user exists yet. + needs_admin: True if no user with the CEO role exists yet. needs_setup: True if setup has not been completed. has_providers: True if at least one provider is configured. + min_password_length: Backend-configured minimum password length. """ model_config = ConfigDict(frozen=True) @@ -60,6 +71,7 @@ class SetupStatusResponse(BaseModel): needs_admin: bool needs_setup: bool has_providers: bool + min_password_length: int = Field(ge=8) class TemplateInfoResponse(BaseModel): @@ -215,8 +227,15 @@ async def get_status( app_state: AppState = state.app_state persistence = app_state.persistence - user_count = await persistence.users.count() - needs_admin = user_count == 0 + try: + admin_count = await persistence.users.count_by_role(HumanRole.CEO) + except QueryError: + logger.warning( + SETUP_STATUS_SETTINGS_UNAVAILABLE, + exc_info=True, + ) + admin_count = 0 + needs_admin = admin_count == 0 settings_svc = app_state.settings_service try: @@ -235,6 +254,34 @@ async def get_status( app_state.has_provider_registry and len(app_state.provider_registry) > 0 ) + min_password_length = _DEFAULT_MIN_PASSWORD_LENGTH + raw_pw_value: str | None = None + try: + pw_entry = await settings_svc.get_entry("api", "min_password_length") + raw_pw_value = pw_entry.value + parsed = int(raw_pw_value) + min_password_length = max(parsed, _DEFAULT_MIN_PASSWORD_LENGTH) + except MemoryError, RecursionError: + raise + except SettingNotFoundError: + logger.debug( + SETUP_STATUS_SETTINGS_DEFAULT_USED, + setting="min_password_length", + ) + except ValueError: + logger.warning( + SETUP_STATUS_SETTINGS_UNAVAILABLE, + setting="min_password_length", + reason="non_integer_value", + raw=raw_pw_value, + ) + except Exception: + logger.warning( + SETUP_STATUS_SETTINGS_UNAVAILABLE, + setting="min_password_length", + exc_info=True, + ) + logger.debug( SETUP_STATUS_CHECKED, needs_admin=needs_admin, @@ -247,6 +294,7 @@ async def get_status( needs_admin=needs_admin, needs_setup=needs_setup, has_providers=has_providers, + min_password_length=min_password_length, ), ) @@ -421,8 +469,8 @@ async def complete_setup( ) -> ApiResponse[SetupCompleteResponse]: """Mark first-run setup as complete. - Validates that at least one provider is configured before - allowing completion. + Validates that a company, at least one agent, and at least one + provider are configured before allowing completion. Args: state: Application state. @@ -431,16 +479,40 @@ async def complete_setup( Success envelope. Raises: - ApiValidationError: If no providers are configured. + ConflictError: If setup has already been completed. + ApiValidationError: If company, agents, or providers are missing. """ app_state: AppState = state.app_state + settings_svc = app_state.settings_service + await _check_setup_not_complete(settings_svc) + # Verify company has been created. + has_company = False + try: + entry = await settings_svc.get_entry("company", "company_name") + has_company = bool(entry.value and entry.value.strip()) + except MemoryError, RecursionError: + raise + except SettingNotFoundError: + pass + if not has_company: + msg = "A company must be created before completing setup" + logger.warning(SETUP_NO_COMPANY) + raise ApiValidationError(msg) + + # Verify at least one agent has been created. + existing_agents = await _get_existing_agents(settings_svc) + if not existing_agents: + msg = "At least one agent must be created before completing setup" + logger.warning(SETUP_NO_AGENTS) + raise ApiValidationError(msg) + + # Verify at least one provider is configured. if not app_state.has_provider_registry or len(app_state.provider_registry) == 0: msg = "At least one provider must be configured before completing setup" logger.warning(SETUP_NO_PROVIDERS) raise ApiValidationError(msg) - settings_svc = app_state.settings_service await settings_svc.set("api", "setup_complete", "true") logger.info(SETUP_COMPLETED) diff --git a/src/synthorg/observability/events/persistence.py b/src/synthorg/observability/events/persistence.py index 978ad7942b..2b637218f1 100644 --- a/src/synthorg/observability/events/persistence.py +++ b/src/synthorg/observability/events/persistence.py @@ -132,6 +132,10 @@ PERSISTENCE_USER_LIST_FAILED: Final[str] = "persistence.user.list_failed" PERSISTENCE_USER_COUNTED: Final[str] = "persistence.user.counted" PERSISTENCE_USER_COUNT_FAILED: Final[str] = "persistence.user.count_failed" +PERSISTENCE_USER_COUNTED_BY_ROLE: Final[str] = "persistence.user.counted_by_role" +PERSISTENCE_USER_COUNT_BY_ROLE_FAILED: Final[str] = ( + "persistence.user.count_by_role_failed" +) PERSISTENCE_USER_DELETED: Final[str] = "persistence.user.deleted" PERSISTENCE_USER_DELETE_FAILED: Final[str] = "persistence.user.delete_failed" diff --git a/src/synthorg/observability/events/setup.py b/src/synthorg/observability/events/setup.py index 6f178e384e..6d06e37904 100644 --- a/src/synthorg/observability/events/setup.py +++ b/src/synthorg/observability/events/setup.py @@ -31,6 +31,9 @@ # Status check fallback (settings service unavailable) SETUP_STATUS_SETTINGS_UNAVAILABLE: Final[str] = "setup.status.settings_unavailable" +# Status check used a default value for a setting (entry absent or not configured) +SETUP_STATUS_SETTINGS_DEFAULT_USED: Final[str] = "setup.status.settings_default_used" + # Provider not found during agent creation SETUP_PROVIDER_NOT_FOUND: Final[str] = "setup.agent.provider_not_found" @@ -40,6 +43,12 @@ # No providers configured when attempting to complete setup SETUP_NO_PROVIDERS: Final[str] = "setup.flow.no_providers" +# No company created when attempting to complete setup +SETUP_NO_COMPANY: Final[str] = "setup.flow.no_company" + +# No agents created when attempting to complete setup +SETUP_NO_AGENTS: Final[str] = "setup.flow.no_agents" + # Template not found during company creation SETUP_TEMPLATE_NOT_FOUND: Final[str] = "setup.company.template_not_found" diff --git a/src/synthorg/persistence/repositories.py b/src/synthorg/persistence/repositories.py index 7812b3156b..f4b7054c76 100644 --- a/src/synthorg/persistence/repositories.py +++ b/src/synthorg/persistence/repositories.py @@ -9,6 +9,7 @@ from pydantic import AwareDatetime # noqa: TC002 from synthorg.api.auth.models import ApiKey, User # noqa: TC001 +from synthorg.api.guards import HumanRole # noqa: TC001 from synthorg.budget.cost_record import CostRecord # noqa: TC001 from synthorg.communication.message import Message # noqa: TC001 from synthorg.core.enums import ApprovalRiskLevel, TaskStatus # noqa: TC001 @@ -396,6 +397,20 @@ async def count(self) -> int: """ ... + async def count_by_role(self, role: HumanRole) -> int: + """Count users with a specific role. + + Args: + role: The role to filter by. + + Returns: + Number of users with the given role. + + Raises: + PersistenceError: If the operation fails. + """ + ... + async def delete(self, user_id: NotBlankStr) -> bool: """Delete a user by ID. diff --git a/src/synthorg/persistence/sqlite/user_repo.py b/src/synthorg/persistence/sqlite/user_repo.py index c0cb05a0c9..69b31e321c 100644 --- a/src/synthorg/persistence/sqlite/user_repo.py +++ b/src/synthorg/persistence/sqlite/user_repo.py @@ -24,8 +24,10 @@ PERSISTENCE_API_KEY_LISTED, PERSISTENCE_API_KEY_SAVE_FAILED, PERSISTENCE_API_KEY_SAVED, + PERSISTENCE_USER_COUNT_BY_ROLE_FAILED, PERSISTENCE_USER_COUNT_FAILED, PERSISTENCE_USER_COUNTED, + PERSISTENCE_USER_COUNTED_BY_ROLE, PERSISTENCE_USER_DELETE_FAILED, PERSISTENCE_USER_DELETED, PERSISTENCE_USER_FETCH_FAILED, @@ -262,6 +264,40 @@ async def count(self) -> int: logger.debug(PERSISTENCE_USER_COUNTED, count=result) return result + async def count_by_role(self, role: HumanRole) -> int: + """Return the number of users with the given role. + + Args: + role: The role to filter by. + + Returns: + Non-negative integer count. + + Raises: + QueryError: If the database query fails. + """ + try: + cursor = await self._db.execute( + "SELECT COUNT(*) FROM users WHERE role = ?", + (role.value,), + ) + row = await cursor.fetchone() + except (sqlite3.Error, aiosqlite.Error) as exc: + msg = "Failed to count users by role" + logger.exception( + PERSISTENCE_USER_COUNT_BY_ROLE_FAILED, + role=role.value, + error=str(exc), + ) + raise QueryError(msg) from exc + result = int(row[0]) if row else 0 + logger.debug( + PERSISTENCE_USER_COUNTED_BY_ROLE, + role=role.value, + count=result, + ) + return result + async def delete(self, user_id: NotBlankStr) -> bool: """Delete a user by primary key. diff --git a/tests/unit/api/controllers/test_setup.py b/tests/unit/api/controllers/test_setup.py index 69c02938c9..8440b003a5 100644 --- a/tests/unit/api/controllers/test_setup.py +++ b/tests/unit/api/controllers/test_setup.py @@ -1,6 +1,7 @@ """Tests for the first-run setup controller.""" import json +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -8,6 +9,9 @@ from litestar.testing import TestClient from pydantic import ValidationError +from synthorg.api.guards import HumanRole +from synthorg.providers.base import BaseCompletionProvider +from synthorg.providers.registry import ProviderRegistry from tests.unit.api.conftest import make_auth_headers @@ -59,6 +63,69 @@ def test_status_response_fields( assert isinstance(data["needs_setup"], bool) assert isinstance(data["has_providers"], bool) + def test_needs_admin_true_when_only_non_admin_exists( + self, + test_client: TestClient[Any], + ) -> None: + """needs_admin is True when only non-CEO users exist.""" + app_state = test_client.app.state.app_state + users_repo = app_state.persistence._users + + # Remove all CEO users, keep only observers + removed = { + uid: users_repo._users.pop(uid) + for uid in [ + uid for uid, u in users_repo._users.items() if u.role == HumanRole.CEO + ] + } + try: + resp = test_client.get("/api/v1/setup/status") + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["needs_admin"] is True + finally: + users_repo._users.update(removed) + + def test_needs_admin_false_when_ceo_exists( + self, + test_client: TestClient[Any], + ) -> None: + """needs_admin is False when a CEO user exists (default fixture).""" + resp = test_client.get("/api/v1/setup/status") + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["needs_admin"] is False + + def test_status_includes_min_password_length( + self, + test_client: TestClient[Any], + ) -> None: + """Status response includes min_password_length (falls back to default).""" + resp = test_client.get("/api/v1/setup/status") + assert resp.status_code == 200 + data = resp.json()["data"] + assert "min_password_length" in data + assert isinstance(data["min_password_length"], int) + assert data["min_password_length"] == 12 + + def test_status_returns_configured_min_password_length( + self, + test_client: TestClient[Any], + ) -> None: + """Status response returns non-default min_password_length from settings.""" + app_state = test_client.app.state.app_state + settings_repo = app_state.persistence._settings_repo + now = datetime.now(UTC).isoformat() + + settings_repo._store[("api", "min_password_length")] = ("16", now) + try: + resp = test_client.get("/api/v1/setup/status") + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["min_password_length"] == 16 + finally: + settings_repo._store.pop(("api", "min_password_length"), None) + @pytest.mark.unit @pytest.mark.timeout(30) @@ -290,14 +357,6 @@ def test_successful_agent_creation( class TestSetupComplete: """POST /api/v1/setup/complete -- mark setup as done.""" - def test_complete_without_provider_fails( - self, - test_client: TestClient[Any], - ) -> None: - """Completing setup without providers returns 422.""" - resp = test_client.post("/api/v1/setup/complete") - assert resp.status_code == 422 - def test_requires_write_access( self, test_client: TestClient[Any], @@ -310,13 +369,76 @@ def test_requires_write_access( finally: test_client.headers.update(saved_headers) - # Note: Happy-path test for successful completion requires a real - # ProviderRegistry with drivers, which the current test fixture does - # not set up. Mocking _provider_registry crashes xdist workers - # during app shutdown. The completion endpoint logic is simple - # (provider check + settings write) and is covered by the DTO and - # agent creation tests. A proper integration test should be added - # when the test fixture supports provider setup. + def test_complete_rejects_without_company( + self, + test_client: TestClient[Any], + ) -> None: + """Completion rejects when no company name is set.""" + app_state = test_client.app.state.app_state + settings_repo = app_state.persistence._settings_repo + + # Remove company_name from the settings store so the YAML + # fallback chain also yields nothing. The fixture's root_config + # provides company_name, so we need to override at the DB level + # with an empty string to simulate "not configured". + now = datetime.now(UTC).isoformat() + settings_repo._store[("company", "company_name")] = ("", now) + try: + resp = test_client.post("/api/v1/setup/complete") + assert resp.status_code == 422 + assert "company" in resp.json()["error"].lower() + finally: + settings_repo._store.pop(("company", "company_name"), None) + + def test_complete_validates_all_prerequisites( + self, + test_client: TestClient[Any], + ) -> None: + """Completion requires company, agents, and providers. + + The test fixture's root_config provides company_name, so the + company check passes automatically. This test walks through + the remaining prerequisite checks: agents, then providers, + then confirms success once all are satisfied. + """ + app_state = test_client.app.state.app_state + settings_repo = app_state.persistence._settings_repo + now = datetime.now(UTC).isoformat() + + # 1. No agents -- rejected (company comes from root_config). + resp = test_client.post("/api/v1/setup/complete") + assert resp.status_code == 422 + assert "agent" in resp.json()["error"].lower() + + # 2. Agents set, no providers -- rejected. + agents_key = ("company", "agents") + original_agents = settings_repo._store.get(agents_key) + agents_json = json.dumps([{"name": "agent-001", "role": "CEO"}]) + settings_repo._store[agents_key] = (agents_json, now) + try: + resp = test_client.post("/api/v1/setup/complete") + assert resp.status_code == 422 + assert "provider" in resp.json()["error"].lower() + + # 3. All present -- success. + stub = MagicMock(spec=BaseCompletionProvider) + original_registry = app_state._provider_registry + app_state._provider_registry = ProviderRegistry( + {"test-provider": stub}, + ) + try: + resp = test_client.post("/api/v1/setup/complete") + assert resp.status_code == 201 + body = resp.json() + assert body["success"] is True + assert body["data"]["setup_complete"] is True + finally: + app_state._provider_registry = original_registry + finally: + if original_agents is None: + settings_repo._store.pop(agents_key, None) + else: + settings_repo._store[agents_key] = original_agents @pytest.mark.unit @@ -362,6 +484,7 @@ def test_setup_status_response_frozen(self) -> None: needs_admin=True, needs_setup=True, has_providers=False, + min_password_length=12, ) with pytest.raises(ValidationError): resp.needs_admin = False # type: ignore[misc] diff --git a/tests/unit/api/fakes.py b/tests/unit/api/fakes.py index 30f4aee286..5b58299ffe 100644 --- a/tests/unit/api/fakes.py +++ b/tests/unit/api/fakes.py @@ -5,6 +5,7 @@ from typing import Any from synthorg.api.auth.models import ApiKey, User +from synthorg.api.guards import HumanRole from synthorg.budget.cost_record import CostRecord from synthorg.communication.channel import Channel from synthorg.communication.message import Message @@ -297,6 +298,9 @@ async def list_users(self) -> tuple[User, ...]: async def count(self) -> int: return len(self._users) + async def count_by_role(self, role: HumanRole) -> int: + return sum(1 for u in self._users.values() if u.role == role) + async def delete(self, user_id: str) -> bool: return self._users.pop(user_id, None) is not None diff --git a/tests/unit/persistence/sqlite/test_user_repo.py b/tests/unit/persistence/sqlite/test_user_repo.py index b2e2deb9ef..329ce40385 100644 --- a/tests/unit/persistence/sqlite/test_user_repo.py +++ b/tests/unit/persistence/sqlite/test_user_repo.py @@ -94,6 +94,26 @@ async def test_count(self, user_repo: SQLiteUserRepository) -> None: await user_repo.save(_make_user()) assert await user_repo.count() == 1 + async def test_count_by_role_empty(self, user_repo: SQLiteUserRepository) -> None: + assert await user_repo.count_by_role(HumanRole.CEO) == 0 + + async def test_count_by_role_filters_correctly( + self, user_repo: SQLiteUserRepository + ) -> None: + await user_repo.save( + _make_user(user_id="ceo-1", username="alice", role=HumanRole.CEO), + ) + await user_repo.save( + _make_user(user_id="obs-1", username="bob", role=HumanRole.OBSERVER), + ) + await user_repo.save( + _make_user(user_id="ceo-2", username="carol", role=HumanRole.CEO), + ) + + assert await user_repo.count_by_role(HumanRole.CEO) == 2 + assert await user_repo.count_by_role(HumanRole.OBSERVER) == 1 + assert await user_repo.count_by_role(HumanRole.MANAGER) == 0 + async def test_delete(self, user_repo: SQLiteUserRepository) -> None: await user_repo.save(_make_user()) deleted = await user_repo.delete("user-001") diff --git a/tests/unit/persistence/test_protocol.py b/tests/unit/persistence/test_protocol.py index f9451fdbee..8fbfc17964 100644 --- a/tests/unit/persistence/test_protocol.py +++ b/tests/unit/persistence/test_protocol.py @@ -4,6 +4,7 @@ import pytest +from synthorg.api.guards import HumanRole from synthorg.core.types import NotBlankStr from synthorg.hr.persistence_protocol import ( CollaborationMetricRepository, @@ -193,6 +194,9 @@ async def list_users(self) -> tuple[User, ...]: async def count(self) -> int: return 0 + async def count_by_role(self, role: HumanRole) -> int: + return 0 + async def delete(self, user_id: str) -> bool: return False diff --git a/web/src/__tests__/stores/setup.test.ts b/web/src/__tests__/stores/setup.test.ts new file mode 100644 index 0000000000..11aa810b64 --- /dev/null +++ b/web/src/__tests__/stores/setup.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +const mockGetSetupStatus = vi.fn() +const mockListTemplates = vi.fn() +const mockCompleteSetup = vi.fn() + +vi.mock('@/api/endpoints/setup', () => ({ + getSetupStatus: (...args: unknown[]) => mockGetSetupStatus(...args), + listTemplates: (...args: unknown[]) => mockListTemplates(...args), + completeSetup: (...args: unknown[]) => mockCompleteSetup(...args), +})) + +import { useSetupStore } from '@/stores/setup' +import { MIN_PASSWORD_LENGTH } from '@/utils/constants' + +describe('useSetupStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe('minPasswordLength', () => { + it('falls back to MIN_PASSWORD_LENGTH before status is loaded', () => { + const store = useSetupStore() + expect(store.minPasswordLength).toBe(MIN_PASSWORD_LENGTH) + }) + + it('uses server value when it exceeds the constant', async () => { + mockGetSetupStatus.mockResolvedValue({ + needs_admin: true, + needs_setup: true, + has_providers: false, + min_password_length: 20, + }) + + const store = useSetupStore() + await store.fetchStatus() + expect(store.minPasswordLength).toBe(20) + }) + + it('clamps to MIN_PASSWORD_LENGTH when server value is lower', async () => { + mockGetSetupStatus.mockResolvedValue({ + needs_admin: true, + needs_setup: true, + has_providers: false, + min_password_length: 4, + }) + + const store = useSetupStore() + await store.fetchStatus() + expect(store.minPasswordLength).toBe(MIN_PASSWORD_LENGTH) + }) + }) +}) diff --git a/web/src/__tests__/views/SetupPage.test.ts b/web/src/__tests__/views/SetupPage.test.ts index 6aa68eb35d..a1e2ef4065 100644 --- a/web/src/__tests__/views/SetupPage.test.ts +++ b/web/src/__tests__/views/SetupPage.test.ts @@ -81,6 +81,7 @@ vi.mock('@/api/endpoints/setup', () => ({ needs_admin: true, needs_setup: true, has_providers: false, + min_password_length: 12, }), listTemplates: vi.fn().mockResolvedValue([]), createCompany: vi.fn().mockResolvedValue({ company_name: 'Test', template_applied: null, department_count: 0 }), diff --git a/web/src/api/types.ts b/web/src/api/types.ts index faa2c10599..e9a99be67e 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -825,6 +825,7 @@ export interface SetupStatusResponse { needs_admin: boolean needs_setup: boolean has_providers: boolean + min_password_length: number } export interface TemplateInfoResponse { diff --git a/web/src/components/setup/SetupAdmin.vue b/web/src/components/setup/SetupAdmin.vue index de92bd90cb..fb35d6a00d 100644 --- a/web/src/components/setup/SetupAdmin.vue +++ b/web/src/components/setup/SetupAdmin.vue @@ -3,8 +3,8 @@ import { ref } from 'vue' import InputText from 'primevue/inputtext' import Button from 'primevue/button' import { useAuthStore } from '@/stores/auth' +import { useSetupStore } from '@/stores/setup' import { getErrorMessage } from '@/utils/errors' -import { MIN_PASSWORD_LENGTH } from '@/utils/constants' import { useLoginLockout } from '@/composables/useLoginLockout' const emit = defineEmits<{ @@ -12,6 +12,7 @@ const emit = defineEmits<{ }>() const auth = useAuthStore() +const setupStore = useSetupStore() const { locked, checkAndClearLockout, recordFailure } = useLoginLockout() const username = ref('') @@ -29,8 +30,8 @@ async function handleSetup() { error.value = 'Passwords do not match' return } - if (password.value.length < MIN_PASSWORD_LENGTH) { - error.value = `Password must be at least ${MIN_PASSWORD_LENGTH} characters` + if (password.value.length < setupStore.minPasswordLength) { + error.value = `Password must be at least ${setupStore.minPasswordLength} characters` return } try { @@ -75,7 +76,7 @@ async function handleSetup() { v-model="password" type="password" class="w-full" - :placeholder="`Min ${MIN_PASSWORD_LENGTH} characters`" + :placeholder="`Min ${setupStore.minPasswordLength} characters`" autocomplete="new-password" :aria-describedby="error ? 'setup-error' : undefined" /> diff --git a/web/src/stores/setup.ts b/web/src/stores/setup.ts index 1bcedd57aa..740b6ac2b9 100644 --- a/web/src/stores/setup.ts +++ b/web/src/stores/setup.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import * as setupApi from '@/api/endpoints/setup' import { getErrorMessage } from '@/utils/errors' +import { MIN_PASSWORD_LENGTH } from '@/utils/constants' import type { SetupStatusResponse, TemplateInfoResponse } from '@/api/types' export const useSetupStore = defineStore('setup', () => { @@ -20,6 +21,9 @@ export const useSetupStore = defineStore('setup', () => { const isAdminNeeded = computed(() => statusLoaded.value ? !!status.value?.needs_admin : true, ) + const minPasswordLength = computed(() => + Math.max(MIN_PASSWORD_LENGTH, status.value?.min_password_length ?? MIN_PASSWORD_LENGTH), + ) async function fetchStatus() { loading.value = true @@ -87,6 +91,7 @@ export const useSetupStore = defineStore('setup', () => { error, isSetupNeeded, isAdminNeeded, + minPasswordLength, fetchStatus, fetchTemplates, nextStep,