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