fix(scripts): guardie di seed_collaudo.py per produzione e cancellazioni (#17)
--supervisor-password non ha piu' un default committato: se il capoturno va creato e l'opzione manca, ne genera una casuale (secrets.token_urlsafe(12)) e la stampa una sola volta. --conferma-produzione richiesto quando APP_ENV (src.backend.config, stessa regola di #33) non e' dev/test. --replace elenca quante Measurement/ProductionRun/ProductionEvent cancellerebbe e si ferma senza cancellare finche' non si aggiunge --si. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
"""Tests for scripts/seed_collaudo.py (issue #17).
|
||||
|
||||
The script is not a package module (it is copied into the server container
|
||||
and run directly), so it is loaded here via importlib from its file path.
|
||||
"""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services.auth_service import create_user
|
||||
from src.backend.tests.conftest import TestSessionFactory, test_engine
|
||||
|
||||
_SCRIPT_PATH = Path(__file__).resolve().parents[3] / "scripts" / "seed_collaudo.py"
|
||||
|
||||
|
||||
def _load_script():
|
||||
spec = importlib.util.spec_from_file_location("seed_collaudo", _SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_collaudo(monkeypatch):
|
||||
module = _load_script()
|
||||
monkeypatch.setattr(module, "async_session_factory", TestSessionFactory)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_app_env(monkeypatch):
|
||||
"""Every test in this file assumes the default (dev)."""
|
||||
monkeypatch.setattr(settings, "app_env", "dev")
|
||||
|
||||
|
||||
async def _make_admin_and_station(db_session) -> None:
|
||||
admin = await create_user(
|
||||
db_session, username="owner", password="OwnerPass1",
|
||||
display_name="Owner", is_admin=True,
|
||||
)
|
||||
db_session.add(Station(
|
||||
code="ST-DEFAULT", name="Default", created_by=admin.id,
|
||||
))
|
||||
await db_session.flush()
|
||||
|
||||
|
||||
class TestReplaceRequiresSi:
|
||||
"""--replace without --si only reports the impact, never deletes (#17c)."""
|
||||
|
||||
async def test_replace_without_si_does_not_delete(self, seed_collaudo, db_session):
|
||||
await _make_admin_and_station(db_session)
|
||||
|
||||
# First run creates COLLAUDO-A/B.
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", False, "FirstPass123", True)
|
||||
assert rc == 0
|
||||
|
||||
# Second run with --replace but no --si: must not touch anything.
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", True, "SecondPass123", False)
|
||||
assert rc == 1
|
||||
|
||||
recipes = (await db_session.execute(
|
||||
select(seed_collaudo.Recipe).where(
|
||||
seed_collaudo.Recipe.code.in_(seed_collaudo.CODES)
|
||||
)
|
||||
)).scalars().all()
|
||||
assert len(recipes) == 2
|
||||
|
||||
async def test_replace_with_si_deletes_and_recreates(self, seed_collaudo, db_session):
|
||||
await _make_admin_and_station(db_session)
|
||||
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", False, "FirstPass123", True)
|
||||
assert rc == 0
|
||||
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", True, "SecondPass123", True)
|
||||
assert rc == 0
|
||||
|
||||
recipes = (await db_session.execute(
|
||||
select(seed_collaudo.Recipe).where(
|
||||
seed_collaudo.Recipe.code.in_(seed_collaudo.CODES)
|
||||
)
|
||||
)).scalars().all()
|
||||
assert len(recipes) == 2
|
||||
|
||||
def test_main_with_fake_argv_replace_without_si_does_not_delete(
|
||||
self, seed_collaudo, monkeypatch
|
||||
):
|
||||
"""The exact scenario from the analysis: main() with a fake argv,
|
||||
--replace but no --si, must leave the recipes untouched.
|
||||
|
||||
Plain `def`, not `async def`: main() manages its own event loop via
|
||||
asyncio.run(), which cannot be called from inside one already running
|
||||
(as an `async def` test under pytest-asyncio would have).
|
||||
"""
|
||||
async def _setup():
|
||||
async with TestSessionFactory() as session:
|
||||
await _make_admin_and_station(session)
|
||||
await session.commit()
|
||||
|
||||
asyncio.run(_setup())
|
||||
assert asyncio.run(
|
||||
seed_collaudo.seed("ST-DEFAULT", False, "FirstPass123", True)
|
||||
) == 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sys.argv",
|
||||
["seed_collaudo.py", "--station", "ST-DEFAULT", "--replace"],
|
||||
)
|
||||
rc = seed_collaudo.main()
|
||||
assert rc == 1
|
||||
|
||||
async def _count():
|
||||
async with TestSessionFactory() as session:
|
||||
recipes = (await session.execute(
|
||||
select(seed_collaudo.Recipe).where(
|
||||
seed_collaudo.Recipe.code.in_(seed_collaudo.CODES)
|
||||
)
|
||||
)).scalars().all()
|
||||
return len(recipes)
|
||||
|
||||
assert asyncio.run(_count()) == 2
|
||||
|
||||
|
||||
class TestSupervisorPassword:
|
||||
"""No default --supervisor-password: generated and printed once (#17a)."""
|
||||
|
||||
async def test_generates_random_password_when_not_given(
|
||||
self, seed_collaudo, db_session
|
||||
):
|
||||
await _make_admin_and_station(db_session)
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", False, None, True)
|
||||
assert rc == 0
|
||||
|
||||
supervisor = (await db_session.execute(
|
||||
select(User).where(User.username == "capoturno")
|
||||
)).scalar_one_or_none()
|
||||
assert supervisor is not None
|
||||
# A real password was set (create_user always hashes something).
|
||||
assert supervisor.password_hash
|
||||
|
||||
async def test_uses_given_password_when_provided(self, seed_collaudo, db_session):
|
||||
await _make_admin_and_station(db_session)
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", False, "ExplicitPass1", True)
|
||||
assert rc == 0
|
||||
|
||||
from src.backend.services.auth_service import authenticate_user
|
||||
|
||||
user = await authenticate_user(db_session, "capoturno", "ExplicitPass1")
|
||||
assert user is not None
|
||||
|
||||
async def test_existing_supervisor_password_untouched(
|
||||
self, seed_collaudo, db_session
|
||||
):
|
||||
"""A second run (no --replace) must not need a password at all."""
|
||||
await _make_admin_and_station(db_session)
|
||||
assert await seed_collaudo.seed("ST-DEFAULT", False, "FirstPass123", True) == 0
|
||||
|
||||
# Recipes already exist, so seed() stops at the "already present"
|
||||
# check before ever looking at supervisor_password.
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", False, None, True)
|
||||
assert rc == 1
|
||||
|
||||
|
||||
class TestProductionGuard:
|
||||
"""Refuses to run against APP_ENV outside dev/test without --conferma-produzione (#17b)."""
|
||||
|
||||
def test_main_refuses_in_production_without_confirmation(
|
||||
self, seed_collaudo, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
monkeypatch.setattr(
|
||||
"sys.argv", ["seed_collaudo.py", "--station", "ST-DEFAULT"]
|
||||
)
|
||||
rc = seed_collaudo.main()
|
||||
assert rc == 2
|
||||
|
||||
def test_main_allows_dev_without_confirmation(self, seed_collaudo, monkeypatch):
|
||||
monkeypatch.setattr(settings, "app_env", "dev")
|
||||
# No admin/station set up: seed() itself will fail with rc=2, but that
|
||||
# proves the production guard did not block it first.
|
||||
monkeypatch.setattr(
|
||||
"sys.argv", ["seed_collaudo.py", "--station", "NOPE"]
|
||||
)
|
||||
rc = seed_collaudo.main()
|
||||
assert rc == 2 # "Nessun utente amministratore" / stazione inesistente
|
||||
Reference in New Issue
Block a user