diff --git a/src/backend/api/routers/auth.py b/src/backend/api/routers/auth.py index 4dd8369..a2b4ea1 100644 --- a/src/backend/api/routers/auth.py +++ b/src/backend/api/routers/auth.py @@ -1,5 +1,6 @@ """Authentication router - login, logout, profile.""" from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.backend.database import get_db @@ -8,10 +9,17 @@ from src.backend.models.orm.user import User from src.backend.models.api.user import ( LoginRequest, LoginResponse, + PinLoginRequest, + PinUserItem, UserProfileUpdate, UserResponse, ) -from src.backend.services.auth_service import authenticate_user, login_user, logout_user +from src.backend.services.auth_service import ( + authenticate_pin, + authenticate_user, + login_user, + logout_user, +) router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -32,6 +40,35 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)): ) +@router.get("/pin-users", response_model=list[PinUserItem]) +async def pin_users(db: AsyncSession = Depends(get_db)): + """Utenti che possono entrare con PIN (#59): attivi, non admin, con PIN. + + Pubblico per costruzione (serve prima del login): espone solo id e nome. + """ + result = await db.execute( + select(User).where( + User.active == True, # noqa: E712 + User.is_admin == False, # noqa: E712 + User.pin_hash.is_not(None), + ) + ) + items = [ + PinUserItem(id=u.id, display_name=u.display_name or u.username) + for u in result.scalars().all() + ] + items.sort(key=lambda i: i.display_name.casefold()) + return items + + +@router.post("/login-pin", response_model=LoginResponse) +async def login_pin(data: PinLoginRequest, db: AsyncSession = Depends(get_db)): + """Login con user_id e PIN a 4 cifre (#59); stessa risposta del login.""" + user = await authenticate_pin(db, data.user_id, data.pin) + api_key = await login_user(db, user) + return LoginResponse(user=UserResponse.model_validate(user), api_key=api_key) + + @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) async def logout( request: Request, diff --git a/src/backend/api/routers/measurements.py b/src/backend/api/routers/measurements.py index 73d8314..4e631b6 100644 --- a/src/backend/api/routers/measurements.py +++ b/src/backend/api/routers/measurements.py @@ -147,6 +147,7 @@ async def authorise_measurement( supervisor = await auth_service.authorise_supervisor( db, action.supervisor_username, action.supervisor_password, + user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59 ) measurement = await measurement_service.authorise_measurement( db, measurement, supervisor, diff --git a/src/backend/api/routers/production.py b/src/backend/api/routers/production.py index 2a4f8c6..f21c018 100644 --- a/src/backend/api/routers/production.py +++ b/src/backend/api/routers/production.py @@ -163,6 +163,7 @@ async def pause_production_run( run = await production_service.get_run(db, run_id) supervisor = await auth_service.authorise_supervisor( db, action.supervisor_username, action.supervisor_password, + user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59 ) run = await production_service.pause_run(db, run, user, supervisor, note=action.note) return await _as_response(db, run) @@ -179,6 +180,7 @@ async def resume_production_run( run = await production_service.get_run(db, run_id) supervisor = await auth_service.authorise_supervisor( db, action.supervisor_username, action.supervisor_password, + user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59 ) run = await production_service.resume_run(db, run, user, supervisor, note=action.note) return await _as_response(db, run) @@ -195,6 +197,7 @@ async def close_production_run( run = await production_service.get_run(db, run_id) supervisor = await auth_service.authorise_supervisor( db, action.supervisor_username, action.supervisor_password, + user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59 ) run = await production_service.close_run(db, run, user, supervisor, note=action.note) return await _as_response(db, run) diff --git a/src/backend/api/routers/users.py b/src/backend/api/routers/users.py index 94ae584..0ab4d9b 100644 --- a/src/backend/api/routers/users.py +++ b/src/backend/api/routers/users.py @@ -6,8 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.backend.database import get_db from src.backend.api.middleware.api_key import require_admin_user from src.backend.models.orm.user import User -from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate -from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key, revoke_all_keys +from src.backend.models.api.user import PinSet, UserCreate, UserPasswordChange, UserResponse, UserUpdate +from src.backend.services.auth_service import ( + create_user, + hash_password, + regenerate_api_key, + revoke_all_keys, + set_user_pin, +) router = APIRouter(prefix="/api/users", tags=["users"]) @@ -151,6 +157,30 @@ async def change_user_password( return {"message": f"Password changed for user {user.username}"} +@router.put("/{user_id}/pin", status_code=status.HTTP_200_OK) +async def change_user_pin( + user_id: int, + data: PinSet, + admin: User = Depends(require_admin_user), + db: AsyncSession = Depends(get_db), +): + """Imposta o toglie (pin null) il PIN di un utente (admin, #59/#51). + + 422 se l'utente e' admin. Il PIN non si restituisce mai. + """ + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + if user.is_admin: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Admin users cannot have a PIN", + ) + await set_user_pin(db, user, data.pin) + return {"message": f"PIN {'set' if data.pin else 'removed'} for user {user.username}"} + + @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def deactivate_user( user_id: int, diff --git a/src/backend/migrations/versions/017_user_pin.py b/src/backend/migrations/versions/017_user_pin.py new file mode 100644 index 0000000..8881a4d --- /dev/null +++ b/src/backend/migrations/versions/017_user_pin.py @@ -0,0 +1,34 @@ +"""users: PIN a 4 cifre per login rapido e capoturno (#59) + +pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco. + +Revision ID: 017_user_pin +Revises: 016_vision_recipe_dir +Create Date: 2026-10-07 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '017_user_pin' +down_revision: Union[str, None] = '016_vision_recipe_dir' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni + with op.batch_alter_table('users') as batch: + batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True)) + batch.add_column(sa.Column( + 'pin_failed_count', sa.Integer(), nullable=False, server_default='0')) + batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('users') as batch: + batch.drop_column('pin_locked_until') + batch.drop_column('pin_failed_count') + batch.drop_column('pin_hash') diff --git a/src/backend/models/api/measurement.py b/src/backend/models/api/measurement.py index 00a5f2e..11ec194 100644 --- a/src/backend/models/api/measurement.py +++ b/src/backend/models/api/measurement.py @@ -2,7 +2,7 @@ from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput from typing import Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator class MeasurementCreate(BaseModel): @@ -71,8 +71,21 @@ class MeasurementListResponse(BaseModel): class MeasurementAuthorisation(BaseModel): """Credentials of the supervisor allowing an out-of-tolerance value to stand.""" - supervisor_username: str = Field(..., min_length=1) - supervisor_password: str = Field(..., min_length=1) + # #59: o username+password, oppure supervisor_user_id+supervisor_pin + supervisor_username: Optional[str] = Field(None, min_length=1) + supervisor_password: Optional[str] = Field(None, min_length=1) + supervisor_user_id: Optional[int] = Field(None, gt=0) + supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$") + + @model_validator(mode="after") + def _one_credential_pair(self): + by_password = self.supervisor_username and self.supervisor_password + by_pin = self.supervisor_user_id and self.supervisor_pin + if not (by_password or by_pin): + raise ValueError( + "supervisor credentials required: username+password or user_id+pin" + ) + return self class TaskProgressResponse(BaseModel): diff --git a/src/backend/models/api/production.py b/src/backend/models/api/production.py index f382ddb..eae838a 100644 --- a/src/backend/models/api/production.py +++ b/src/backend/models/api/production.py @@ -2,7 +2,7 @@ from src.backend.models.api.tipi import UtcDateTime from typing import Literal, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator class ProductionRunCreate(BaseModel): @@ -64,8 +64,21 @@ class ProductionRunWithEventsResponse(ProductionRunResponse): class SupervisorAction(BaseModel): """Credentials of the supervisor authorising a line stop or a close.""" - supervisor_username: str = Field(..., min_length=1) - supervisor_password: str = Field(..., min_length=1) + # #59: o username+password, oppure supervisor_user_id+supervisor_pin + supervisor_username: Optional[str] = Field(None, min_length=1) + supervisor_password: Optional[str] = Field(None, min_length=1) + supervisor_user_id: Optional[int] = Field(None, gt=0) + supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$") + + @model_validator(mode="after") + def _one_credential_pair(self): + by_password = self.supervisor_username and self.supervisor_password + by_pin = self.supervisor_user_id and self.supervisor_pin + if not (by_password or by_pin): + raise ValueError( + "supervisor credentials required: username+password or user_id+pin" + ) + return self note: Optional[str] = None diff --git a/src/backend/models/api/user.py b/src/backend/models/api/user.py index eb022ac..fab403e 100644 --- a/src/backend/models/api/user.py +++ b/src/backend/models/api/user.py @@ -50,6 +50,8 @@ class UserResponse(BaseModel): active: bool created_at: UtcDateTime last_login: Optional[UtcDateTime] = None + # #59: solo il fatto che il PIN esista, mai l'hash + has_pin: bool = False class UserPasswordChange(BaseModel): @@ -68,3 +70,21 @@ class LoginResponse(BaseModel): user: UserResponse api_key: str message: str = "Login successful" + + +class PinUserItem(BaseModel): + """Voce dell'elenco pubblico per il login con PIN (#59): solo id e nome.""" + id: int + display_name: str + + +class PinLoginRequest(BaseModel): + """Login con PIN (#59).""" + user_id: int + pin: str = Field(..., pattern=r"^[0-9]{4}$") + + +class PinSet(BaseModel): + """Imposta (4 cifre) o toglie (null) il PIN di un utente (#59).""" + pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$") + diff --git a/src/backend/models/orm/user.py b/src/backend/models/orm/user.py index e5ba358..7a47de2 100644 --- a/src/backend/models/orm/user.py +++ b/src/backend/models/orm/user.py @@ -22,6 +22,13 @@ class User(Base): # Admin flag separate from roles is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # PIN a 4 cifre (#59): solo l'hash; contatore errori e fine blocco per il lockout + pin_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + pin_failed_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + pin_locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + # User preferences language_pref: Mapped[str] = mapped_column( Enum("it", "en", name="language_enum"), nullable=False, default="it" @@ -39,6 +46,11 @@ class User(Base): ) last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + @property + def has_pin(self) -> bool: + """True se l'utente ha un PIN impostato (#59); serve a UserResponse.has_pin.""" + return self.pin_hash is not None + def has_role(self, role: str) -> bool: """Check if user has a specific role.""" return role in (self.roles or []) diff --git a/src/backend/services/auth_service.py b/src/backend/services/auth_service.py index 3da2328..50a1311 100644 --- a/src/backend/services/auth_service.py +++ b/src/backend/services/auth_service.py @@ -54,7 +54,79 @@ async def authenticate_user( return user -async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User: +# #59: PIN a 4 cifre. Hash identico alle password (bcrypt); 5 errori -> blocco 15 minuti. +PIN_MAX_FAILURES = 5 +PIN_LOCK_MINUTES = 15 + + +async def set_user_pin(db: AsyncSession, user: User, pin: str | None) -> None: + """Imposta o toglie (None) il PIN; azzera contatore e blocco. Admin esclusi (#59).""" + if pin is not None and user.is_admin: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Admin users cannot have a PIN", + ) + user.pin_hash = hash_password(pin) if pin is not None else None + user.pin_failed_count = 0 + user.pin_locked_until = None + await db.flush() + + +async def authenticate_pin(db: AsyncSession, user_id: int, pin: str) -> User: + """Verifica user_id+PIN con blocco dopo 5 errori consecutivi (#59). + + 401 PIN errato (o utente non idoneo: stessa risposta, niente enumerazione), + 423 utente bloccato. Il contatore va committato prima di sollevare: get_db fa + rollback su eccezione e l'errore andrebbe perso. + """ + result = await db.execute( + select(User).where(User.id == user_id, User.active == True) # noqa: E712 + ) + user = result.scalar_one_or_none() + if user is None or user.is_admin or user.pin_hash is None: + verify_password(pin, _DUMMY_PASSWORD_HASH) # stesso costo di un PIN errato + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN" + ) + now = utcnow() + if user.pin_locked_until is not None: + if user.pin_locked_until > now: + raise HTTPException( + status_code=status.HTTP_423_LOCKED, + detail="PIN locked until " + + user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S") + + " UTC", + ) + # blocco scaduto: si riparte da zero + user.pin_locked_until = None + user.pin_failed_count = 0 + if verify_password(pin, user.pin_hash): + if user.pin_failed_count: + user.pin_failed_count = 0 + await db.flush() + return user + user.pin_failed_count += 1 + locked = user.pin_failed_count >= PIN_MAX_FAILURES + if locked: + user.pin_locked_until = now + timedelta(minutes=PIN_LOCK_MINUTES) + await db.commit() + if locked: + raise HTTPException( + status_code=status.HTTP_423_LOCKED, + detail="PIN locked until " + + user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S") + + " UTC", + ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN") + + +async def authorise_supervisor( + db: AsyncSession, + username: str | None = None, + password: str | None = None, + user_id: int | None = None, + pin: str | None = None, +) -> User: """Check the credentials of the supervisor authorising something. Used wherever the shop floor needs a capoturno: a line stop, a closure, an @@ -65,7 +137,16 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) - Uses authenticate_user rather than a full login: logging in would issue a needless API key for the supervisor. """ - supervisor = await authenticate_user(db, username, password) + if user_id is not None and pin is not None: + # #59: via PIN, stesso blocco del login con PIN (401/423 sollevati da li') + supervisor = await authenticate_pin(db, user_id, pin) + elif username is not None and password is not None: + supervisor = await authenticate_user(db, username, password) + else: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Supervisor credentials required", + ) if supervisor is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/src/backend/tests/test_migrations.py b/src/backend/tests/test_migrations.py index 3938829..64e9664 100644 --- a/src/backend/tests/test_migrations.py +++ b/src/backend/tests/test_migrations.py @@ -149,3 +149,22 @@ class TestVisionRecipeDirMigration: assert "vision_recipe_dir" not in cols() command.upgrade(cfg, "head") # e di nuovo su assert "vision_recipe_dir" in cols() + + +class TestUserPinMigration: + """017_user_pin: users.pin_hash / pin_failed_count / pin_locked_until, su e giu (#59).""" + + def test_017_upgrade_and_downgrade(self, sqlite_database_url): + db_path, _ = sqlite_database_url + cfg = _alembic_config() + new = {"pin_hash", "pin_failed_count", "pin_locked_until"} + + def cols(): + return {c["name"] for c in _inspect(db_path).get_columns("users")} + + command.upgrade(cfg, "head") + assert new <= cols() + command.downgrade(cfg, "016_vision_recipe_dir") + assert not (new & cols()) + command.upgrade(cfg, "head") # e di nuovo su + assert new <= cols() diff --git a/src/backend/tests/test_pin.py b/src/backend/tests/test_pin.py new file mode 100644 index 0000000..7f78206 --- /dev/null +++ b/src/backend/tests/test_pin.py @@ -0,0 +1,167 @@ +"""PIN a 4 cifre: login rapido, blocco, capoturno via PIN (#59).""" +from datetime import timedelta + +import pytest +from fastapi import HTTPException +from httpx import AsyncClient +from sqlalchemy import select + +from src.backend.models.orm.user import User +from src.backend.services import auth_service +from src.backend.tests.conftest import _create_user, auth_headers +from src.backend.tests.test_production_runs import ( + _open, _recipe_with_interval, _station, +) +from src.backend.utils.tempo import utcnow + + +async def _pin_user(db_session, username="operaio", pin="1234", **kw) -> User: + user = await _create_user(db_session, username=username, **kw) + await auth_service.set_user_pin(db_session, user, pin) + await db_session.commit() + return user + + +async def _login_pin(client, user_id, pin): + return await client.post("/api/auth/login-pin", json={"user_id": user_id, "pin": pin}) + + +async def test_login_pin_ok_same_shape_as_login(client: AsyncClient, db_session): + user = await _pin_user(db_session) + resp = await _login_pin(client, user.id, "1234") + assert resp.status_code == 200 + data = resp.json() + assert data["user"]["username"] == "operaio" + assert data["user"]["has_pin"] is True + assert data["api_key"] + assert "pin_hash" not in data["user"] + # la api_key vale davvero + me = await client.get("/api/auth/me", headers={"X-API-Key": data["api_key"]}) + assert me.status_code == 200 + + +async def test_login_pin_wrong_is_401(client: AsyncClient, db_session): + user = await _pin_user(db_session) + assert (await _login_pin(client, user.id, "9999")).status_code == 401 + + +async def test_login_pin_format_422(client: AsyncClient, db_session): + user = await _pin_user(db_session) + for bad in ("123", "12345", "12a4", ""): + assert (await _login_pin(client, user.id, bad)).status_code == 422 + + +async def test_lock_after_five_errors_even_with_right_pin(client: AsyncClient, db_session): + user = await _pin_user(db_session) + for _ in range(4): + assert (await _login_pin(client, user.id, "0000")).status_code == 401 + resp = await _login_pin(client, user.id, "0000") # 5 errore: scatta il blocco + assert resp.status_code == 423 + assert "locked until" in resp.json()["detail"] + assert (await _login_pin(client, user.id, "1234")).status_code == 423 + + +async def test_lock_expires_and_counter_resets(client: AsyncClient, db_session): + user = await _pin_user(db_session) + for _ in range(5): + await _login_pin(client, user.id, "0000") + row = (await db_session.execute(select(User).where(User.id == user.id))).scalar_one() + await db_session.refresh(row) + assert row.pin_locked_until > utcnow() + timedelta(minutes=14) + # tempo simulato: il blocco e scaduto un secondo fa + row.pin_locked_until = utcnow() - timedelta(seconds=1) + await db_session.commit() + assert (await _login_pin(client, user.id, "1234")).status_code == 200 + await db_session.refresh(row) + assert row.pin_failed_count == 0 and row.pin_locked_until is None + + +async def test_success_resets_failed_counter(client: AsyncClient, db_session): + user = await _pin_user(db_session) + for _ in range(3): + await _login_pin(client, user.id, "0000") + assert (await _login_pin(client, user.id, "1234")).status_code == 200 + for _ in range(4): # il contatore e ripartito da zero: 4 errori non bloccano + assert (await _login_pin(client, user.id, "0000")).status_code == 401 + + +async def test_admin_cannot_have_pin_or_login_with_it(client: AsyncClient, admin_user, db_session): + resp = await client.put( + f"/api/users/{admin_user.id}/pin", headers=auth_headers(admin_user), json={"pin": "1234"}, + ) + assert resp.status_code == 422 + # anche un admin con pin_hash scritto a mano non entra da login-pin + admin_user.pin_hash = auth_service.hash_password("1234") + await db_session.commit() + assert (await _login_pin(client, admin_user.id, "1234")).status_code == 401 + listed = (await client.get("/api/auth/pin-users")).json() + assert all(u["id"] != admin_user.id for u in listed) + + +async def test_put_pin_set_and_remove(client: AsyncClient, admin_user, db_session): + user = await _create_user(db_session, username="senzapin") + await db_session.commit() + h = auth_headers(admin_user) + assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "4321"})).status_code == 200 + assert (await _login_pin(client, user.id, "4321")).status_code == 200 + assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "12"})).status_code == 422 + assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": None})).status_code == 200 + assert (await _login_pin(client, user.id, "4321")).status_code == 401 + assert (await client.put("/api/users/99999/pin", headers=h, json={"pin": "1234"})).status_code == 404 + # non admin: 403 + assert (await client.put( + f"/api/users/{user.id}/pin", headers=auth_headers(user), json={"pin": "1234"}, + )).status_code == 403 + + +async def test_pin_users_list(client: AsyncClient, db_session): + a = await _pin_user(db_session, username="zeta", display_name="Zeta Rossi", pin="1111") + b = await _pin_user(db_session, username="alfa", display_name="Alfa Bianchi", pin="2222") + await _create_user(db_session, username="nopin") + await _pin_user(db_session, username="inattivo", pin="3333", active=False) + await db_session.commit() + resp = await client.get("/api/auth/pin-users") # senza autenticazione + assert resp.status_code == 200 + data = resp.json() + assert [u["display_name"] for u in data] == ["Alfa Bianchi", "Zeta Rossi"] + assert all(set(u) == {"id", "display_name"} for u in data) + assert {u["id"] for u in data} == {a.id, b.id} + + +async def test_supervisor_via_pin(client: AsyncClient, measurement_tec_user, admin_user, db_session): + station = await _station(db_session, admin_user.id, code="ST-PIN") + recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PIN") + run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"] + capo = await _pin_user(db_session, username="capo-pin", pin="5555", roles=["Supervisor"]) + h = auth_headers(measurement_tec_user) + + bad = await client.post( + f"/api/production-runs/{run_id}/pause", headers=h, + json={"supervisor_user_id": capo.id, "supervisor_pin": "0000"}, + ) + assert bad.status_code == 401 + ok = await client.post( + f"/api/production-runs/{run_id}/pause", headers=h, + json={"supervisor_user_id": capo.id, "supervisor_pin": "5555"}, + ) + assert ok.status_code == 200 + # nessuna coppia di credenziali: 422 + none = await client.post(f"/api/production-runs/{run_id}/resume", headers=h, json={}) + assert none.status_code == 422 + + +async def test_supervisor_pin_role_still_checked(db_session): + plain = await _pin_user(db_session, username="non-capo", pin="6666", roles=["Maker"]) + with pytest.raises(HTTPException) as e: + await auth_service.authorise_supervisor(db_session, user_id=plain.id, pin="6666") + assert e.value.status_code == 403 + + +async def test_supervisor_pin_lockout(db_session): + capo = await _pin_user(db_session, username="capo-lock", pin="7777", roles=["Supervisor"]) + codes = [] + for pin in ["0000"] * 5 + ["7777"]: + with pytest.raises(HTTPException) as e: + await auth_service.authorise_supervisor(db_session, user_id=capo.id, pin=pin) + codes.append(e.value.status_code) + assert codes == [401, 401, 401, 401, 423, 423]