126 lines
5 KiB
Python
126 lines
5 KiB
Python
import unittest
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from app.core.decision_types import type_decision_depuis_proposition
|
|
from app.core.financial_position import PositionTresorerie
|
|
from app.core.sanitization import safe_query_params
|
|
from app.core.security import create_token, decode_token
|
|
from app.models.poste import Poste
|
|
from app.models.affectation import Affectation
|
|
from app.models.candidature import Candidature
|
|
from app.models.collecte import Collecte
|
|
from app.models.depense import Depense
|
|
from app.models.evenement import Evenement
|
|
from app.models.jeton import Jeton
|
|
from app.models.litterature import Litterature
|
|
from app.models.presence import Presence
|
|
from app.models.proposition import Proposition
|
|
from app.models.pv_reunion import PvReunion
|
|
from app.models.reunion import Reunion
|
|
from app.models.vente_litterature import VenteLitterature
|
|
from app.models.vente_jeton import VenteJeton
|
|
|
|
|
|
class DecisionTypeTests(unittest.TestCase):
|
|
def test_resolution_prefix(self):
|
|
prop = SimpleNamespace(numero_resolution="RES-2026-001", candidature_id=None)
|
|
self.assertEqual(type_decision_depuis_proposition(prop), "resolution")
|
|
|
|
def test_nomination_from_candidature(self):
|
|
prop = SimpleNamespace(numero_resolution=None, candidature_id="candidate-id")
|
|
self.assertEqual(type_decision_depuis_proposition(prop), "nomination")
|
|
|
|
def test_contribution_prefix(self):
|
|
prop = SimpleNamespace(numero_resolution="DON-2026-001", candidature_id=None)
|
|
self.assertEqual(type_decision_depuis_proposition(prop), "contribution")
|
|
|
|
def test_plain_proposition_without_decision_marker(self):
|
|
prop = SimpleNamespace(numero_resolution=None, candidature_id=None)
|
|
self.assertEqual(type_decision_depuis_proposition(prop), "proposition")
|
|
|
|
|
|
class PositionTresorerieTests(unittest.TestCase):
|
|
def test_available_bank_excludes_reserves_and_commitments(self):
|
|
position = PositionTresorerie(
|
|
solde_banque=100.0,
|
|
encaisse=25.0,
|
|
total_reserves=40.0,
|
|
engagements_non_debites=15.0,
|
|
)
|
|
self.assertEqual(position.disponible_bancaire, 45.0)
|
|
self.assertEqual(position.disponible_total, 70.0)
|
|
|
|
|
|
class AuditTests(unittest.TestCase):
|
|
def test_sensitive_query_params_are_masked(self):
|
|
request = SimpleNamespace(query_params={"pin": "1234", "membre_id": "abc"})
|
|
self.assertEqual(safe_query_params(request.query_params), {"pin": "***", "membre_id": "abc"})
|
|
|
|
|
|
class SecurityTokenTests(unittest.TestCase):
|
|
def test_sysadmin_instance_token_has_no_group_context(self):
|
|
token = create_token("member-id", None, expire_jours=1)
|
|
payload = decode_token(token)
|
|
self.assertEqual(payload["sub"], "member-id")
|
|
self.assertNotIn("grp", payload)
|
|
|
|
|
|
class MultiTenantTests(unittest.TestCase):
|
|
def test_postes_are_group_scoped(self):
|
|
self.assertIn("groupe_id", Poste.__table__.columns)
|
|
self.assertFalse(Poste.__table__.columns["groupe_id"].nullable)
|
|
|
|
def test_core_business_tables_are_group_scoped(self):
|
|
for model in [
|
|
Affectation,
|
|
Candidature,
|
|
Collecte,
|
|
Depense,
|
|
Evenement,
|
|
Jeton,
|
|
Litterature,
|
|
Proposition,
|
|
Reunion,
|
|
VenteLitterature,
|
|
VenteJeton,
|
|
]:
|
|
with self.subTest(model=model.__name__):
|
|
self.assertIn("groupe_id", model.__table__.columns)
|
|
self.assertFalse(model.__table__.columns["groupe_id"].nullable)
|
|
|
|
def test_child_tables_without_group_id_are_documented_exceptions(self):
|
|
exceptions = {
|
|
"presences": "scoped_by_reunion",
|
|
"pv_reunions": "scoped_by_reunion",
|
|
}
|
|
for model in [Presence, PvReunion]:
|
|
with self.subTest(model=model.__name__):
|
|
self.assertNotIn("groupe_id", model.__table__.columns)
|
|
self.assertIn(model.__tablename__, exceptions)
|
|
|
|
def test_role_fallback_queries_are_group_bounded(self):
|
|
root = Path(__file__).resolve().parents[1]
|
|
for rel in [
|
|
"app/routers/jetons.py",
|
|
"app/routers/ventes_litterature.py",
|
|
"app/routers/litterature.py",
|
|
"app/routers/rapports_rsg.py",
|
|
"app/routers/commandes_jetons.py",
|
|
]:
|
|
source = (root / rel).read_text(encoding="utf-8")
|
|
with self.subTest(router=rel):
|
|
self.assertIn("Affectation.groupe_id == membre.groupe_id", source)
|
|
self.assertIn("Poste.groupe_id == membre.groupe_id", source)
|
|
|
|
def test_presence_batch_validates_members_in_current_group(self):
|
|
source = (Path(__file__).resolve().parents[1] / "app/routers/presences.py").read_text(encoding="utf-8")
|
|
self.assertIn("mbr.groupe_id != membre.groupe_id", source)
|
|
self.assertIn("reunion.groupe_id != invitation.groupe_id", source)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|