Merge lavoro/daitarn-trafilo-pin

This commit is contained in:
2026-10-07 12:38:56 +02:00
12 changed files with 441 additions and 11 deletions
+38 -1
View File
@@ -1,5 +1,6 @@
"""Authentication router - login, logout, profile.""" """Authentication router - login, logout, profile."""
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db 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 ( from src.backend.models.api.user import (
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
PinLoginRequest,
PinUserItem,
UserProfileUpdate, UserProfileUpdate,
UserResponse, 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"]) 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) @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout( async def logout(
request: Request, request: Request,
+1
View File
@@ -147,6 +147,7 @@ async def authorise_measurement(
supervisor = await auth_service.authorise_supervisor( supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password, db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
) )
measurement = await measurement_service.authorise_measurement( measurement = await measurement_service.authorise_measurement(
db, measurement, supervisor, db, measurement, supervisor,
+3
View File
@@ -163,6 +163,7 @@ async def pause_production_run(
run = await production_service.get_run(db, run_id) run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor( supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password, 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) run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run) return await _as_response(db, run)
@@ -179,6 +180,7 @@ async def resume_production_run(
run = await production_service.get_run(db, run_id) run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor( supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password, 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) run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run) return await _as_response(db, run)
@@ -195,6 +197,7 @@ async def close_production_run(
run = await production_service.get_run(db, run_id) run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor( supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password, 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) run = await production_service.close_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run) return await _as_response(db, run)
+32 -2
View File
@@ -6,8 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_admin_user from src.backend.api.middleware.api_key import require_admin_user
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate 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 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"]) 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}"} 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) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_user( async def deactivate_user(
user_id: int, user_id: int,
@@ -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')
+16 -3
View File
@@ -2,7 +2,7 @@
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, model_validator
class MeasurementCreate(BaseModel): class MeasurementCreate(BaseModel):
@@ -71,8 +71,21 @@ class MeasurementListResponse(BaseModel):
class MeasurementAuthorisation(BaseModel): class MeasurementAuthorisation(BaseModel):
"""Credentials of the supervisor allowing an out-of-tolerance value to stand.""" """Credentials of the supervisor allowing an out-of-tolerance value to stand."""
supervisor_username: str = Field(..., min_length=1) # #59: o username+password, oppure supervisor_user_id+supervisor_pin
supervisor_password: str = Field(..., min_length=1) 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): class TaskProgressResponse(BaseModel):
+16 -3
View File
@@ -2,7 +2,7 @@
from src.backend.models.api.tipi import UtcDateTime from src.backend.models.api.tipi import UtcDateTime
from typing import Literal, Optional from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, model_validator
class ProductionRunCreate(BaseModel): class ProductionRunCreate(BaseModel):
@@ -64,8 +64,21 @@ class ProductionRunWithEventsResponse(ProductionRunResponse):
class SupervisorAction(BaseModel): class SupervisorAction(BaseModel):
"""Credentials of the supervisor authorising a line stop or a close.""" """Credentials of the supervisor authorising a line stop or a close."""
supervisor_username: str = Field(..., min_length=1) # #59: o username+password, oppure supervisor_user_id+supervisor_pin
supervisor_password: str = Field(..., min_length=1) 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 note: Optional[str] = None
+20
View File
@@ -50,6 +50,8 @@ class UserResponse(BaseModel):
active: bool active: bool
created_at: UtcDateTime created_at: UtcDateTime
last_login: Optional[UtcDateTime] = None last_login: Optional[UtcDateTime] = None
# #59: solo il fatto che il PIN esista, mai l'hash
has_pin: bool = False
class UserPasswordChange(BaseModel): class UserPasswordChange(BaseModel):
@@ -68,3 +70,21 @@ class LoginResponse(BaseModel):
user: UserResponse user: UserResponse
api_key: str api_key: str
message: str = "Login successful" 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}$")
+12
View File
@@ -22,6 +22,13 @@ class User(Base):
# Admin flag separate from roles # Admin flag separate from roles
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) 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 # User preferences
language_pref: Mapped[str] = mapped_column( language_pref: Mapped[str] = mapped_column(
Enum("it", "en", name="language_enum"), nullable=False, default="it" 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) 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: def has_role(self, role: str) -> bool:
"""Check if user has a specific role.""" """Check if user has a specific role."""
return role in (self.roles or []) return role in (self.roles or [])
+82 -1
View File
@@ -54,7 +54,79 @@ async def authenticate_user(
return 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. """Check the credentials of the supervisor authorising something.
Used wherever the shop floor needs a capoturno: a line stop, a closure, an 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 Uses authenticate_user rather than a full login: logging in would issue a
needless API key for the supervisor. needless API key for the supervisor.
""" """
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) 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: if supervisor is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
+19
View File
@@ -149,3 +149,22 @@ class TestVisionRecipeDirMigration:
assert "vision_recipe_dir" not in cols() assert "vision_recipe_dir" not in cols()
command.upgrade(cfg, "head") # e di nuovo su command.upgrade(cfg, "head") # e di nuovo su
assert "vision_recipe_dir" in cols() 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()
+167
View File
@@ -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]