-
Notifications
You must be signed in to change notification settings - Fork 4
Tests add in ci #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Tests add in ci #47
Conversation
…teway health checks - Created a test suite in `srcs/tests` with README documentation. - Implemented tests for authentication features: registration, login, logout, token verification, and 2FA setup. - Added tests for admin functionalities and rate limiting. - Developed a gateway test suite to check health endpoints for all services. - Introduced helper functions for managing test sessions and generating test credentials. - Updated profiles.routes.ts to standardize health check response.
…t la gestion des QR codes
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces a comprehensive Python-based functional test suite for the authentication, 2FA, and gateway services, along with GitHub Actions CI integration. The tests cover ~40 scenarios including registration validation, login flows, 2FA setup/verification, admin authorization, and health checks.
Changes:
- Added Python test infrastructure with 913 lines of auth tests, gateway health tests, and reusable test helpers
- Integrated functional tests into CI pipeline with proper service orchestration and health check waiting
- Modified backend API responses to match test expectations (HTTP status codes, response structures)
- Enhanced error handling to preserve upstream status codes and properly respond to clients
- Adjusted rate limiting and username constraints to support test execution
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 25 comments.
Show a summary per file
| File | Description |
|---|---|
srcs/tests/test_auth.py |
Comprehensive auth service tests covering registration, login, 2FA, admin, rate limiting (29 test cases) |
srcs/tests/test_gateway.py |
Gateway health check tests for all microservices |
srcs/tests/test_helpers.py |
Shared test utilities: session management, credential generation, QR code decoding |
srcs/tests/test.py |
Test runner that executes all test suites sequentially |
srcs/tests/requirements.txt |
Python dependencies (requests, Pillow, pyzbar, pyotp) |
srcs/tests/README.md |
Test documentation with setup instructions and troubleshooting |
.github/workflows/ci.yml |
CI workflow with functional-tests job including Python setup and Docker orchestration |
srcs/auth/src/controllers/auth.controller.ts |
Modified response formats and error handling for register/login/verify/me endpoints |
srcs/auth/src/utils/constants.ts |
Increased rate limits for testing and reduced username max length to 20 chars |
srcs/auth/src/utils/error-catalog.ts |
Corrected HTTP status codes (409 for conflicts) |
srcs/auth/src/services/external/um.service.ts |
Enhanced to preserve upstream service status codes and error messages |
srcs/auth/src/index.ts |
Fixed global error handler to actually send error responses to clients |
srcs/gateway/src/controllers/health.controller.ts |
Improved health check to use service names as keys instead of host:port |
srcs/gateway/src/utils/constants.ts |
Added /auth/logout and /users/health to public routes |
srcs/users/src/routes/profiles.routes.ts |
Simplified health check response message |
srcs/nginx/src/html/index.html |
Added 2FA status and role fields to user profile display |
.gitignore |
Added Python-specific exclusions (*.pyc, pycache) and removed blanket *.txt exclusion |
|
|
||
|
|
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test function test_27_2fa_invalid_format_login is incomplete - it sets up 2FA but never actually tests the invalid format scenario.
Current behavior:
- Creates user and enables 2FA (lines 794-801)
- Logs out (line 802)
- Test ends - no actual test of invalid format during login!
What's missing:
# After line 802, add:
session2 = TestSession()
session2.post("/auth/login", json={"username": creds["username"], "password": creds["password"]})
# Now test invalid format
resp = session2.post("/auth/2fa/verify", json={"code": "invalid"}, expected_status=400)
data = resp.json()
assert data.get("error", {}).get("code") == "INVALID_CODE_FORMAT"
print_success("Format invalide correctement rejeté (login)")Impact: This test is marked as passing but doesn't actually verify the behavior it claims to test. The test name suggests it should verify that non-numeric codes are rejected during the login 2FA flow, similar to test_22_2fa_setup_bad_format.
| # Tentative de login avec code 2FA au format invalide | |
| session2 = TestSession() | |
| session2.post( | |
| "/auth/login", | |
| json={"username": creds["username"], "password": creds["password"]}, | |
| ) | |
| resp = session2.post("/auth/2fa/verify", json={"code": "invalid"}, expected_status=400) | |
| data = resp.json() | |
| assert data.get("error", {}).get("code") == "INVALID_CODE_FORMAT" | |
| print_success("Format invalide correctement rejeté (login)") |
| #!/usr/bin/env python3 | ||
| """ | ||
| Tests fonctionnels CI/CD - Service Auth | ||
| Tests: Register, Login, Logout, Verify, Me, 2FA, Admin | ||
| """ | ||
| import sys | ||
| import time | ||
| import pyotp | ||
| from test_helpers import ( | ||
| TestSession, | ||
| generate_test_credentials, | ||
| print_test, | ||
| print_success, | ||
| print_error, | ||
| decode_qr_secret, | ||
| QR_DECODE_AVAILABLE, | ||
| API_URL, | ||
| ) | ||
|
|
||
| # Credentials pré-configurés | ||
| ADMIN_CREDS = {"username": "admin", "password": "Admin123!"} | ||
| INVITE_CREDS = {"username": "invite", "password": "Invite123!"} | ||
|
|
||
|
|
||
| def skip_if_no_qr_decode(test_name: str) -> bool: | ||
| """Skip le test si le décodage QR n'est pas disponible""" | ||
| if not QR_DECODE_AVAILABLE: | ||
| print(f" ⏭️ SKIPPED: {test_name} (pyzbar/libzbar not available)") | ||
| return True | ||
| return False | ||
|
|
||
|
|
||
| def safe_decode_qr_or_skip(qr_data_url: str, test_name: str) -> str: | ||
| """Décode le QR ou skip le test si impossible""" | ||
| secret = decode_qr_secret(qr_data_url) | ||
| if not secret: | ||
| if QR_DECODE_AVAILABLE: | ||
| raise AssertionError(f"FAILED: Impossible d'extraire le secret depuis le QR code") | ||
| else: | ||
| print(f" ⏭️ SKIPPED: {test_name} (QR decode not available)") | ||
| sys.exit(0) # Skip ce test proprement | ||
| return secret | ||
|
|
||
|
|
||
| def test_01_health_check(): | ||
| """Test: Health check du service auth""" | ||
| print_test("Health check - /auth/health") | ||
|
|
||
| session = TestSession() | ||
| response = session.get("/auth/health") | ||
|
|
||
| data = response.json() | ||
| assert data.get("status") == "healthy", "Health check failed" | ||
|
|
||
| print_success("Service auth est opérationnel") | ||
|
|
||
|
|
||
| def test_02_register_success(): | ||
| """Test: Création de compte réussie""" | ||
| print_test("Register - Création compte valide") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| response = session.post("/auth/register", json=creds, expected_status=201) | ||
| data = response.json() | ||
|
|
||
| assert "user" in data, "User not in response" | ||
| assert data["user"]["username"] == creds["username"], "Username mismatch" | ||
|
|
||
| print_success(f"Compte créé: {creds['username']}") | ||
| return creds | ||
|
|
||
|
|
||
| def test_03_register_duplicate(): | ||
| """Test: Création compte déjà existant (409)""" | ||
| print_test("Register - Compte existant (409)") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| # Première création | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
|
|
||
| # Tentative de duplication | ||
| response = session.post("/auth/register", json=creds, expected_status=409) | ||
| data = response.json() | ||
|
|
||
| assert "error" in data, "Error not in response" | ||
| assert "exist" in data["error"].lower() or "conflict" in data["error"].lower(), \ | ||
| "Error message doesn't indicate conflict" | ||
|
|
||
| print_success("409 Conflict détecté correctement") | ||
|
|
||
|
|
||
| def test_03b_register_duplicate_email(): | ||
| """Test: Création compte avec email déjà utilisé (409)""" | ||
| print_test("Register - Email existant (409)") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| # Première création | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
|
|
||
| # Tentative de duplication email avec autre username | ||
| dup = generate_test_credentials() | ||
| dup["email"] = creds["email"] | ||
|
|
||
| resp = session.post("/auth/register", json=dup, expected_status=409) | ||
| data = resp.json() | ||
| assert "error" in data, "Error not in response" | ||
| print_success("409 Conflict détecté sur email") | ||
|
|
||
|
|
||
| def test_04_register_invalid_username(): | ||
| """Test: Register avec username invalide (400)""" | ||
| print_test("Register - Username invalide (400)") | ||
|
|
||
| session = TestSession() | ||
|
|
||
| invalid_usernames = [ | ||
| "ab", # Trop court | ||
| "user-name", # Caractère invalide (tiret) | ||
| "admin", # Réservé | ||
| "root", # Réservé | ||
| "a" * 51, # Trop long | ||
| "user name", # Espace | ||
| "user@name", # Caractère invalide (@) | ||
| "user$name", # Caractère invalide ($) | ||
| "user.name", # Caractère invalide (.) | ||
| ] | ||
|
|
||
| for invalid_user in invalid_usernames: | ||
| creds = { | ||
| "username": invalid_user, | ||
| "email": "test@test.local", | ||
| "password": "ValidPass123!" | ||
| } | ||
|
|
||
| response = session.post("/auth/register", json=creds, expected_status=400) | ||
| data = response.json() | ||
| assert "error" in data, f"Error not in response for {invalid_user}" | ||
| print_success(f"Username '{invalid_user}' rejeté") | ||
|
|
||
|
|
||
| def test_04b_register_invalid_email_formats(): | ||
| """Test: Register avec emails invalides (400)""" | ||
| print_test("Register - Email invalide (400)") | ||
|
|
||
| session = TestSession() | ||
| invalid_emails = [ | ||
| "plainaddress", | ||
| "missing-at.test.local", | ||
| "missing-domain@", | ||
| "@nouser.test", | ||
| "user@domain@other", | ||
| "user domain@test.local", | ||
| ] | ||
|
|
||
| for email in invalid_emails: | ||
| creds = { | ||
| "username": generate_test_credentials()["username"], | ||
| "email": email, | ||
| "password": "ValidPass123!", | ||
| } | ||
| resp = session.post("/auth/register", json=creds, expected_status=400) | ||
| data = resp.json() | ||
| assert "error" in data, f"Error not in response for email: {email}" | ||
| print_success(f"Email invalide rejeté: {email}") | ||
|
|
||
|
|
||
| def test_04c_register_email_too_long(): | ||
| """Test: Register avec email > 100 chars (400)""" | ||
| print_test("Register - Email trop long (400)") | ||
|
|
||
| session = TestSession() | ||
| local = "a" * 60 | ||
| domain = "b" * 41 # 60 + 1(@) + 41 + 4(.com) = 106 | ||
| long_email = f"{local}@{domain}.com" | ||
|
|
||
| creds = generate_test_credentials() | ||
| creds["email"] = long_email | ||
|
|
||
| resp = session.post("/auth/register", json=creds, expected_status=400) | ||
| data = resp.json() | ||
| assert "error" in data, "Error not in response for long email" | ||
| print_success("Email >100 chars rejeté") | ||
|
|
||
|
|
||
| def test_05_register_invalid_password(): | ||
| """Test: Register avec password invalide (400)""" | ||
| print_test("Register - Password invalide (400)") | ||
|
|
||
| session = TestSession() | ||
|
|
||
| invalid_passwords = [ | ||
| "short1!", # Trop court | ||
| "nouppercase123!", # Pas de majuscule | ||
| "NOLOWERCASE123!", # Pas de minuscule | ||
| "NoDigits!!!", # Pas de chiffre | ||
| "NoSpecial123", # Pas de caractère spécial | ||
| ] | ||
|
|
||
| for invalid_pass in invalid_passwords: | ||
| creds = { | ||
| "username": f"testuser{time.time()}", | ||
| "email": "test@test.local", | ||
| "password": invalid_pass | ||
| } | ||
|
|
||
| response = session.post("/auth/register", json=creds, expected_status=400) | ||
| data = response.json() | ||
| assert "error" in data, f"Error not in response for password: {invalid_pass}" | ||
| print_success(f"Password invalide rejeté") | ||
|
|
||
|
|
||
| def test_05b_register_password_too_long(): | ||
| """Test: Register avec password > 100 chars (400)""" | ||
| print_test("Register - Password trop long (400)") | ||
|
|
||
| session = TestSession() | ||
| long_pass = "A" * 101 + "!1a" # >100 chars | ||
| creds = generate_test_credentials() | ||
| creds["password"] = long_pass | ||
|
|
||
| resp = session.post("/auth/register", json=creds, expected_status=400) | ||
| data = resp.json() | ||
| assert "error" in data, "Error not in response pour password trop long" | ||
| print_success("Password >100 chars rejeté") | ||
|
|
||
|
|
||
| def test_06_login_success(): | ||
| """Test: Login réussi avec username""" | ||
| print_test("Login - Succès avec username") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| # Créer le compte | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
|
|
||
| # Login | ||
| response = session.post("/auth/login", json={ | ||
| "username": creds["username"], | ||
| "password": creds["password"] | ||
| }) | ||
|
|
||
| data = response.json() | ||
| assert "message" in data and "success" in data["message"].lower(), ( | ||
| "Login success message not found" | ||
| ) | ||
|
|
||
| # Vérifier le cookie token | ||
| assert "token" in session.session.cookies, "Token cookie not set" | ||
|
|
||
| print_success(f"Login réussi: {creds['username']}") | ||
| return session, creds | ||
|
|
||
|
|
||
| def test_06b_login_email_invalid_format(): | ||
| """Test: Login échoue si email mal formé (400)""" | ||
| print_test("Login - Email mal formé") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
|
|
||
| resp = session.post("/auth/login", json={"email": "bad-email", "password": creds["password"]}, expected_status=400) | ||
| data = resp.json() | ||
| assert "error" in data, "Error not in response for invalid email" | ||
| print_success("Email mal formé rejeté au login") | ||
|
|
||
|
|
||
| def test_06c_login_with_username_and_email(): | ||
| """Test: Login accepte username + email fournis (priorité peu importe)""" | ||
| print_test("Login - Username et email fournis") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
|
|
||
| resp = session.post("/auth/login", json={ | ||
| "username": creds["username"], | ||
| "email": creds["email"], | ||
| "password": creds["password"], | ||
| }) | ||
| assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" | ||
| print_success("Login fonctionne avec username+email") | ||
|
|
||
|
|
||
| def test_07_login_with_email(): | ||
| """Test: Login réussi avec email""" | ||
| print_test("Login - Succès avec email") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| # Créer le compte | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
|
|
||
| # Login avec email | ||
| response = session.post("/auth/login", json={ | ||
| "email": creds["email"], | ||
| "password": creds["password"] | ||
| }) | ||
|
|
||
| data = response.json() | ||
| assert "message" in data and "success" in data["message"].lower(), ( | ||
| "Login success message not found" | ||
| ) | ||
|
|
||
| print_success(f"Login par email réussi: {creds['email']}") | ||
|
|
||
|
|
||
| def test_08_login_invalid_credentials(): | ||
| """Test: Login avec mauvais credentials (401)""" | ||
| print_test("Login - Credentials invalides (401)") | ||
|
|
||
| session = TestSession() | ||
|
|
||
| response = session.post("/auth/login", json={ | ||
| "username": "nonexistent_user", | ||
| "password": "WrongPass123!" | ||
| }, expected_status=401) | ||
|
|
||
| data = response.json() | ||
| assert "error" in data, "Error not in response" | ||
|
|
||
| print_success("401 Unauthorized pour credentials invalides") | ||
|
|
||
|
|
||
| def test_08b_login_missing_identifier(): | ||
| """Test: Login sans username/email (400)""" | ||
| print_test("Login - Identifiant manquant") | ||
|
|
||
| session = TestSession() | ||
| resp = session.post("/auth/login", json={"password": "whatever"}, expected_status=400) | ||
| data = resp.json() | ||
| assert data.get("error", {}).get("code") in {"VALIDATION_ERROR", "MISSING_IDENTIFIER"} | ||
| print_success("400 pour identifiant manquant") | ||
|
|
||
|
|
||
| def test_08c_login_missing_password(): | ||
| """Test: Login sans password (400)""" | ||
| print_test("Login - Password manquant") | ||
|
|
||
| session = TestSession() | ||
| resp = session.post("/auth/login", json={"username": "someone"}, expected_status=400) | ||
| data = resp.json() | ||
| assert data.get("error", {}).get("code") in {"VALIDATION_ERROR", "MISSING_PASSWORD"} | ||
| print_success("400 pour mot de passe manquant") | ||
|
|
||
|
|
||
| def test_09_verify_token(): | ||
| """Test: Vérification de token valide""" | ||
| print_test("Verify - Token valide") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| # Créer et login | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
| session.post("/auth/login", json={ | ||
| "username": creds["username"], | ||
| "password": creds["password"] | ||
| }) | ||
|
|
||
| # Vérifier le token | ||
| response = session.get("/auth/verify") | ||
| data = response.json() | ||
|
|
||
| assert "user" in data, "User not in verify response" | ||
| assert data["user"]["username"] == creds["username"], "Username mismatch in verify" | ||
|
|
||
| print_success("Token vérifié avec succès") | ||
|
|
||
|
|
||
| def test_09b_verify_after_logout(): | ||
| """Test: Verify échoue après logout (cookie supprimé)""" | ||
| print_test("Verify - Après logout") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
| session.post("/auth/login", json={"username": creds["username"], "password": creds["password"]}) | ||
|
|
||
| session.post("/auth/logout", expected_status=200) | ||
| resp = session.get("/auth/verify", expected_status=401) | ||
| data = resp.json() | ||
| assert data.get("error", {}).get("code") in ("TOKEN_MISSING", "INVALID_TOKEN"), "Verify devrait échouer sans token" | ||
| print_success("Verify rejette après logout") | ||
|
|
||
|
|
||
| def test_10_verify_without_token(): | ||
| """Test: Vérification sans token (401)""" | ||
| print_test("Verify - Sans token (401)") | ||
|
|
||
| session = TestSession() | ||
| response = session.get("/auth/verify", expected_status=401) | ||
|
|
||
| data = response.json() | ||
| assert "error" in data, "Error not in response" | ||
|
|
||
| print_success("401 pour token manquant") | ||
|
|
||
|
|
||
| def test_10b_verify_invalid_token(): | ||
| """Test: Vérification avec token invalide (401)""" | ||
| print_test("Verify - Token invalide") | ||
|
|
||
| session = TestSession() | ||
| # Envoie un token bidon via Authorization | ||
| resp = session.session.get(f"{API_URL}/auth/verify", headers={"Authorization": "Bearer bad"}, verify=False) | ||
| assert resp.status_code == 401, f"Expected 401, got {resp.status_code}" | ||
| data = resp.json() | ||
| assert data.get("error", {}).get("code") in {"INVALID_TOKEN", "TOKEN_MISSING"} | ||
| print_success("401 pour token invalide") | ||
|
|
||
|
|
||
| def test_11_me_authenticated(): | ||
| """Test: Route /me avec authentification""" | ||
| print_test("Me - Utilisateur authentifié") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| # Créer et login | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
| session.post("/auth/login", json={ | ||
| "username": creds["username"], | ||
| "password": creds["password"] | ||
| }) | ||
|
|
||
| # Accéder à /me | ||
| response = session.get("/auth/me") | ||
| data = response.json() | ||
|
|
||
| assert "username" in data or "user" in data, "User info not in /me response" | ||
|
|
||
| print_success("Route /me accessible") | ||
|
|
||
|
|
||
| def test_12_me_unauthenticated(): | ||
| """Test: Route /me sans authentification (401)""" | ||
| print_test("Me - Sans authentification (401)") | ||
|
|
||
| session = TestSession() | ||
| response = session.get("/auth/me", expected_status=401) | ||
|
|
||
| data = response.json() | ||
| assert "error" in data, "Error not in response" | ||
|
|
||
| print_success("401 pour /me sans auth") | ||
|
|
||
|
|
||
| def test_13_logout(): | ||
| """Test: Logout et vérification de déconnexion""" | ||
| print_test("Logout - Déconnexion complète") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| # Créer et login | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
| session.post("/auth/login", json={ | ||
| "username": creds["username"], | ||
| "password": creds["password"] | ||
| }) | ||
|
|
||
| # Vérifier qu'on est connecté | ||
| session.get("/auth/me", expected_status=200) | ||
|
|
||
| # Logout | ||
| session.post("/auth/logout") | ||
|
|
||
| # Vérifier qu'on est déconnecté | ||
| session.get("/auth/me", expected_status=401) | ||
|
|
||
| print_success("Logout et déconnexion vérifiés") | ||
|
|
||
|
|
||
| def test_13b_logout_without_session(): | ||
| """Test: Logout sans session active (doit être idempotent)""" | ||
| print_test("Logout - Sans session") | ||
|
|
||
| session = TestSession() | ||
| resp = session.post("/auth/logout", expected_status=200) | ||
| data = resp.json() | ||
| assert data.get("result", {}).get("message"), "Message de logout manquant" | ||
| print_success("Logout sans session renvoie 200") | ||
|
|
||
|
|
||
| def test_14_admin_list_users(): | ||
| """Test: Admin peut lister les utilisateurs""" | ||
| print_test("Admin - List users") | ||
|
|
||
| session = TestSession() | ||
|
|
||
| # Login en tant qu'admin | ||
| session.post("/auth/login", json=ADMIN_CREDS) | ||
|
|
||
| # Lister les users | ||
| response = session.get("/auth/list") | ||
| data = response.json() | ||
|
|
||
| assert isinstance(data, list), "List should return an array" | ||
| assert len(data) > 0, "Should have at least admin user" | ||
|
|
||
| # Vérifier que admin est dans la liste | ||
| admin_found = any(user.get("username") == "admin" for user in data) | ||
| assert admin_found, "Admin user not found in list" | ||
|
|
||
| print_success(f"Admin list: {len(data)} utilisateurs") | ||
|
|
||
|
|
||
| def test_15_non_admin_cannot_list(): | ||
| """Test: Non-admin ne peut pas lister (403)""" | ||
| print_test("Admin - Non-admin interdit (403)") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| # Créer un user normal | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
| session.post("/auth/login", json={ | ||
| "username": creds["username"], | ||
| "password": creds["password"] | ||
| }) | ||
|
|
||
| # Tenter de lister | ||
| response = session.get("/auth/list", expected_status=403) | ||
| data = response.json() | ||
|
|
||
| assert "error" in data, "Error not in response" | ||
|
|
||
| print_success("403 Forbidden pour non-admin") | ||
|
|
||
|
|
||
| def test_16_2fa_setup(): | ||
| """Test: Setup 2FA et obtention du QR code""" | ||
| print_test("2FA - Setup et génération secret") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| # Créer et login | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
| session.post("/auth/login", json={ | ||
| "username": creds["username"], | ||
| "password": creds["password"] | ||
| }) | ||
|
|
||
| # Setup 2FA | ||
| response = session.post("/auth/2fa/setup") | ||
| data = response.json() | ||
|
|
||
| assert "result" in data, "result wrapper missing" | ||
| result = data["result"] | ||
| assert "qrCode" in result, "QR code not in 2FA setup response" | ||
| assert "2fa_setup_token" in session.session.cookies, "2FA setup token not set" | ||
|
|
||
| secret = safe_decode_qr_or_skip(result["qrCode"], "2FA Setup") | ||
|
|
||
| print_success("2FA setup: QR code décodé et secret récupéré") | ||
| return session, secret, creds | ||
|
|
||
|
|
||
| def test_17_2fa_verify_setup(): | ||
| """Test: Vérification du code 2FA lors du setup""" | ||
| print_test("2FA - Vérification code setup") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| # Créer et login | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
| session.post("/auth/login", json={"username": creds["username"], "password": creds["password"]}) | ||
|
|
||
| # Setup 2FA | ||
| setup_resp = session.post("/auth/2fa/setup") | ||
| setup_data = setup_resp.json()["result"] | ||
| if skip_if_no_qr_decode("2FA Vérification code setup"): | ||
| return | ||
|
|
||
| secret = safe_decode_qr_or_skip(setup_data["qrCode"], "2FA Vérification code setup") | ||
|
|
||
| # Générer un code TOTP valide | ||
| totp = pyotp.TOTP(secret) | ||
| code = totp.now() | ||
|
|
||
| # Vérifier le code | ||
| response = session.post("/auth/2fa/setup/verify", json={"code": code}) | ||
| data = response.json() | ||
|
|
||
| assert "result" in data, "result wrapper manquant" | ||
| assert "message" in data["result"], "Message de succès manquant" | ||
| assert "token" in session.session.cookies, "Token final non défini après 2FA setup" | ||
|
|
||
| print_success("2FA activé avec succès") | ||
| return session, secret, creds | ||
|
|
||
|
|
||
| def test_18_2fa_verify_invalid_code(): | ||
| """Test: Code 2FA invalide lors du setup (401)""" | ||
| print_test("2FA - Code invalide (401)") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| # Créer et login | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
| session.post("/auth/login", json={ | ||
| "username": creds["username"], | ||
| "password": creds["password"] | ||
| }) | ||
|
|
||
| # Setup 2FA | ||
| session.post("/auth/2fa/setup") | ||
|
|
||
| # Envoyer un code invalide | ||
| response = session.post("/auth/2fa/setup/verify", json={"code": "000000"}, expected_status=400) | ||
| data = response.json() | ||
|
|
||
| assert "error" in data, "Error not in response" | ||
| assert data["error"].get("code") == "INVALID_2FA_CODE", "Mauvais code d'erreur" | ||
|
|
||
| print_success("400 pour code 2FA invalide avec détail") | ||
|
|
||
|
|
||
| def test_18b_2fa_setup_when_already_enabled(): | ||
| """Test: Relancer setup alors que 2FA déjà active (400 2FA_ALREADY_ENABLED)""" | ||
| print_test("2FA - Setup déjà activé") | ||
|
|
||
| session, secret, creds = test_17_2fa_verify_setup() | ||
| resp = session.post("/auth/2fa/setup", expected_status=400) | ||
| data = resp.json() | ||
| assert data.get("error", {}).get("code") == "2FA_ALREADY_ENABLED" | ||
| print_success("Setup refusé car 2FA déjà active") | ||
|
|
||
|
|
||
| def test_19_2fa_login_flow(): | ||
| """Test: Login complet avec 2FA""" | ||
| print_test("2FA - Flow de login complet") | ||
|
|
||
| # Session 1: Setup 2FA | ||
| session1 = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| session1.post("/auth/register", json=creds, expected_status=201) | ||
| session1.post("/auth/login", json={ | ||
| "username": creds["username"], | ||
| "password": creds["password"] | ||
| }) | ||
|
|
||
| response = session1.post("/auth/2fa/setup") | ||
| if skip_if_no_qr_decode("2FA Flow de login complet"): | ||
| return | ||
|
|
||
| secret = safe_decode_qr_or_skip(response.json()["result"]["qrCode"], "2FA Flow de login complet") | ||
|
|
||
| totp = pyotp.TOTP(secret) | ||
| code = totp.now() | ||
|
|
||
| session1.post("/auth/2fa/setup/verify", json={"code": code}) | ||
| session1.post("/auth/logout") | ||
|
|
||
| # Session 2: Login avec 2FA | ||
| session2 = TestSession() | ||
| response = session2.post("/auth/login", json={ | ||
| "username": creds["username"], | ||
| "password": creds["password"] | ||
| }) | ||
|
|
||
| data = response.json() | ||
| assert "result" in data and data["result"].get("require2FA") is True, ( | ||
| "2FA requirement not indicated" | ||
| ) | ||
| assert "2fa_login_token" in session2.session.cookies, "2FA login token not set" | ||
|
|
||
| # Vérifier qu'on ne peut pas accéder à /me sans le code 2FA | ||
| session2.get("/auth/me", expected_status=401) | ||
|
|
||
| # Entrer le code 2FA | ||
| code = totp.now() | ||
| response = session2.post("/auth/2fa/verify", json={"code": code}) | ||
| data = response.json() | ||
|
|
||
| assert "result" in data and "message" in data["result"], ( | ||
| "2FA verification success message not found" | ||
| ) | ||
| assert "token" in session2.session.cookies, "Final token not set after 2FA verify" | ||
|
|
||
| # Vérifier qu'on peut maintenant accéder à /me | ||
| session2.get("/auth/me", expected_status=200) | ||
|
|
||
| print_success("Flow 2FA complet: login → code → accès") | ||
|
|
||
|
|
||
| def test_20_2fa_disable(): | ||
| """Test: Désactivation du 2FA""" | ||
| print_test("2FA - Désactivation") | ||
|
|
||
| session, secret, creds = test_17_2fa_verify_setup() | ||
|
|
||
| # Désactiver 2FA | ||
| response = session.post("/auth/2fa/disable") | ||
| data = response.json() | ||
|
|
||
| assert "result" in data and "disabled" in data["result"].get("message", "").lower(), ( | ||
| "2FA disable confirmation not found" | ||
| ) | ||
|
|
||
| # Vérifier qu'on peut se reconnecter sans 2FA | ||
| session.post("/auth/logout") | ||
|
|
||
| response = session.post("/auth/login", json={ | ||
| "username": creds["username"], | ||
| "password": creds["password"] | ||
| }) | ||
| data = response.json() | ||
|
|
||
| assert not (data.get("result", {}) or {}).get("require2FA", False), ( | ||
| "2FA still required after disable" | ||
| ) | ||
|
|
||
| print_success("2FA désactivé avec succès") | ||
|
|
||
|
|
||
| def test_20b_2fa_disable_not_enabled(): | ||
| """Test: Désactivation 2FA quand non activée (400 2FA_NOT_ENABLED)""" | ||
| print_test("2FA - Désactivation sans 2FA active") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
| session.post("/auth/register", json=creds, expected_status=201) | ||
| session.post("/auth/login", json={"username": creds["username"], "password": creds["password"]}) | ||
|
|
||
| resp = session.post("/auth/2fa/disable", expected_status=400) | ||
| data = resp.json() | ||
| assert data.get("error", {}).get("code") == "2FA_NOT_ENABLED" | ||
| print_success("Désactivation refusée car 2FA non activée") | ||
|
|
||
|
|
||
| def test_21_rate_limiting(): | ||
| """Test: Rate limiting sur login (5 req/5min)""" | ||
| print_test("Rate Limiting - Login (5 req/5min)") | ||
|
|
||
| session = TestSession() | ||
|
|
||
| # Faire plusieurs tentatives de login jusqu'à déclencher le rate limit si actif | ||
| got_429 = False | ||
| for i in range(10): | ||
| resp = session.post( | ||
| "/auth/login", | ||
| json={"username": "nonexistent", "password": "wrong"}, | ||
| expected_status=401 if not got_429 else 429, | ||
| ) | ||
| if resp.status_code == 429: | ||
| got_429 = True | ||
| print_success(f"Rate limit atteint après {i+1} requêtes") | ||
| break | ||
| time.sleep(0.05) | ||
|
|
||
| if not got_429: | ||
| print_success("Rate limit non atteint (comportement toléré en env de dev)") | ||
|
|
||
|
|
||
| def test_22_2fa_setup_bad_format(): | ||
| """Test: Code non numérique pour setup (400 INVALID_CODE_FORMAT)""" | ||
| print_test("2FA - Code format invalide (setup)") | ||
|
|
||
| session = TestSession() | ||
| creds = generate_test_credentials() | ||
|
|
||
| session.post("/auth/register", json=creds, expected_status=201) | ||
| session.post("/auth/login", json={"username": creds["username"], "password": creds["password"]}) | ||
| session.post("/auth/2fa/setup") | ||
|
|
||
| resp = session.post("/auth/2fa/setup/verify", json={"code": "abc"}, expected_status=400) | ||
| data = resp.json() | ||
| assert data.get("error", {}).get("code") == "INVALID_CODE_FORMAT" | ||
| print_success("Format invalide correctement rejeté (setup)") | ||
|
|
||
|
|
||
| def test_27_2fa_invalid_format_login(): | ||
| """Test: Code 2FA format invalide pendant login""" | ||
| print_test("2FA - Code format invalide (login)") | ||
|
|
||
| if skip_if_no_qr_decode("2FA Code format invalide (login)"): | ||
| return | ||
|
|
||
| # Activer 2FA d'abord | ||
| session1 = TestSession() | ||
| creds = generate_test_credentials() | ||
| session1.post("/auth/register", json=creds, expected_status=201) | ||
| session1.post("/auth/login", json={"username": creds["username"], "password": creds["password"]}) | ||
| setup_resp = session1.post("/auth/2fa/setup") | ||
| secret = safe_decode_qr_or_skip(setup_resp.json()["result"]["qrCode"], "2FA Code format invalide (login)") | ||
| totp = pyotp.TOTP(secret) | ||
| session1.post("/auth/2fa/setup/verify", json={"code": totp.now()}) | ||
| session1.post("/auth/logout") | ||
|
|
||
|
|
||
| def test_28_2fa_too_many_attempts(): | ||
| """Test: Trop de tentatives 2FA (protection rate limiting)""" | ||
| print_test("2FA - Trop de tentatives (login)") | ||
|
|
||
| if skip_if_no_qr_decode("2FA Trop de tentatives (login)"): | ||
| return | ||
|
|
||
| # Activer 2FA | ||
| session1 = TestSession() | ||
| creds = generate_test_credentials() | ||
| session1.post("/auth/register", json=creds, expected_status=201) | ||
| session1.post("/auth/login", json={"username": creds["username"], "password": creds["password"]}) | ||
| setup_resp = session1.post("/auth/2fa/setup") | ||
| secret = safe_decode_qr_or_skip(setup_resp.json()["result"]["qrCode"], "2FA Trop de tentatives (login)") | ||
| totp = pyotp.TOTP(secret) | ||
| session1.post("/auth/2fa/setup/verify", json={"code": totp.now()}) | ||
| session1.post("/auth/logout") | ||
|
|
||
| # Login et épuiser les tentatives | ||
| session2 = TestSession() | ||
| session2.post("/auth/login", json={"username": creds["username"], "password": creds["password"]}) | ||
|
|
||
| last_resp = None | ||
| for i in range(6): | ||
| # On envoie plusieurs codes invalides rapidement pour éviter l'expiration du token | ||
| resp = session2.session.post(f"{API_URL}/auth/2fa/verify", json={"code": "000000"}, verify=False) | ||
| last_resp = resp | ||
| if resp.status_code in (429, 401): | ||
| break | ||
| time.sleep(0.05) | ||
|
|
||
| assert last_resp is not None | ||
| if last_resp.status_code == 429: | ||
| print_success("429 reçu après trop de tentatives") | ||
| elif last_resp.status_code == 401 and last_resp.json().get("error", {}).get("code") == "LOGIN_SESSION_EXPIRED": | ||
| print_success("Session de login 2FA expirée avant d'atteindre 429 (token très court)") | ||
| else: | ||
| raise AssertionError(f"Statut inattendu: {last_resp.status_code} -> {last_resp.text}") | ||
|
|
||
|
|
||
| def main(): | ||
| """Exécution de tous les tests""" | ||
| print("\n" + "="*60) | ||
| print("🚀 Tests CI/CD - Service Auth") | ||
| print("="*60) | ||
|
|
||
| tests = [ | ||
| test_01_health_check, | ||
| test_02_register_success, | ||
| test_03_register_duplicate, | ||
| test_03b_register_duplicate_email, | ||
| test_04_register_invalid_username, | ||
| test_04b_register_invalid_email_formats, | ||
| test_04c_register_email_too_long, | ||
| test_05_register_invalid_password, | ||
| test_05b_register_password_too_long, | ||
| test_06_login_success, | ||
| test_06b_login_email_invalid_format, | ||
| test_06c_login_with_username_and_email, | ||
| test_07_login_with_email, | ||
| test_08_login_invalid_credentials, | ||
| test_08b_login_missing_identifier, | ||
| test_08c_login_missing_password, | ||
| test_09_verify_token, | ||
| test_09b_verify_after_logout, | ||
| test_10_verify_without_token, | ||
| test_10b_verify_invalid_token, | ||
| test_11_me_authenticated, | ||
| test_12_me_unauthenticated, | ||
| test_13_logout, | ||
| test_13b_logout_without_session, | ||
| test_14_admin_list_users, | ||
| test_15_non_admin_cannot_list, | ||
| test_16_2fa_setup, | ||
| test_17_2fa_verify_setup, | ||
| test_18_2fa_verify_invalid_code, | ||
| test_18b_2fa_setup_when_already_enabled, | ||
| test_19_2fa_login_flow, | ||
| test_20_2fa_disable, | ||
| test_20b_2fa_disable_not_enabled, | ||
| test_21_rate_limiting, | ||
| test_22_2fa_setup_bad_format, | ||
| test_27_2fa_invalid_format_login, | ||
| test_28_2fa_too_many_attempts, | ||
| ] | ||
|
|
||
| passed = 0 | ||
| failed = 0 | ||
|
|
||
| for test in tests: | ||
| try: | ||
| test() | ||
| passed += 1 | ||
| except AssertionError as e: | ||
| failed += 1 | ||
| print_error(f"FAILED: {str(e)}") | ||
| except Exception as e: | ||
| failed += 1 | ||
| print_error(f"ERROR: {str(e)}") | ||
|
|
||
| print("\n" + "="*60) | ||
| print(f"📊 Résultats: {passed} réussis, {failed} échoués") | ||
| print("="*60 + "\n") | ||
|
|
||
| sys.exit(0 if failed == 0 else 1) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test suite has grown to 913 lines with 29 test functions. While comprehensive, this large single file makes maintenance and debugging more challenging.
Current structure issues:
- All tests in one file: Authentication, 2FA, admin, and rate limiting all mixed together
- Test interdependencies: Some tests return values (lines 72, 258, 566, 601, 704) that other tests use, creating implicit dependencies
- Shared setup duplication: User creation + login is repeated in many tests
For a 42 School project, consider refactoring for better organization:
Option A: Split by feature domain
test_auth_basic.py # Register, login, logout, verify (tests 01-13)
test_auth_2fa.py # All 2FA tests (tests 16-28)
test_auth_admin.py # Admin functionality (tests 14-15)
test_auth_security.py # Rate limiting, edge cases (test 21)
Option B: Use pytest fixtures for shared setup
# conftest.py
@pytest.fixture
def authenticated_user():
session = TestSession()
creds = generate_test_credentials()
session.post("/auth/register", json=creds, expected_status=201)
session.login(creds["username"], creds["password"])
return session, creds
# Then in tests:
def test_me_authenticated(authenticated_user):
session, creds = authenticated_user
response = session.get("/auth/me")
# ... test logicBenefits:
- Easier to run subset of tests (just 2FA tests, just admin tests)
- Clearer test organization for team members
- Easier to identify which area is failing in CI
- Reduced duplication of setup code
Why this matters for learning:
Understanding test organization is crucial for maintaining large codebases. This demonstrates software engineering maturity beyond "tests that pass."
Note: This is marked as nit because the tests work correctly as-is. Refactoring is about maintainability, not correctness.
| user: { id, username, email }, | ||
| message: 'Register success', |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The response structure for successful registration has been changed from a nested result wrapper to a flat structure. This is an inconsistent API design change.
Before:
{ "result": { "message": "...", "id": 123 } }After:
{ "user": { "id": 123, "username": "...", "email": "..." }, "message": "..." }Consistency concerns:
Looking at other endpoints in this file, some still use the result wrapper pattern (e.g., /auth/list at line 426, 2FA endpoints). This creates API inconsistency where clients must handle different response structures for different endpoints.
Recommendation:
Choose one pattern and apply it consistently across all endpoints:
- Option A: Keep the
resultwrapper for all successful responses - Option B: Remove the
resultwrapper from all endpoints (requires updating all other endpoints too)
For a 42 School project: Consistency in API design demonstrates understanding of interface design principles and makes the codebase more maintainable for team collaboration.
| user: { id, username, email }, | |
| message: 'Register success', | |
| result: { | |
| user: { id, username, email }, | |
| message: 'Register success', | |
| }, |
| if (err && err.code === 'USER_EXISTS') { | ||
| return reply.code(409).send({ error: "username already exists" }); | ||
| } | ||
| if (err && err.code === 'EMAIL_EXISTS') { | ||
| return reply.code(400).send({ | ||
| error: { | ||
| message: err.message || `L'email "${email}" est déjà utilisé.`, | ||
| code: 'EMAIL_EXISTS', | ||
| field: 'email', | ||
| }, | ||
| }); | ||
| return reply.code(409).send({ error: "email already exists" }); | ||
| } |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The duplicate error handling code (lines 119-124) is redundant because the same check already exists at lines 59-66. When execution reaches line 119, the username and email have already been verified as unique.
What's happening:
- Lines 59-66: Check if username/email exists → return 409 if found
- Lines 100-106: Call
createUser() - Lines 119-124: Check again for
USER_EXISTS/EMAIL_EXISTSerrors (unreachable code)
This code can never execute because:
- If the user exists, we already returned at line 61 or 65
- The
createUser()call at line 100 shouldn't throwUSER_EXISTSerrors given the prior checks - If somehow it does, this indicates a race condition that needs fixing, not catching
Recommendation:
Remove lines 119-124. If you're concerned about race conditions between the check and create operations, use database-level unique constraints and handle the constraint violation error from the database.
|
|
||
|
|
||
| def main(): | ||
| root = Path(__file__).parent |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Variable root is not used.
| root = Path(__file__).parent |
| import requests | ||
| from PIL import Image | ||
|
|
||
| # Import optionnel pour le décodage QR (nécessite libzbar système) | ||
| try: | ||
| from pyzbar.pyzbar import decode as qr_decode | ||
| QR_DECODE_AVAILABLE = True | ||
| except ImportError: | ||
| print("⚠️ Warning: pyzbar not available (missing libzbar system library)") | ||
| print(" QR code decoding will be disabled in 2FA tests") | ||
| print(" To fix: sudo apt-get install libzbar0") |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Print statement may execute during import.
| import requests | |
| from PIL import Image | |
| # Import optionnel pour le décodage QR (nécessite libzbar système) | |
| try: | |
| from pyzbar.pyzbar import decode as qr_decode | |
| QR_DECODE_AVAILABLE = True | |
| except ImportError: | |
| print("⚠️ Warning: pyzbar not available (missing libzbar system library)") | |
| print(" QR code decoding will be disabled in 2FA tests") | |
| print(" To fix: sudo apt-get install libzbar0") | |
| import logging | |
| import requests | |
| from PIL import Image | |
| logger = logging.getLogger(__name__) | |
| # Import optionnel pour le décodage QR (nécessite libzbar système) | |
| try: | |
| from pyzbar.pyzbar import decode as qr_decode | |
| QR_DECODE_AVAILABLE = True | |
| except ImportError: | |
| logger.warning("pyzbar not available (missing libzbar system library)") | |
| logger.warning("QR code decoding will be disabled in 2FA tests") | |
| logger.warning("To fix: sudo apt-get install libzbar0") |
|
|
||
| # Import optionnel pour le décodage QR (nécessite libzbar système) | ||
| try: | ||
| from pyzbar.pyzbar import decode as qr_decode | ||
| QR_DECODE_AVAILABLE = True | ||
| except ImportError: | ||
| print("⚠️ Warning: pyzbar not available (missing libzbar system library)") | ||
| print(" QR code decoding will be disabled in 2FA tests") | ||
| print(" To fix: sudo apt-get install libzbar0") |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Print statement may execute during import.
| # Import optionnel pour le décodage QR (nécessite libzbar système) | |
| try: | |
| from pyzbar.pyzbar import decode as qr_decode | |
| QR_DECODE_AVAILABLE = True | |
| except ImportError: | |
| print("⚠️ Warning: pyzbar not available (missing libzbar system library)") | |
| print(" QR code decoding will be disabled in 2FA tests") | |
| print(" To fix: sudo apt-get install libzbar0") | |
| import warnings | |
| # Import optionnel pour le décodage QR (nécessite libzbar système) | |
| try: | |
| from pyzbar.pyzbar import decode as qr_decode | |
| QR_DECODE_AVAILABLE = True | |
| except ImportError: | |
| warnings.warn( | |
| "pyzbar not available (missing libzbar system library); " | |
| "QR code decoding will be disabled in 2FA tests. " | |
| "To fix: sudo apt-get install libzbar0", | |
| RuntimeWarning, | |
| ) |
|
|
||
| # Import optionnel pour le décodage QR (nécessite libzbar système) | ||
| try: | ||
| from pyzbar.pyzbar import decode as qr_decode | ||
| QR_DECODE_AVAILABLE = True | ||
| except ImportError: | ||
| print("⚠️ Warning: pyzbar not available (missing libzbar system library)") | ||
| print(" QR code decoding will be disabled in 2FA tests") | ||
| print(" To fix: sudo apt-get install libzbar0") |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Print statement may execute during import.
| # Import optionnel pour le décodage QR (nécessite libzbar système) | |
| try: | |
| from pyzbar.pyzbar import decode as qr_decode | |
| QR_DECODE_AVAILABLE = True | |
| except ImportError: | |
| print("⚠️ Warning: pyzbar not available (missing libzbar system library)") | |
| print(" QR code decoding will be disabled in 2FA tests") | |
| print(" To fix: sudo apt-get install libzbar0") | |
| import warnings | |
| # Import optionnel pour le décodage QR (nécessite libzbar système) | |
| try: | |
| from pyzbar.pyzbar import decode as qr_decode | |
| QR_DECODE_AVAILABLE = True | |
| except ImportError: | |
| warnings.warn( | |
| "pyzbar not available (missing libzbar system library).\n" | |
| "QR code decoding will be disabled in 2FA tests.\n" | |
| "To fix: sudo apt-get install libzbar0", | |
| RuntimeWarning, | |
| ) |
…ertions dans les tests
rom98759
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JUSTE PARFAIT BRAVO
Ilia1177
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bravo ! Trés jolie travail !
Description
Add tests Python try functional project
Details
Tests :
CI implementer GitHub actions