diff --git a/server/routers/setup.py b/server/routers/setup.py index ff79539..bb2a257 100644 --- a/server/routers/setup.py +++ b/server/routers/setup.py @@ -3,21 +3,24 @@ Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404. NOT protected by API Key auth (standalone access). """ +import random import secrets +from datetime import datetime, timedelta from pathlib import Path from fastapi import APIRouter, HTTPException, Request, status from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel -from sqlalchemy import inspect as sa_inspect +from sqlalchemy import inspect as sa_inspect, select from config import settings from database import Base, engine, async_session_factory from models import ( - User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, + User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, ) from services.auth_service import hash_password +from services.measurement_service import calculate_pass_fail router = APIRouter(prefix="/api/setup", tags=["setup"]) @@ -46,6 +49,28 @@ class ResetDbBody(BaseModel): confirm: bool +class ManageUserBody(BaseModel): + password: str # setup password + user_id: int | None = None # None = create, int = update + username: str + user_password: str | None = None # Required for create, optional for update + display_name: str + email: str | None = None + roles: list[str] = [] + is_admin: bool = False + + +class ChangeUserPasswordBody(BaseModel): + password: str # setup password + user_id: int + new_password: str + + +class ToggleUserActiveBody(BaseModel): + password: str # setup password + user_id: int + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -66,6 +91,76 @@ def _check_password(password: str) -> None: ) +def _generate_measurements_for_subtask( + subtask: RecipeSubtask, + version_id: int, + measured_by: int, + lot_numbers: list[str], + serial_numbers: list[str], + base_date: datetime, + count: int = 30, +) -> list[Measurement]: + """Generate realistic demo measurements with Gaussian distribution.""" + nominal = float(subtask.nominal) + uwl = float(subtask.uwl) + ltl = float(subtask.ltl) + utl = float(subtask.utl) + lwl = float(subtask.lwl) + + sigma = (uwl - nominal) / 2.5 + + measurements = [] + for i in range(count): + # ~80% pass, ~13% warning, ~7% fail + r = random.random() + if r < 0.07: + # Fail: outside tolerance limits + if random.random() < 0.5: + value = random.uniform(utl, utl + (utl - nominal)) + else: + value = random.uniform(ltl - (nominal - ltl), ltl) + elif r < 0.20: + # Warning: between warning and tolerance + if random.random() < 0.5: + value = random.uniform(uwl, utl) + else: + value = random.uniform(ltl, lwl) + else: + # Pass: Gaussian around nominal + value = random.gauss(nominal, sigma * 0.7) + # Clamp to within warning limits + value = max(lwl, min(uwl, value)) + + value = round(value, 6) + + pass_fail, deviation = calculate_pass_fail(value, subtask) + + # Distribute across time, lots, serials + days_offset = random.uniform(0, 30) + hours_offset = random.uniform(8, 17) # working hours + measured_at = base_date - timedelta(days=days_offset) + timedelta(hours=hours_offset) + + lot = lot_numbers[i % len(lot_numbers)] + serial = serial_numbers[i % len(serial_numbers)] + input_method = "usb_caliper" if random.random() < 0.7 else "manual" + + m = Measurement( + subtask_id=subtask.id, + version_id=version_id, + measured_by=measured_by, + value=value, + pass_fail=pass_fail, + deviation=deviation, + lot_number=lot, + serial_number=serial, + input_method=input_method, + measured_at=measured_at, + ) + measurements.append(m) + + return measurements + + # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @@ -312,14 +407,188 @@ async def seed_demo_data(body: PasswordBody): ] session.add_all(subtasks_2) + # ---- Measurements ------------------------------------------------- + await session.flush() # Get subtask IDs + + random.seed(42) # Reproducible demo data + + tec_user = created_users[2] # tec1 + lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"] + serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)] + base_date = datetime.now() + + all_subtasks = subtasks_1 + subtasks_2 + measurements_created = 0 + for st in all_subtasks: + ms = _generate_measurements_for_subtask( + subtask=st, + version_id=version.id, + measured_by=tec_user.id, + lot_numbers=lot_numbers, + serial_numbers=serial_numbers, + base_date=base_date, + count=30, + ) + session.add_all(ms) + measurements_created += len(ms) + return { "status": "ok", "message": "Demo data seeded successfully", "users_created": [u["username"] for u in users_data], "recipe_created": "DEMO-001", + "measurements_created": measurements_created, } +@router.post("/list-users") +async def list_users(body: PasswordBody): + """List all users.""" + _check_password(body.password) + + async with async_session_factory() as session: + result = await session.execute(select(User).order_by(User.id)) + users = result.scalars().all() + return { + "status": "ok", + "users": [ + { + "id": u.id, + "username": u.username, + "display_name": u.display_name, + "email": u.email, + "roles": u.roles or [], + "is_admin": u.is_admin, + "active": u.active, + "created_at": u.created_at.isoformat() if u.created_at else None, + "last_login": u.last_login.isoformat() if u.last_login else None, + } + for u in users + ], + } + + +@router.post("/manage-user") +async def manage_user(body: ManageUserBody): + """Create or update a user. user_id=null creates new, user_id=int updates.""" + _check_password(body.password) + + VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist"} + invalid = set(body.roles) - VALID_ROLES + if invalid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid roles: {', '.join(invalid)}", + ) + + async with async_session_factory() as session: + async with session.begin(): + if body.user_id is None: + # Create + if not body.user_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password is required when creating a new user", + ) + # Check username uniqueness + existing = await session.execute( + select(User).where(User.username == body.username) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Username '{body.username}' already exists", + ) + user = User( + username=body.username, + password_hash=hash_password(body.user_password), + display_name=body.display_name, + email=body.email, + roles=body.roles, + is_admin=body.is_admin, + api_key=secrets.token_hex(32), + ) + session.add(user) + await session.flush() + return {"status": "ok", "message": f"User '{body.username}' created", "user_id": user.id} + else: + # Update + result = await session.execute( + select(User).where(User.id == body.user_id) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User ID {body.user_id} not found", + ) + # Check username uniqueness (exclude self) + existing = await session.execute( + select(User).where( + User.username == body.username, User.id != body.user_id + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Username '{body.username}' already taken", + ) + user.username = body.username + user.display_name = body.display_name + user.email = body.email + user.roles = body.roles + user.is_admin = body.is_admin + if body.user_password: + user.password_hash = hash_password(body.user_password) + return {"status": "ok", "message": f"User '{body.username}' updated", "user_id": user.id} + + +@router.post("/change-user-password") +async def change_user_password(body: ChangeUserPasswordBody): + """Change a user's password and invalidate their API key.""" + _check_password(body.password) + + async with async_session_factory() as session: + async with session.begin(): + result = await session.execute( + select(User).where(User.id == body.user_id) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User ID {body.user_id} not found", + ) + user.password_hash = hash_password(body.new_password) + user.api_key = None # Invalidate sessions + + return {"status": "ok", "message": f"Password changed for '{user.username}'"} + + +@router.post("/toggle-user-active") +async def toggle_user_active(body: ToggleUserActiveBody): + """Toggle a user's active status. Invalidates API key when deactivated.""" + _check_password(body.password) + + async with async_session_factory() as session: + async with session.begin(): + result = await session.execute( + select(User).where(User.id == body.user_id) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User ID {body.user_id} not found", + ) + user.active = not user.active + if not user.active: + user.api_key = None # Invalidate sessions on deactivation + new_status = "active" if user.active else "inactive" + + return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active} + + @router.post("/reset-db") async def reset_db(body: ResetDbBody): """Drop all tables and recreate them. Requires confirm=true.""" diff --git a/server/templates/setup/setup.html b/server/templates/setup/setup.html index 38342d8..9bee60a 100644 --- a/server/templates/setup/setup.html +++ b/server/templates/setup/setup.html @@ -224,6 +224,123 @@ .mt-4 { margin-top: 16px; } .text-sm { font-size: 0.82rem; } .text-muted { color: #94a3b8; } + + /* --- User Management ----------------------------------------------- */ + .user-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin-top: 12px; + } + .user-table th { + text-align: left; + padding: 8px 10px; + background: #f8fafc; + color: #475569; + font-weight: 600; + border-bottom: 2px solid #e2e8f0; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.03em; + } + .user-table td { + padding: 8px 10px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + } + .user-table tr:hover td { background: #f8fafc; } + + .badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + } + .badge-maker { background: #dbeafe; color: #1d4ed8; } + .badge-measurement { background: #dcfce7; color: #166534; } + .badge-metrologist { background: #fef3c7; color: #92400e; } + .badge-admin { background: #fce7f3; color: #9d174d; } + .badge-active { background: #dcfce7; color: #166534; } + .badge-inactive { background: #fee2e2; color: #991b1b; } + + .btn-sm { + padding: 4px 10px; + font-size: 0.75rem; + border-radius: 6px; + border: none; + cursor: pointer; + font-weight: 500; + font-family: inherit; + transition: background .15s; + } + .btn-sm-primary { background: #2563eb; color: #fff; } + .btn-sm-primary:hover { background: #1d4ed8; } + .btn-sm-warning { background: #f59e0b; color: #fff; } + .btn-sm-warning:hover { background: #d97706; } + .btn-sm-danger { background: #ef4444; color: #fff; } + .btn-sm-danger:hover { background: #dc2626; } + .btn-sm-success { background: #10b981; color: #fff; } + .btn-sm-success:hover { background: #059669; } + + .action-btns { display: flex; gap: 4px; flex-wrap: wrap; } + + /* --- Modal --------------------------------------------------------- */ + .modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,.4); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeInOverlay .2s ease-out; + } + @keyframes fadeInOverlay { from { opacity: 0; } to { opacity: 1; } } + + .modal { + background: #fff; + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0,0,0,.2); + padding: 24px; + width: 90%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + animation: scaleIn .2s ease-out; + } + @keyframes scaleIn { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } } + + .modal h2 { margin-bottom: 16px; } + .modal .btn { margin-top: 8px; } + + .modal-actions { + display: flex; + gap: 8px; + margin-top: 16px; + } + .modal-actions .btn { flex: 1; } + + /* --- Checkbox group ------------------------------------------------ */ + .checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 14px; + } + .checkbox-group label { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + margin-bottom: 0; + cursor: pointer; + } + .checkbox-group input[type="checkbox"] { + width: 16px; height: 16px; + accent-color: #2563eb; + } @@ -300,14 +417,26 @@

Seed Demo Data

- Populate the database with sample users and a demo recipe. + Populate the database with sample users, a demo recipe, and 180 measurements for SPC testing. Creates users: admin, maker1, tec1, metro1.

- + +
+

User Management

+

+ Create, edit, and manage user accounts. Changes take effect immediately. +

+ +
+

Unlock the setup page to load users.

+
+
+ +

Reset Database

@@ -321,6 +450,58 @@

+ + + + + + + + + + @@ -394,7 +575,7 @@ + 'Database initialized — ' + data.table_count + ' table(s)'; html += '
'; data.tables.forEach(function(t) { - html += '' + t + ''; + html += '' + escHtml(t) + ''; }); html += '
'; } else { @@ -404,7 +585,7 @@ container.innerHTML = html; } catch (e) { container.innerHTML = '
' - + 'Could not fetch status: ' + e.message + '
'; + + 'Could not fetch status: ' + escHtml(e.message) + ''; } } @@ -427,6 +608,7 @@ document.getElementById('login-screen').classList.add('hidden'); document.getElementById('setup-panel').classList.remove('hidden'); await checkStatus(); + await loadUsers(); showToast('Setup panel unlocked', 'success'); } catch (e) { showToast('Connection error: ' + e.message, 'error'); @@ -499,7 +681,11 @@ setLoading('btn-seed', true); try { const data = await apiPost('/seed', { password: setupPassword }); - showToast(data.message + ' (users: ' + data.users_created.join(', ') + ')', 'success'); + var msg = data.message + ' (users: ' + data.users_created.join(', '); + if (data.measurements_created) msg += ', measurements: ' + data.measurements_created; + msg += ')'; + showToast(msg, 'success'); + await loadUsers(); } catch (e) { showToast(e.message, 'error'); } finally { @@ -541,6 +727,203 @@ setLoading('btn-reset', false); } } + + /* ================================================================== */ + /* USER MANAGEMENT */ + /* ================================================================== */ + + function escHtml(str) { + if (!str) return ''; + var d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; + } + + function roleBadge(role) { + var cls = 'badge '; + if (role === 'Maker') cls += 'badge-maker'; + else if (role === 'MeasurementTec') cls += 'badge-measurement'; + else if (role === 'Metrologist') cls += 'badge-metrologist'; + return '' + escHtml(role) + ' '; + } + + async function loadUsers() { + var container = document.getElementById('users-table-container'); + try { + var data = await apiPost('/list-users', { password: setupPassword }); + if (!data.users || data.users.length === 0) { + container.innerHTML = '

No users found. Use Seed Demo Data or create one.

'; + return; + } + var html = '' + + '' + + '' + + ''; + data.users.forEach(function(u) { + var roles = ''; + (u.roles || []).forEach(function(r) { roles += roleBadge(r); }); + if (u.is_admin) roles += 'Admin '; + var statusBadge = u.active + ? 'Active' + : 'Inactive'; + var toggleLabel = u.active ? 'Deactivate' : 'Activate'; + var toggleClass = u.active ? 'btn-sm-danger' : 'btn-sm-success'; + html += '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
IDUsernameDisplay NameRolesStatusActions
' + u.id + '' + escHtml(u.username) + '' + escHtml(u.display_name) + '' + roles + '' + statusBadge + '
' + + '' + + '' + + '' + + '
'; + container.innerHTML = html; + } catch (e) { + container.innerHTML = '
' + escHtml(e.message) + '
'; + } + } + + /* -- Modal helpers ------------------------------------------------- */ + function closeModal(id) { + document.getElementById(id).classList.add('hidden'); + } + + function openCreateUserModal() { + document.getElementById('modal-user-title').textContent = 'New User'; + document.getElementById('mu-user-id').value = ''; + document.getElementById('mu-username').value = ''; + document.getElementById('mu-password').value = ''; + document.getElementById('mu-displayname').value = ''; + document.getElementById('mu-email').value = ''; + document.getElementById('mu-role-maker').checked = false; + document.getElementById('mu-role-tec').checked = false; + document.getElementById('mu-role-metro').checked = false; + document.getElementById('mu-admin').checked = false; + document.getElementById('mu-pwd-hint').textContent = '(required)'; + document.getElementById('modal-user').classList.remove('hidden'); + } + + /* Store user data for edit lookup */ + var _usersCache = []; + + async function openEditUserModal(userId) { + /* Reload users to get fresh data */ + try { + var data = await apiPost('/list-users', { password: setupPassword }); + _usersCache = data.users || []; + } catch (e) { + showToast('Failed to load user: ' + e.message, 'error'); + return; + } + var user = _usersCache.find(function(u) { return u.id === userId; }); + if (!user) { showToast('User not found', 'error'); return; } + + document.getElementById('modal-user-title').textContent = 'Edit User'; + document.getElementById('mu-user-id').value = user.id; + document.getElementById('mu-username').value = user.username; + document.getElementById('mu-password').value = ''; + document.getElementById('mu-displayname').value = user.display_name; + document.getElementById('mu-email').value = user.email || ''; + document.getElementById('mu-role-maker').checked = (user.roles || []).indexOf('Maker') !== -1; + document.getElementById('mu-role-tec').checked = (user.roles || []).indexOf('MeasurementTec') !== -1; + document.getElementById('mu-role-metro').checked = (user.roles || []).indexOf('Metrologist') !== -1; + document.getElementById('mu-admin').checked = user.is_admin; + document.getElementById('mu-pwd-hint').textContent = '(leave blank to keep current)'; + document.getElementById('modal-user').classList.remove('hidden'); + } + + async function saveUser() { + var userId = document.getElementById('mu-user-id').value; + var username = document.getElementById('mu-username').value.trim(); + var pwd = document.getElementById('mu-password').value; + var displayName = document.getElementById('mu-displayname').value.trim(); + var email = document.getElementById('mu-email').value.trim(); + + if (!username || !displayName) { + showToast('Username and Display Name are required', 'error'); + return; + } + if (!userId && !pwd) { + showToast('Password is required for new users', 'error'); + return; + } + + var roles = []; + if (document.getElementById('mu-role-maker').checked) roles.push('Maker'); + if (document.getElementById('mu-role-tec').checked) roles.push('MeasurementTec'); + if (document.getElementById('mu-role-metro').checked) roles.push('Metrologist'); + + setLoading('btn-save-user', true); + try { + var body = { + password: setupPassword, + user_id: userId ? parseInt(userId) : null, + username: username, + display_name: displayName, + email: email || null, + roles: roles, + is_admin: document.getElementById('mu-admin').checked, + }; + if (pwd) body.user_password = pwd; + var data = await apiPost('/manage-user', body); + showToast(data.message, 'success'); + closeModal('modal-user'); + await loadUsers(); + } catch (e) { + showToast(e.message, 'error'); + } finally { + setLoading('btn-save-user', false); + } + } + + function openPasswordModal(userId, username) { + document.getElementById('mp-user-id').value = userId; + document.getElementById('mp-username').textContent = username; + document.getElementById('mp-new-password').value = ''; + document.getElementById('mp-confirm-password').value = ''; + document.getElementById('modal-password').classList.remove('hidden'); + } + + async function changePassword() { + var userId = document.getElementById('mp-user-id').value; + var newPwd = document.getElementById('mp-new-password').value; + var confirmPwd = document.getElementById('mp-confirm-password').value; + + if (!newPwd) { showToast('Password cannot be empty', 'error'); return; } + if (newPwd !== confirmPwd) { showToast('Passwords do not match', 'error'); return; } + + try { + var data = await apiPost('/change-user-password', { + password: setupPassword, + user_id: parseInt(userId), + new_password: newPwd, + }); + showToast(data.message, 'success'); + closeModal('modal-password'); + } catch (e) { + showToast(e.message, 'error'); + } + } + + async function toggleActive(userId, username, currentlyActive) { + var action = currentlyActive ? 'deactivate' : 'activate'; + if (!confirm('Are you sure you want to ' + action + ' user "' + username + '"?')) return; + + try { + var data = await apiPost('/toggle-user-active', { + password: setupPassword, + user_id: userId, + }); + showToast(data.message, 'success'); + await loadUsers(); + } catch (e) { + showToast(e.message, 'error'); + } + } diff --git a/server/tests/test_setup.py b/server/tests/test_setup.py new file mode 100644 index 0000000..41452b9 --- /dev/null +++ b/server/tests/test_setup.py @@ -0,0 +1,294 @@ +"""Tests for setup router - demo measurements and user management.""" +import pytest +from httpx import AsyncClient + +from config import settings +from tests.conftest import test_engine, TestSessionFactory + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +SETUP_PWD = "test-setup-password" + + +@pytest.fixture(autouse=True) +def enable_setup(monkeypatch): + """Enable setup endpoints and redirect engine/session to test DB.""" + monkeypatch.setattr(settings, "setup_password", SETUP_PWD) + # setup.py imports engine and async_session_factory directly from database + # and uses them instead of get_db dependency injection. Patch them to use + # the test in-memory SQLite engine/session instead of real MySQL. + import routers.setup as setup_mod + monkeypatch.setattr(setup_mod, "engine", test_engine) + monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory) + + +async def _seed(client: AsyncClient) -> dict: + """Run seed and return response data.""" + resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD}) + assert resp.status_code == 200 + resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD}) + assert resp.status_code == 200 + return resp.json() + + +# --------------------------------------------------------------------------- +# Feature 1: Demo Measurements in Seed +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_seed_creates_measurements(client: AsyncClient): + """Seed should create 180 measurements (30 per subtask x 6 subtasks).""" + data = await _seed(client) + assert data["measurements_created"] == 180 + + +@pytest.mark.asyncio +async def test_seed_measurements_have_correct_distribution(client: AsyncClient, db_session): + """Measurements should have a mix of pass, warning, and fail.""" + await _seed(client) + + from sqlalchemy import select, func + from models.measurement import Measurement + + result = await db_session.execute( + select(Measurement.pass_fail, func.count()).group_by(Measurement.pass_fail) + ) + counts = dict(result.all()) + + # Should have all three statuses + assert "pass" in counts + assert "warning" in counts + assert "fail" in counts + # Pass should be the majority + assert counts["pass"] > counts["warning"] + assert counts["pass"] > counts["fail"] + + +@pytest.mark.asyncio +async def test_seed_measurements_have_lot_and_serial(client: AsyncClient, db_session): + """All measurements should have lot and serial numbers.""" + await _seed(client) + + from sqlalchemy import select, func + from models.measurement import Measurement + + result = await db_session.execute( + select(func.count()).where(Measurement.lot_number.is_(None)) + ) + null_lots = result.scalar() + assert null_lots == 0 + + +@pytest.mark.asyncio +async def test_seed_measurements_input_methods(client: AsyncClient, db_session): + """Measurements should have mixed input methods.""" + await _seed(client) + + from sqlalchemy import select, func + from models.measurement import Measurement + + result = await db_session.execute( + select(Measurement.input_method, func.count()).group_by(Measurement.input_method) + ) + methods = dict(result.all()) + assert "usb_caliper" in methods + assert "manual" in methods + + +@pytest.mark.asyncio +async def test_seed_is_reproducible(client: AsyncClient): + """Seed uses random.seed(42), so results should be deterministic.""" + data = await _seed(client) + assert data["measurements_created"] == 180 + assert data["recipe_created"] == "DEMO-001" + + +# --------------------------------------------------------------------------- +# Feature 2: User Management - List +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_users_after_seed(client: AsyncClient): + """After seed, list-users should return 4 users.""" + await _seed(client) + resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD}) + assert resp.status_code == 200 + data = resp.json() + assert len(data["users"]) == 4 + + +@pytest.mark.asyncio +async def test_list_users_wrong_password(client: AsyncClient): + """list-users with wrong password should return 403.""" + resp = await client.post("/api/setup/list-users", json={"password": "wrong"}) + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Feature 2: User Management - Create / Update +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_user(client: AsyncClient): + """Create a new user via manage-user endpoint.""" + resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD}) + assert resp.status_code == 200 + + resp = await client.post("/api/setup/manage-user", json={ + "password": SETUP_PWD, + "user_id": None, + "username": "newuser", + "user_password": "pass123", + "display_name": "New User", + "email": "new@test.com", + "roles": ["Maker"], + "is_admin": False, + }) + assert resp.status_code == 200 + data = resp.json() + assert data["user_id"] is not None + assert "created" in data["message"] + + +@pytest.mark.asyncio +async def test_create_user_duplicate_username(client: AsyncClient): + """Creating a user with a duplicate username should return 409.""" + await _seed(client) + + resp = await client.post("/api/setup/manage-user", json={ + "password": SETUP_PWD, + "user_id": None, + "username": "admin", + "user_password": "pass123", + "display_name": "Duplicate", + "roles": [], + }) + assert resp.status_code == 409 + + +@pytest.mark.asyncio +async def test_create_user_without_password(client: AsyncClient): + """Creating a user without a password should return 400.""" + resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD}) + + resp = await client.post("/api/setup/manage-user", json={ + "password": SETUP_PWD, + "user_id": None, + "username": "nopassuser", + "display_name": "No Pass", + "roles": [], + }) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_update_user(client: AsyncClient): + """Update an existing user via manage-user endpoint.""" + await _seed(client) + + # Get users to find the ID + resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD}) + users = resp.json()["users"] + maker = next(u for u in users if u["username"] == "maker1") + + resp = await client.post("/api/setup/manage-user", json={ + "password": SETUP_PWD, + "user_id": maker["id"], + "username": "maker1", + "display_name": "Updated Name", + "email": "updated@test.com", + "roles": ["Maker", "MeasurementTec"], + "is_admin": False, + }) + assert resp.status_code == 200 + assert "updated" in resp.json()["message"] + + +@pytest.mark.asyncio +async def test_create_user_invalid_role(client: AsyncClient): + """Creating a user with an invalid role should return 400.""" + resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD}) + + resp = await client.post("/api/setup/manage-user", json={ + "password": SETUP_PWD, + "user_id": None, + "username": "badrole", + "user_password": "pass123", + "display_name": "Bad Role", + "roles": ["InvalidRole"], + }) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# Feature 2: User Management - Password Change +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_change_password(client: AsyncClient): + """Changing password should succeed and invalidate api_key.""" + await _seed(client) + + resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD}) + users = resp.json()["users"] + tec = next(u for u in users if u["username"] == "tec1") + + resp = await client.post("/api/setup/change-user-password", json={ + "password": SETUP_PWD, + "user_id": tec["id"], + "new_password": "newpass123", + }) + assert resp.status_code == 200 + assert "changed" in resp.json()["message"].lower() + + +@pytest.mark.asyncio +async def test_change_password_user_not_found(client: AsyncClient): + """Changing password for non-existent user should return 404.""" + resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD}) + + resp = await client.post("/api/setup/change-user-password", json={ + "password": SETUP_PWD, + "user_id": 99999, + "new_password": "newpass123", + }) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Feature 2: User Management - Toggle Active +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_toggle_user_active(client: AsyncClient): + """Toggle active should flip the user's active status.""" + await _seed(client) + + resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD}) + users = resp.json()["users"] + maker = next(u for u in users if u["username"] == "maker1") + assert maker["active"] is True + + # Deactivate + resp = await client.post("/api/setup/toggle-user-active", json={ + "password": SETUP_PWD, + "user_id": maker["id"], + }) + assert resp.status_code == 200 + assert resp.json()["active"] is False + + # Re-activate + resp = await client.post("/api/setup/toggle-user-active", json={ + "password": SETUP_PWD, + "user_id": maker["id"], + }) + assert resp.status_code == 200 + assert resp.json()["active"] is True