mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 18:25:26 -04:00
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.
This commit is contained in:
+10
-1
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
+25
-10
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -1913,7 +1913,7 @@
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Change Password</h2>
|
||||
<div class="settings-col">
|
||||
<input id="settings-pw-current" type="password" placeholder="Current password" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<input id="settings-pw-new" type="password" placeholder="New password (min 8)" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<input id="settings-pw-new" type="password" placeholder="New password" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<input id="settings-pw-confirm" type="password" placeholder="Confirm new password" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<div class="settings-row" style="margin-top:2px;justify-content:flex-end;">
|
||||
<span id="settings-pw-msg" style="font-size:11px;margin-right:auto;"></span>
|
||||
@@ -2049,7 +2049,7 @@
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>Add User</h2>
|
||||
<div class="admin-add-form">
|
||||
<input id="adm-newUsername" type="text" placeholder="Username">
|
||||
<input id="adm-newPassword" type="password" placeholder="Password (min 8)">
|
||||
<input id="adm-newPassword" type="password" placeholder="Password">
|
||||
<div class="admin-switch-inline" title="Grant full admin access"><label class="admin-switch"><input type="checkbox" id="adm-newIsAdmin"><span class="admin-slider"></span></label> Admin</div>
|
||||
</div>
|
||||
<div class="settings-row" style="margin-top:6px;">
|
||||
|
||||
+12
-1
@@ -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 }) });
|
||||
|
||||
+12
-1
@@ -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 {
|
||||
|
||||
+18
-5
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user