feat(auth): politique de complexite du mot de passe et flux de reinitialisation
Remplace la regle de longueur seule (12 caracteres) par une exigence de composition (8 caracteres minimum, majuscule, minuscule, chiffre, caractere special), non documentee dans les exigences officielles du projet, par une regle explicite partagee entre le backend (validateur Pydantic) et le frontend. Ajoute un flux "mot de passe oublie" en libre-service, absent jusqu'ici : jeton a usage unique hache en base (meme principe que les refresh tokens), expirant a 15 minutes, envoye par email via un service SMTP (aiosmtplib, Mailpit en dev), avec limitation de debit dediee et reponse generique pour eviter l'enumeration des comptes. Closes #87
This commit is contained in:
@@ -11,6 +11,7 @@ from app.core.roles import AccountKind, Role
|
||||
from app.services.auth import (
|
||||
AuthenticatedSession,
|
||||
InvalidCredentialsError,
|
||||
InvalidOrExpiredResetTokenError,
|
||||
RateLimitedError,
|
||||
SessionRejectedError,
|
||||
)
|
||||
@@ -36,6 +37,14 @@ class FauxService:
|
||||
async def logout(self, **_: object) -> None:
|
||||
return None
|
||||
|
||||
async def request_password_reset(self, **_: object) -> None:
|
||||
if self._erreur is not None:
|
||||
raise self._erreur
|
||||
return None
|
||||
|
||||
async def confirm_password_reset(self, **_: object) -> AuthenticatedSession:
|
||||
return await self.authenticate()
|
||||
|
||||
async def authenticate(self, **_: object) -> AuthenticatedSession:
|
||||
if self._erreur is not None:
|
||||
raise self._erreur
|
||||
@@ -206,3 +215,94 @@ async def test_a_cookie_bearing_route_accepts_a_request_without_origin(
|
||||
response = await client.post("/api/v1/auth/logout")
|
||||
|
||||
assert response.status_code != 403
|
||||
|
||||
|
||||
async def test_forgot_password_answers_202_when_the_account_exists(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"}
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
async def test_forgot_password_answers_202_identically_when_the_account_is_unknown(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/forgot-password", json={"email": "inconnu@enervision.fr"}
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
|
||||
|
||||
async def test_forgot_password_returns_429_with_a_retry_after_when_the_rate_limit_is_reached(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
fake_auth_service[0] = RateLimitedError(900)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"}
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.headers["retry-after"] == "900"
|
||||
|
||||
|
||||
async def test_forgot_password_rejects_a_malformed_email(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post("/api/v1/auth/forgot-password", json={"email": "pas-un-email"})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_reset_password_returns_the_token_and_the_cookie_on_success(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.cookies.get("ev_refresh") is not None
|
||||
assert "refresh_secret" not in response.text
|
||||
|
||||
|
||||
async def test_reset_password_rejects_an_invalid_or_expired_token(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
fake_auth_service[0] = InvalidOrExpiredResetTokenError("Lien invalide ou expiré")
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
json={"token": "un-secret-perime", "new_password": "Un-nouveau-mot-de-passe1!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
async def test_reset_password_rejects_a_weak_password(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
json={"token": "un-secret-opaque", "new_password": "trop-simple"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_reset_password_refuses_a_foreign_origin(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"},
|
||||
headers={"Origin": "https://malveillant.example"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -18,6 +18,10 @@ ROUTES_PUBLIQUES = frozenset(
|
||||
("POST", "/api/v1/auth/login"),
|
||||
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
|
||||
("POST", "/api/v1/auth/logout"),
|
||||
("POST", "/api/v1/auth/forgot-password"),
|
||||
# Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune
|
||||
# authentification préalable ne s'applique, c'est la validité du jeton qui tranche.
|
||||
("POST", "/api/v1/auth/reset-password"),
|
||||
("GET", "/metrics"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Le premier test démontre l'atomicité de `consume()` : sur un double, deux soumissions
|
||||
# concurrentes du même lien réussiraient toutes les deux.
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.roles import Role
|
||||
from app.core.security import fingerprint_refresh, generate_refresh_secret
|
||||
from app.repositories.password_reset_token import PasswordResetTokenRepository
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
DUREE = timedelta(minutes=15)
|
||||
|
||||
|
||||
async def un_compte(session: AsyncSession) -> uuid.UUID:
|
||||
compte = await UserRepository(session).create(
|
||||
email=f"reset-{uuid.uuid4().hex[:12]}@enervision.fr",
|
||||
password_hash="$argon2id$x",
|
||||
role=Role.LECTEUR,
|
||||
)
|
||||
return compte.id
|
||||
|
||||
|
||||
async def un_jeton(
|
||||
depot: PasswordResetTokenRepository, user_id: uuid.UUID, *, duree: timedelta = DUREE
|
||||
) -> str:
|
||||
secret = generate_refresh_secret()
|
||||
await depot.create(
|
||||
user_id=user_id,
|
||||
token_hash=fingerprint_refresh(secret),
|
||||
expires_at=datetime.now(UTC) + duree,
|
||||
client_ip="203.0.113.10",
|
||||
user_agent="pytest",
|
||||
)
|
||||
return secret
|
||||
|
||||
|
||||
async def test_consume_only_succeeds_once(session: AsyncSession) -> None:
|
||||
depot = PasswordResetTokenRepository(session)
|
||||
secret = await un_jeton(depot, await un_compte(session))
|
||||
|
||||
premier = await depot.consume(fingerprint_refresh(secret))
|
||||
second = await depot.consume(fingerprint_refresh(secret))
|
||||
await session.rollback()
|
||||
|
||||
assert premier is not None
|
||||
assert second is None
|
||||
|
||||
|
||||
async def test_consume_refuses_an_expired_token(session: AsyncSession) -> None:
|
||||
depot = PasswordResetTokenRepository(session)
|
||||
secret = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1))
|
||||
|
||||
revendique = await depot.consume(fingerprint_refresh(secret))
|
||||
await session.rollback()
|
||||
|
||||
assert revendique is None
|
||||
|
||||
|
||||
async def test_consume_returns_nothing_for_an_unknown_fingerprint(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
revendique = await PasswordResetTokenRepository(session).consume(
|
||||
fingerprint_refresh(generate_refresh_secret())
|
||||
)
|
||||
|
||||
assert revendique is None
|
||||
|
||||
|
||||
async def test_invalidate_all_for_user_only_touches_living_tokens(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
depot = PasswordResetTokenRepository(session)
|
||||
compte = await un_compte(session)
|
||||
await un_jeton(depot, compte)
|
||||
await un_jeton(depot, compte)
|
||||
|
||||
invalides = await depot.invalidate_all_for_user(compte)
|
||||
second_passage = await depot.invalidate_all_for_user(compte)
|
||||
await session.rollback()
|
||||
|
||||
assert invalides == 2
|
||||
assert second_passage == 0
|
||||
|
||||
|
||||
async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
depot = PasswordResetTokenRepository(session)
|
||||
compte = await un_compte(session)
|
||||
secret = generate_refresh_secret()
|
||||
await depot.create(
|
||||
user_id=compte,
|
||||
token_hash=fingerprint_refresh(secret),
|
||||
expires_at=datetime.now(UTC) + DUREE,
|
||||
client_ip=None,
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await depot.create(
|
||||
user_id=compte,
|
||||
token_hash=fingerprint_refresh(secret),
|
||||
expires_at=datetime.now(UTC) + DUREE,
|
||||
client_ip=None,
|
||||
user_agent=None,
|
||||
)
|
||||
await session.rollback()
|
||||
@@ -0,0 +1,41 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.auth import PasswordChangeRequest, valide_complexite
|
||||
|
||||
MOT_DE_PASSE_VALIDE = "Un-mot-de-passe1!"
|
||||
|
||||
|
||||
def test_password_change_request_accepts_a_password_covering_the_four_classes() -> None:
|
||||
requete = PasswordChangeRequest(
|
||||
current_password="peu-importe", new_password=MOT_DE_PASSE_VALIDE
|
||||
)
|
||||
|
||||
assert requete.new_password == MOT_DE_PASSE_VALIDE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"new_password",
|
||||
[
|
||||
"un-mot-de-passe1!",
|
||||
"UN-MOT-DE-PASSE1!",
|
||||
"Un-mot-de-passe!",
|
||||
"Un mot de passe 1",
|
||||
],
|
||||
ids=["sans_majuscule", "sans_minuscule", "sans_chiffre", "sans_caractere_special"],
|
||||
)
|
||||
def test_password_change_request_rejects_a_password_missing_a_character_class(
|
||||
new_password: str,
|
||||
) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
PasswordChangeRequest(current_password="peu-importe", new_password=new_password)
|
||||
|
||||
|
||||
def test_password_change_request_rejects_a_password_below_the_minimum_length() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
PasswordChangeRequest(current_password="peu-importe", new_password="Ab1!")
|
||||
|
||||
|
||||
def test_valide_complexite_names_every_missing_class_in_the_error() -> None:
|
||||
with pytest.raises(ValueError, match=r"majuscule.*chiffre|chiffre.*majuscule"):
|
||||
valide_complexite("minuscules-seulement")
|
||||
@@ -16,11 +16,15 @@ from app.core.security import (
|
||||
from app.models.login_attempt import LoginOutcome
|
||||
from app.models.refresh_token import RevocationReason
|
||||
from app.repositories.login_attempt import FailureCounts
|
||||
from app.repositories.password_reset_attempt import ResetRequestCounts
|
||||
from app.repositories.password_reset_token import ConsumedResetToken
|
||||
from app.repositories.refresh_token import ClaimedToken
|
||||
from app.services.auth import (
|
||||
AuthService,
|
||||
InvalidCredentialsError,
|
||||
InvalidOrExpiredResetTokenError,
|
||||
LoginPolicy,
|
||||
PasswordResetPolicy,
|
||||
RateLimitedError,
|
||||
SessionRejectedError,
|
||||
)
|
||||
@@ -37,6 +41,13 @@ POLITIQUE_CONNEXION = LoginPolicy(
|
||||
max_failures_per_ip=20,
|
||||
max_failures_per_identifier=50,
|
||||
)
|
||||
POLITIQUE_RESET = PasswordResetPolicy(
|
||||
window_seconds=900,
|
||||
max_requests_per_identifier=3,
|
||||
max_requests_per_ip=10,
|
||||
token_ttl=timedelta(minutes=15),
|
||||
frontend_reset_url="http://localhost:4200/reset-password",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -168,6 +179,43 @@ class FausseTransaction:
|
||||
self.validations += 1
|
||||
|
||||
|
||||
class FauxDepotJetonsReset:
|
||||
def __init__(self, revendique: ConsumedResetToken | None = None) -> None:
|
||||
self.revendique = revendique
|
||||
self.crees: list[UUID] = []
|
||||
self.invalidations: list[UUID] = []
|
||||
|
||||
async def create(self, *, user_id: UUID, **_: object) -> None:
|
||||
self.crees.append(user_id)
|
||||
|
||||
async def consume(self, token_hash: bytes) -> ConsumedResetToken | None:
|
||||
return self.revendique
|
||||
|
||||
async def invalidate_all_for_user(self, user_id: UUID) -> int:
|
||||
self.invalidations.append(user_id)
|
||||
return len(self.invalidations)
|
||||
|
||||
|
||||
class FauxDepotTentativesReset:
|
||||
def __init__(self, compteurs: ResetRequestCounts | None = None) -> None:
|
||||
self.compteurs = compteurs or ResetRequestCounts(0, 0)
|
||||
self.enregistrees: list[str] = []
|
||||
|
||||
async def count_recent(self, **_: object) -> ResetRequestCounts:
|
||||
return self.compteurs
|
||||
|
||||
async def record(self, *, email: str, **_: object) -> None:
|
||||
self.enregistrees.append(email)
|
||||
|
||||
|
||||
class FauxMailer:
|
||||
def __init__(self) -> None:
|
||||
self.envois: list[tuple[str, str]] = []
|
||||
|
||||
async def send_password_reset_email(self, *, to: str, reset_url: str) -> None:
|
||||
self.envois.append((to, reset_url))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Attirail:
|
||||
service: AuthService
|
||||
@@ -176,6 +224,9 @@ class Attirail:
|
||||
jetons: FauxDepotJetons
|
||||
audit: FauxDepotAudit
|
||||
hacheur: FauxHacheur
|
||||
jetons_reset: FauxDepotJetonsReset
|
||||
tentatives_reset: FauxDepotTentativesReset
|
||||
mailer: FauxMailer
|
||||
|
||||
|
||||
def fabrique_service(
|
||||
@@ -184,12 +235,17 @@ def fabrique_service(
|
||||
compteurs: FailureCounts | None = None,
|
||||
hacheur: FauxHacheur | None = None,
|
||||
jetons: FauxDepotJetons | None = None,
|
||||
jetons_reset: FauxDepotJetonsReset | None = None,
|
||||
compteurs_reset: ResetRequestCounts | None = None,
|
||||
) -> Attirail:
|
||||
comptes = FauxDepotComptes(compte)
|
||||
tentatives = FauxDepotTentatives(compteurs)
|
||||
depot_jetons = jetons or FauxDepotJetons()
|
||||
audit = FauxDepotAudit()
|
||||
hacheur = hacheur or FauxHacheur()
|
||||
depot_jetons_reset = jetons_reset or FauxDepotJetonsReset()
|
||||
tentatives_reset = FauxDepotTentativesReset(compteurs_reset)
|
||||
mailer = FauxMailer()
|
||||
service = AuthService(
|
||||
users=comptes, # type: ignore[arg-type]
|
||||
attempts=tentatives, # type: ignore[arg-type]
|
||||
@@ -200,8 +256,22 @@ def fabrique_service(
|
||||
token_policy=POLITIQUE_JETON,
|
||||
login_policy=POLITIQUE_CONNEXION,
|
||||
refresh_ttl=timedelta(days=7),
|
||||
reset_tokens=depot_jetons_reset, # type: ignore[arg-type]
|
||||
reset_attempts=tentatives_reset, # type: ignore[arg-type]
|
||||
reset_policy=POLITIQUE_RESET,
|
||||
mailer=mailer, # type: ignore[arg-type]
|
||||
)
|
||||
return Attirail(
|
||||
service,
|
||||
comptes,
|
||||
tentatives,
|
||||
depot_jetons,
|
||||
audit,
|
||||
hacheur,
|
||||
depot_jetons_reset,
|
||||
tentatives_reset,
|
||||
mailer,
|
||||
)
|
||||
return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur)
|
||||
|
||||
|
||||
async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object:
|
||||
@@ -493,3 +563,89 @@ async def test_change_password_refuses_a_wrong_current_password() -> None:
|
||||
|
||||
assert attirail.jetons.revocations_par_compte == []
|
||||
assert attirail.jetons.crees == []
|
||||
|
||||
|
||||
async def test_request_password_reset_emails_a_link_when_the_account_exists() -> None:
|
||||
compte = FauxCompte()
|
||||
attirail = fabrique_service(compte=compte)
|
||||
|
||||
await attirail.service.request_password_reset(
|
||||
email=compte.email, client_ip="203.0.113.10", user_agent="pytest"
|
||||
)
|
||||
|
||||
assert attirail.jetons_reset.invalidations == [compte.id]
|
||||
assert attirail.jetons_reset.crees == [compte.id]
|
||||
assert len(attirail.mailer.envois) == 1
|
||||
assert attirail.mailer.envois[0][0] == compte.email
|
||||
assert "auth.password_reset_requested" in attirail.audit.lignes[0][0]
|
||||
|
||||
|
||||
async def test_request_password_reset_stays_silent_when_the_account_is_unknown() -> None:
|
||||
attirail = fabrique_service(compte=None)
|
||||
|
||||
await attirail.service.request_password_reset(
|
||||
email="inconnu@enervision.fr", client_ip="203.0.113.10", user_agent="pytest"
|
||||
)
|
||||
|
||||
assert attirail.jetons_reset.crees == []
|
||||
assert attirail.mailer.envois == []
|
||||
assert attirail.hacheur.verifications == 1, "le hachage factice doit tout de même tourner"
|
||||
|
||||
|
||||
async def test_request_password_reset_stays_silent_when_the_account_is_inactive() -> None:
|
||||
compte = FauxCompte(is_active=False)
|
||||
attirail = fabrique_service(compte=compte)
|
||||
|
||||
await attirail.service.request_password_reset(
|
||||
email=compte.email, client_ip="203.0.113.10", user_agent="pytest"
|
||||
)
|
||||
|
||||
assert attirail.jetons_reset.crees == []
|
||||
assert attirail.mailer.envois == []
|
||||
|
||||
|
||||
async def test_request_password_reset_raises_when_the_rate_limit_is_reached() -> None:
|
||||
attirail = fabrique_service(compteurs_reset=ResetRequestCounts(per_identifier=3, per_ip=0))
|
||||
|
||||
with pytest.raises(RateLimitedError):
|
||||
await attirail.service.request_password_reset(
|
||||
email="operateur@enervision.fr", client_ip="203.0.113.10", user_agent="pytest"
|
||||
)
|
||||
|
||||
assert attirail.mailer.envois == []
|
||||
|
||||
|
||||
async def test_confirm_password_reset_revokes_every_session_then_reopens_the_current_one() -> None:
|
||||
compte = FauxCompte()
|
||||
jetons_reset = FauxDepotJetonsReset(
|
||||
revendique=ConsumedResetToken(id=uuid4(), user_id=compte.id)
|
||||
)
|
||||
attirail = fabrique_service(compte=compte, jetons_reset=jetons_reset)
|
||||
|
||||
session = await attirail.service.confirm_password_reset(
|
||||
token="un-secret-opaque",
|
||||
new_password="Un-nouveau-mot-de-passe1!",
|
||||
client_ip="203.0.113.10",
|
||||
user_agent="pytest",
|
||||
)
|
||||
|
||||
assert attirail.jetons.revocations_par_compte == [
|
||||
(compte.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value)
|
||||
]
|
||||
assert len(attirail.jetons.crees) == 1
|
||||
assert session.refresh_secret
|
||||
assert "auth.password_reset_self_service" in attirail.audit.lignes[0][0]
|
||||
|
||||
|
||||
async def test_confirm_password_reset_rejects_an_invalid_or_expired_token() -> None:
|
||||
attirail = fabrique_service(jetons_reset=FauxDepotJetonsReset(revendique=None))
|
||||
|
||||
with pytest.raises(InvalidOrExpiredResetTokenError):
|
||||
await attirail.service.confirm_password_reset(
|
||||
token="un-secret-invalide",
|
||||
new_password="Un-nouveau-mot-de-passe1!",
|
||||
client_ip=None,
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
assert attirail.jetons.revocations_par_compte == []
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from app import cli
|
||||
from app.schemas.auth import valide_complexite
|
||||
|
||||
|
||||
def test_build_parser_reads_the_create_admin_arguments() -> None:
|
||||
@@ -34,26 +35,36 @@ def test_read_password_generates_a_long_secret_when_asked(
|
||||
|
||||
assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE
|
||||
assert mot_de_passe in capsys.readouterr().out
|
||||
valide_complexite(mot_de_passe)
|
||||
|
||||
|
||||
def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"])
|
||||
saisies = iter(["Un-mot-de-passe-valide1", "Un-mot-de-passe-valide1"])
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
|
||||
|
||||
assert cli.read_password(generate=False) == "un-mot-de-passe-valide"
|
||||
assert cli.read_password(generate=False) == "Un-mot-de-passe-valide1"
|
||||
|
||||
|
||||
def test_read_password_refuses_a_password_below_the_minimum_length(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: "court")
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: "Court1!")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.read_password(generate=False)
|
||||
|
||||
|
||||
def test_read_password_refuses_a_password_missing_a_character_class(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: "un-mot-de-passe-sans-majuscule-ni-chiffre")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.read_password(generate=False)
|
||||
|
||||
|
||||
def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"])
|
||||
saisies = iter(["Un-mot-de-passe-valide1", "Un-autre-mot-de-passe2"])
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
|
||||
Reference in New Issue
Block a user