From ee72d718724d2ec960e505004bd5a94b6f781a36 Mon Sep 17 00:00:00 2001 From: Karl Jussila Date: Tue, 16 Jun 2026 02:52:15 -0500 Subject: [PATCH] fix(auth): centralize password and username validation constants (#4120) Added PASSWORD_MIN_LENGTH and RESERVED_USERNAMES to src/constants.py as the single source of truth. Previously PASSWORD_MIN_LENGTH was hardcoded as 8 in four route handlers and all three JS validation paths; RESERVED_USERNAMES was an inline frozenset duplicated in core/auth.py, routes/assistant_routes.py, routes/research_routes.py, and src/task_scheduler.py. Added GET /api/auth/policy (unauthenticated) so the frontend reads the real values from the server instead of hardcoding them in JS. Added missing empty-username guard to /setup and admin POST /users. Both returned a misleading 500/409 on whitespace-only input. /signup already had the check; this makes all three consistent. --- core/auth.py | 11 +- routes/assistant_routes.py | 5 +- routes/auth_routes.py | 35 ++++-- routes/research_routes.py | 3 +- setup.py | 26 ++++- src/constants.py | 3 + src/task_scheduler.py | 4 +- static/index.html | 4 +- static/js/admin.js | 13 ++- static/js/settings.js | 13 ++- static/login.html | 23 +++- tests/test_auth_policy.py | 217 +++++++++++++++++++++++++++++++++++++ 12 files changed, 327 insertions(+), 30 deletions(-) create mode 100644 tests/test_auth_policy.py diff --git a/core/auth.py b/core/auth.py index 665344eb3..adad33b80 100644 --- a/core/auth.py +++ b/core/auth.py @@ -47,7 +47,7 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False # backwards for this sentinel. ADMIN_PRIVILEGES["block_all_models"] = False -from src.constants import AUTH_FILE +from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH DEFAULT_AUTH_PATH = AUTH_FILE TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days @@ -243,6 +243,15 @@ class AuthManager: def is_configured(self) -> bool: return len(self.users) > 0 + def policy(self) -> dict: + """Return public auth policy constants for the frontend.""" + return { + "password_min_length": PASSWORD_MIN_LENGTH, + "reserved_usernames": sorted(RESERVED_USERNAMES), + "signup_enabled": self.signup_enabled, + "session_days": TOKEN_TTL // 86400, + } + # ------------------------------------------------------------------ # Account management # ------------------------------------------------------------------ diff --git a/routes/assistant_routes.py b/routes/assistant_routes.py index 17c50163d..0b609e37f 100644 --- a/routes/assistant_routes.py +++ b/routes/assistant_routes.py @@ -16,6 +16,7 @@ from pydantic import BaseModel from core.database import SessionLocal, CrewMember, ScheduledTask from src.auth_helpers import get_current_user +from core.auth import RESERVED_USERNAMES from src.task_scheduler import compute_next_run @@ -89,11 +90,11 @@ def setup_assistant_routes(task_scheduler) -> APIRouter: # check-in tasks seeded. Hitting any /assistant route under one of these # used to seed a full CrewMember + Morning/Midday/Evening tasks under that # owner, which then double-fired alongside the real user's check-ins. - _SYNTHETIC_OWNERS = frozenset({"internal-tool", "api", "demo", "system", ""}) + # RESERVED_USERNAMES covers the same set; the `not owner` guard handles "". async def _get_or_create(owner: str) -> CrewMember: """Return the per-owner assistant CrewMember, creating it on demand.""" - if not owner or owner in _SYNTHETIC_OWNERS: + if not owner or owner in RESERVED_USERNAMES: raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}") db = SessionLocal() try: diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 15b8270a2..f180f44e9 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -12,8 +12,8 @@ import re from pathlib import Path from core.atomic_io import atomic_write_json, atomic_write_text -from core.auth import AuthManager, SetAdminResult -from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, SKILLS_DIR +from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult +from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR from src.rate_limiter import RateLimiter from src.settings_scrub import scrub_settings from src.settings import ( @@ -102,8 +102,12 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: raise HTTPException(429, "Too many requests — try again later") if auth_manager.is_configured: raise HTTPException(400, "Already configured") - if len(body.password) < 8: - raise HTTPException(400, "Password must be at least 8 characters") + if len(body.password) < PASSWORD_MIN_LENGTH: + raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters") + if len(body.username.strip()) < 1: + raise HTTPException(400, "Username is required") + if body.username.lower() in RESERVED_USERNAMES: + raise HTTPException(403, "Username is reserved") ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password) if not ok: raise HTTPException(500, "Setup failed") @@ -118,10 +122,12 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: raise HTTPException(400, "Run setup first") if not auth_manager.signup_enabled: raise HTTPException(403, "Registration is disabled. Ask an admin for an account.") - if len(body.password) < 8: - raise HTTPException(400, "Password must be at least 8 characters") + if len(body.password) < PASSWORD_MIN_LENGTH: + raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters") if len(body.username.strip()) < 1: raise HTTPException(400, "Username is required") + if body.username.lower() in RESERVED_USERNAMES: + raise HTTPException(403, "Username is reserved") ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False) if not ok: raise HTTPException(409, "Username already taken") @@ -184,13 +190,18 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: pass return result + @router.get("/policy") + async def auth_policy(): + """Return public auth policy constants for the frontend.""" + return auth_manager.policy() + @router.post("/change-password") async def change_password(body: ChangePasswordRequest, request: Request): user = _get_current_user(request) if not user: raise HTTPException(401, "Not authenticated") - if len(body.new_password) < 8: - raise HTTPException(400, "Password must be at least 8 characters") + if len(body.new_password) < PASSWORD_MIN_LENGTH: + raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters") current_token = request.cookies.get(SESSION_COOKIE) ok = await asyncio.to_thread(auth_manager.change_password, user, body.current_password, body.new_password) if not ok: @@ -270,8 +281,12 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") - if len(body.password) < 8: - raise HTTPException(400, "Password must be at least 8 characters") + if len(body.password) < PASSWORD_MIN_LENGTH: + raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters") + if len(body.username.strip()) < 1: + raise HTTPException(400, "Username is required") + if body.username.lower() in RESERVED_USERNAMES: + raise HTTPException(403, "Username is reserved") ok = auth_manager.create_user(body.username, body.password, body.is_admin) if not ok: raise HTTPException(409, "Username already taken") diff --git a/routes/research_routes.py b/routes/research_routes.py index 1ef36bd75..947789e5c 100644 --- a/routes/research_routes.py +++ b/routes/research_routes.py @@ -14,6 +14,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse from pydantic import BaseModel, Field from src.endpoint_resolver import resolve_endpoint from src.auth_helpers import _auth_disabled, get_current_user +from core.auth import RESERVED_USERNAMES from src.constants import DEEP_RESEARCH_DIR _SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$") @@ -387,7 +388,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter: user = require_privilege(request, "can_use_research") if user == "internal-tool": tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip() - if tool_owner and tool_owner not in {"internal-tool", "api", "demo", "system"}: + if tool_owner and tool_owner not in RESERVED_USERNAMES: auth_mgr = getattr(request.app.state, "auth_manager", None) if auth_mgr is not None and getattr(auth_mgr, "is_configured", False): try: diff --git a/setup.py b/setup.py index 81fcc87ab..a9c565282 100644 --- a/setup.py +++ b/setup.py @@ -16,8 +16,9 @@ sys.path.insert(0, BASE_DIR) from src.constants import ( DATA_DIR, AUTH_FILE, UPLOAD_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR, TTS_CACHE_DIR, GENERATED_IMAGES_DIR, DEEP_RESEARCH_DIR, CHROMA_DIR, - RAG_DIR, MEMORY_VECTORS_DIR, + RAG_DIR, MEMORY_VECTORS_DIR, PASSWORD_MIN_LENGTH, ) +from core.auth import RESERVED_USERNAMES DIRS = [ DATA_DIR, @@ -59,15 +60,23 @@ def _prompt_admin_credentials(): print(" (Press Enter to accept defaults)") print() - username = input(" Username [admin]: ").strip().lower() - if not username: - username = "admin" + while True: + username = input(" Username [admin]: ").strip().lower() + if not username: + username = "admin" + if username in RESERVED_USERNAMES: + print(f" '{username}' is a reserved username. Choose another.") + continue + break while True: password = getpass.getpass(" Password: ") if not password: print(" Password cannot be empty.") continue + if len(password) < PASSWORD_MIN_LENGTH: + print(f" Password must be at least {PASSWORD_MIN_LENGTH} characters.") + continue confirm = getpass.getpass(" Confirm password: ") if password != confirm: print(" Passwords don't match. Try again.") @@ -93,8 +102,13 @@ def create_default_admin(): password = os.getenv("ODYSSEUS_ADMIN_PASSWORD", "").strip() if username and password: - # Both provided via env — use them directly - pass + # Both provided via env — validate before using + if username in RESERVED_USERNAMES: + print(f" [error] ODYSSEUS_ADMIN_USER '{username}' is a reserved username") + return "failed" + if len(password) < PASSWORD_MIN_LENGTH: + print(f" [error] ODYSSEUS_ADMIN_PASSWORD must be at least {PASSWORD_MIN_LENGTH} characters") + return "failed" elif sys.stdin.isatty() and not os.getenv("ODYSSEUS_SKIP_ADMIN_PROMPT"): # Interactive terminal — ask the user username, password = _prompt_admin_credentials() diff --git a/src/constants.py b/src/constants.py index 40efbe73a..b76f5d97b 100644 --- a/src/constants.py +++ b/src/constants.py @@ -102,6 +102,9 @@ SEARXNG_INSTANCE = os.getenv("SEARXNG_INSTANCE", "http://localhost:8080") CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "True").lower() == "true" CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24")) +# Auth policy +PASSWORD_MIN_LENGTH = 8 + # Default parameters DEFAULT_TEMPERATURE = 1.0 DEFAULT_MAX_TOKENS = 0 diff --git a/src/task_scheduler.py b/src/task_scheduler.py index b84632e43..3e6e4f93a 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -9,6 +9,8 @@ import uuid from datetime import datetime, timedelta, timezone from typing import Any, Awaitable, Callable, Dict, Tuple +from core.auth import RESERVED_USERNAMES + logger = logging.getLogger(__name__) @@ -2221,7 +2223,7 @@ class TaskScheduler: # check-ins seeded, which then double-fire alongside the human user's # check-ins. This was the root cause of the duplicate 'Morning check-in' # rows we had to manually clean up. - if not owner or owner in {"internal-tool", "api", "demo", "system"}: + if not owner or owner in RESERVED_USERNAMES: logger.info(f"ensure_assistant_defaults: skip synthetic owner {owner!r}") return from core.database import SessionLocal, CrewMember, ScheduledTask diff --git a/static/index.html b/static/index.html index 89b0ebb34..fe22d6b9e 100644 --- a/static/index.html +++ b/static/index.html @@ -1913,7 +1913,7 @@

Change Password

- +
@@ -2049,7 +2049,7 @@

Add User

- +
Admin
diff --git a/static/js/admin.js b/static/js/admin.js index 61458f1c6..bd63e10db 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -13,6 +13,7 @@ let modalEl = null; // the endpoints list can flash a glow on that row. Cleared once the // animation fires. let _recentlyAddedEpId = null; +let _authPolicy = { password_min_length: 8, reserved_usernames: [] }; function el(id) { return document.getElementById(id); } function esc(s) { return uiModule.esc(s); } @@ -343,6 +344,15 @@ function initSignupToggle() { } function initAddUser() { + fetch('/api/auth/policy', { credentials: 'same-origin' }) + .then(r => r.ok ? r.json() : null) + .then(policy => { + if (!policy) return; + _authPolicy = policy; + const admPw = el('adm-newPassword'); + if (admPw) admPw.placeholder = `Password (min ${policy.password_min_length})`; + }) + .catch(() => {}); el('adm-addBtn').addEventListener('click', async () => { const msg = el('adm-addMsg'); msg.textContent = ''; msg.className = ''; @@ -350,7 +360,8 @@ function initAddUser() { const password = el('adm-newPassword').value; const is_admin = el('adm-newIsAdmin').checked; if (!username) { msg.textContent = 'Username required'; msg.className = 'admin-error'; return; } - if (password.length < 8) { msg.textContent = 'Password must be at least 8 characters'; msg.className = 'admin-error'; return; } + if (password.length < _authPolicy.password_min_length) { msg.textContent = `Password must be at least ${_authPolicy.password_min_length} characters`; msg.className = 'admin-error'; return; } + if (_authPolicy.reserved_usernames.includes(username.toLowerCase())) { msg.textContent = 'This username is reserved'; msg.className = 'admin-error'; return; } el('adm-addBtn').disabled = true; try { const res = await fetch('/api/auth/users', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, is_admin }) }); diff --git a/static/js/settings.js b/static/js/settings.js index 09cc5505d..1dbe7f6d7 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -11,6 +11,7 @@ import { isAltGrEvent } from './platform.js'; let initialized = false; let modalEl = null; +let _authPolicy = { password_min_length: 8 }; function el(id) { return document.getElementById(id); } function esc(s) { return uiModule.esc(s); } @@ -2160,6 +2161,16 @@ function initAccount() { } }).catch(() => {}); + // Update password placeholder and policy from server + fetch('/api/auth/policy', { credentials: 'same-origin' }) + .then(r => r.ok ? r.json() : null) + .then(policy => { + if (!policy) return; + _authPolicy = policy; + const pwNew = el('settings-pw-new'); + if (pwNew) pwNew.placeholder = `New password (min ${policy.password_min_length})`; + }).catch(() => {}); + // Change password const saveBtn = el('settings-pw-save'); const msgEl = el('settings-pw-msg'); @@ -2170,7 +2181,7 @@ function initAccount() { const conf = el('settings-pw-confirm').value; msgEl.style.color = ''; if (!cur || !nw) { msgEl.textContent = 'Fill in all fields'; msgEl.style.color = 'var(--red)'; return; } - if (nw.length < 8) { msgEl.textContent = 'Min 8 characters'; msgEl.style.color = 'var(--red)'; return; } + if (nw.length < _authPolicy.password_min_length) { msgEl.textContent = `Min ${_authPolicy.password_min_length} characters`; msgEl.style.color = 'var(--red)'; return; } if (nw !== conf) { msgEl.textContent = 'Passwords don\'t match'; msgEl.style.color = 'var(--red)'; return; } saveBtn.disabled = true; try { diff --git a/static/login.html b/static/login.html index 1bfc639b1..eeece7cc3 100644 --- a/static/login.html +++ b/static/login.html @@ -328,6 +328,7 @@ let mode = 'login'; // 'login' | 'signup' | 'setup' let signupAllowed = false; + let policy = { password_min_length: 8, reserved_usernames: [] }; const rememberToggle = document.getElementById('rememberToggle'); @@ -360,10 +361,12 @@ } } - // Check auth status + // Check auth status and fetch policy in parallel, but don't block the + // authenticated redirect on the policy response. + const policyPromise = fetch('/api/auth/policy', { credentials: 'same-origin' }).catch(() => null); try { - const res = await fetch('/api/auth/status', { credentials: 'same-origin' }); - const data = await res.json(); + const statusRes = await fetch('/api/auth/status', { credentials: 'same-origin' }); + const data = await statusRes.json(); if (data.authenticated) { window.location.replace('/'); return; @@ -374,6 +377,10 @@ } else { setMode('login'); } + const policyRes = await policyPromise; + if (policyRes && policyRes.ok) { + policy = await policyRes.json(); + } } catch (e) { setMode('login'); } @@ -426,8 +433,14 @@ submitBtn.disabled = false; return; } - if (password.length < 8) { - errEl.textContent = 'Password must be at least 8 characters'; + if (password.length < policy.password_min_length) { + errEl.textContent = `Password must be at least ${policy.password_min_length} characters`; + errEl.style.display = 'block'; + submitBtn.disabled = false; + return; + } + if (policy.reserved_usernames.includes(username.toLowerCase())) { + errEl.textContent = 'This username is reserved'; errEl.style.display = 'block'; submitBtn.disabled = false; return; diff --git a/tests/test_auth_policy.py b/tests/test_auth_policy.py new file mode 100644 index 000000000..89b0e7dd9 --- /dev/null +++ b/tests/test_auth_policy.py @@ -0,0 +1,217 @@ +"""Tests for auth policy endpoint and password length validation.""" + +import asyncio +import importlib +import sys +import types +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + +from tests.helpers.import_state import clear_module + + +def _real_core_package(): + root = Path(__file__).resolve().parent.parent + core_path = str(root / "core") + core = sys.modules.get("core") + if core is None: + core = types.ModuleType("core") + sys.modules["core"] = core + core.__path__ = [core_path] + clear_module("core.auth") + return core + + +def _auth_module(): + _real_core_package() + return importlib.import_module("core.auth") + + +def _make_manager(tmp_path): + auth_mod = _auth_module() + auth_mod._hash_password = lambda password: f"hash:{password}" + auth_mod._verify_password = lambda password, hashed: hashed == f"hash:{password}" + auth_path = tmp_path / "auth.json" + mgr = auth_mod.AuthManager(str(auth_path)) + return mgr + + +async def _immediate_to_thread(fn, *args, **kwargs): + return fn(*args, **kwargs) + + +# ── AuthManager.policy() ─────────────────────────────────────────────── + + +def test_policy_returns_password_min_length(tmp_path): + mgr = _make_manager(tmp_path) + policy = mgr.policy() + assert policy["password_min_length"] == 8 + + +def test_policy_returns_reserved_usernames(tmp_path): + mgr = _make_manager(tmp_path) + policy = mgr.policy() + assert "internal-tool" in policy["reserved_usernames"] + assert "api" in policy["reserved_usernames"] + assert "demo" in policy["reserved_usernames"] + assert "system" in policy["reserved_usernames"] + assert isinstance(policy["reserved_usernames"], list) + + +def test_policy_returns_signup_enabled(tmp_path): + mgr = _make_manager(tmp_path) + policy = mgr.policy() + assert policy["signup_enabled"] is False # default + + +def test_policy_returns_session_days(tmp_path): + mgr = _make_manager(tmp_path) + policy = mgr.policy() + assert policy["session_days"] == 7 + + +# ── GET /api/auth/policy endpoint ────────────────────────────────────── + + +def _policy_endpoint(auth_manager): + sys.modules.pop("routes.auth_routes", None) + _real_core_package() + from routes.auth_routes import setup_auth_routes + + router = setup_auth_routes(auth_manager) + for route in router.routes: + if getattr(route, "path", None) == "/api/auth/policy": + return route.endpoint + raise AssertionError("policy route not found") + + +def test_policy_endpoint_returns_dict(tmp_path): + mgr = _make_manager(tmp_path) + endpoint = _policy_endpoint(mgr) + result = asyncio.run(endpoint()) + assert isinstance(result, dict) + assert "password_min_length" in result + assert "reserved_usernames" in result + assert "signup_enabled" in result + assert "session_days" in result + + +def test_policy_endpoint_values_match_manager(tmp_path): + mgr = _make_manager(tmp_path) + endpoint = _policy_endpoint(mgr) + result = asyncio.run(endpoint()) + assert result == mgr.policy() + + +# ── Password length validation ───────────────────────────────────────── + + +def _setup_endpoint(auth_manager): + sys.modules.pop("routes.auth_routes", None) + _real_core_package() + from routes.auth_routes import SetupRequest, setup_auth_routes + + router = setup_auth_routes(auth_manager) + for route in router.routes: + if getattr(route, "path", None) == "/api/auth/setup": + return route.endpoint, SetupRequest + raise AssertionError("setup route not found") + + +def _signup_endpoint(auth_manager): + sys.modules.pop("routes.auth_routes", None) + _real_core_package() + from routes.auth_routes import SignupRequest, setup_auth_routes + + router = setup_auth_routes(auth_manager) + for route in router.routes: + if getattr(route, "path", None) == "/api/auth/signup": + return route.endpoint, SignupRequest + raise AssertionError("signup route not found") + + +def _change_password_endpoint(auth_manager): + sys.modules.pop("routes.auth_routes", None) + _real_core_package() + from routes.auth_routes import ChangePasswordRequest, setup_auth_routes + + router = setup_auth_routes(auth_manager) + for route in router.routes: + if getattr(route, "path", None) == "/api/auth/change-password": + return route.endpoint, ChangePasswordRequest + raise AssertionError("change-password route not found") + + +def test_setup_rejects_short_password(tmp_path): + mgr = _make_manager(tmp_path) + endpoint, SetupRequest = _setup_endpoint(mgr) + request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1")) + body = SetupRequest(username="admin", password="short") + + with pytest.raises(HTTPException) as exc: + asyncio.run(endpoint(body=body, request=request)) + + assert exc.value.status_code == 400 + assert "8 characters" in exc.value.detail + + +def test_signup_rejects_short_password(tmp_path): + mgr = _make_manager(tmp_path) + mgr.create_user("admin", "admin-password", is_admin=True) + mgr.signup_enabled = True + endpoint, SignupRequest = _signup_endpoint(mgr) + request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1")) + body = SignupRequest(username="newuser", password="short") + + with pytest.raises(HTTPException) as exc: + asyncio.run(endpoint(body=body, request=request)) + + assert exc.value.status_code == 400 + assert "8 characters" in exc.value.detail + + +def test_change_password_rejects_short_password(tmp_path): + mgr = _make_manager(tmp_path) + mgr.create_user("alice", "old-password", is_admin=False) + endpoint, ChangePasswordRequest = _change_password_endpoint(mgr) + request = SimpleNamespace( + cookies={"odysseus_session": "current-token"}, + client=SimpleNamespace(host="127.0.0.1"), + ) + # Mock get_username_for_token to return alice + mgr.get_username_for_token = MagicMock(return_value="alice") + body = ChangePasswordRequest(current_password="old-password", new_password="short") + + with pytest.raises(HTTPException) as exc: + asyncio.run(endpoint(body=body, request=request)) + + assert exc.value.status_code == 400 + assert "8 characters" in exc.value.detail + + +def test_setup_accepts_exactly_min_length_password(tmp_path): + mgr = _make_manager(tmp_path) + endpoint, SetupRequest = _setup_endpoint(mgr) + request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1")) + body = SetupRequest(username="admin", password="12345678") + + result = asyncio.run(endpoint(body=body, request=request)) + + assert result == {"ok": True, "message": "Admin account created"} + + +def test_setup_rejects_seven_char_password(tmp_path): + mgr = _make_manager(tmp_path) + endpoint, SetupRequest = _setup_endpoint(mgr) + request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1")) + body = SetupRequest(username="admin", password="1234567") + + with pytest.raises(HTTPException) as exc: + asyncio.run(endpoint(body=body, request=request)) + + assert exc.value.status_code == 400