Initial commit — Stupid Simple Network Inventory
Application web d'inventaire réseau manuel avec FastAPI, Vue 3 et Docker. Inclut l'authentification JWT, la découverte ICMP, et la topologie en cards CSS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Configures a fresh in-memory SQLite database for every test session.
|
||||
DATABASE_URL must be set before any app module is imported.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
# Must be set before importing database or main
|
||||
_tmpdb = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmpdb.close()
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{_tmpdb.name}"
|
||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Tests de sécurité pour l'authentification.
|
||||
|
||||
Couvre :
|
||||
- SEC-FIX-001 : bootstrap admin, rattrapage admin existant, blocage CRUD avant changement
|
||||
- SEC-FIX-002 : rate limiting login
|
||||
- SEC-FIX-003 : validation mot de passe et username
|
||||
- SEC-FIX-004 : invalidation de token après changement de mot de passe
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# conftest.py sets DATABASE_URL before this import
|
||||
from database import engine, Base
|
||||
from main import (
|
||||
app,
|
||||
_migrate_users_must_change_password,
|
||||
_migrate_users_token_version,
|
||||
_migrate_force_admin_password_change,
|
||||
_migrate_users,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_db():
|
||||
"""Fresh schema + seeded admin for each test."""
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_users_must_change_password()
|
||||
_migrate_users_token_version()
|
||||
_migrate_users()
|
||||
yield
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_rate_limits():
|
||||
"""Remet à zéro les compteurs de rate limiting entre chaque test."""
|
||||
from routers.auth import _ip_attempts, _login_attempts, _rate_lock
|
||||
with _rate_lock:
|
||||
_ip_attempts.clear()
|
||||
_login_attempts.clear()
|
||||
yield
|
||||
with _rate_lock:
|
||||
_ip_attempts.clear()
|
||||
_login_attempts.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
def _login(client, username="admin", password="admin"):
|
||||
return client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": username, "password": password},
|
||||
)
|
||||
|
||||
|
||||
def _auth_headers(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SEC-FIX-001 — Bootstrap et rattrapage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBootstrap:
|
||||
def test_fresh_db_admin_must_change_password(self, client):
|
||||
"""Nouvelle base : admin créé avec must_change_password=1."""
|
||||
r = _login(client)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["must_change_password"] is True
|
||||
assert data["username"] == "admin"
|
||||
|
||||
def test_crud_blocked_before_password_change(self, client):
|
||||
"""CRUD refusé (403) tant que must_change_password est vrai."""
|
||||
token = _login(client).json()["access_token"]
|
||||
r = client.get("/api/vlans/", headers=_auth_headers(token))
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"] == "Password change required"
|
||||
|
||||
def test_crud_allowed_after_password_change(self, client):
|
||||
"""CRUD autorisé après changement de mot de passe."""
|
||||
token = _login(client).json()["access_token"]
|
||||
r = client.put(
|
||||
"/api/auth/account",
|
||||
json={"current_password": "admin", "new_password": "SecurePass1"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
new_token = r.json()["access_token"]
|
||||
assert r.json()["must_change_password"] is False
|
||||
r2 = client.get("/api/vlans/", headers=_auth_headers(new_token))
|
||||
assert r2.status_code == 200
|
||||
|
||||
def test_migration_forces_existing_admin_with_default_password(self, client):
|
||||
"""Rattrapage : admin existant avec must_change_password=0 et password 'admin' est forcé."""
|
||||
# Simuler une ancienne base : admin avec must_change_password=0
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||
conn.commit()
|
||||
# La migration de rattrapage doit remettre must_change_password=1
|
||||
_migrate_force_admin_password_change()
|
||||
r = _login(client)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["must_change_password"] is True
|
||||
|
||||
def test_migration_does_not_touch_admin_with_custom_password(self, client):
|
||||
"""Rattrapage : admin avec mot de passe personnalisé et must_change_password=0 n'est pas touché."""
|
||||
from passlib.context import CryptContext
|
||||
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(
|
||||
"UPDATE users SET hashed_password=:h, must_change_password=0 WHERE username='admin'"
|
||||
), {"h": pwd.hash("CustomPass9")})
|
||||
conn.commit()
|
||||
_migrate_force_admin_password_change()
|
||||
r = client.post("/api/auth/login", data={"username": "admin", "password": "CustomPass9"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["must_change_password"] is False
|
||||
|
||||
def test_initial_admin_password_env_var(self, monkeypatch):
|
||||
"""Avec INITIAL_ADMIN_PASSWORD, must_change_password=0."""
|
||||
monkeypatch.setenv("INITIAL_ADMIN_PASSWORD", "EnvPass42")
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_users_must_change_password()
|
||||
_migrate_users_token_version()
|
||||
_migrate_users()
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/auth/login", data={"username": "admin", "password": "EnvPass42"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["must_change_password"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SEC-FIX-004 — Invalidation de token après changement de mot de passe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTokenInvalidation:
|
||||
def test_old_token_rejected_after_password_change(self, client):
|
||||
"""L'ancien token est invalide après changement de mot de passe."""
|
||||
# Forcer must_change_password=0 pour pouvoir tester le CRUD
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||
conn.commit()
|
||||
old_token = _login(client).json()["access_token"]
|
||||
# Changer le mot de passe → invalide old_token
|
||||
client.put(
|
||||
"/api/auth/account",
|
||||
json={"current_password": "admin", "new_password": "NewPass99"},
|
||||
headers=_auth_headers(old_token),
|
||||
)
|
||||
r = client.get("/api/vlans/", headers=_auth_headers(old_token))
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_new_token_valid_after_password_change(self, client):
|
||||
"""Le nouveau token fonctionne après changement de mot de passe."""
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||
conn.commit()
|
||||
old_token = _login(client).json()["access_token"]
|
||||
r = client.put(
|
||||
"/api/auth/account",
|
||||
json={"current_password": "admin", "new_password": "NewPass99"},
|
||||
headers=_auth_headers(old_token),
|
||||
)
|
||||
new_token = r.json()["access_token"]
|
||||
r2 = client.get("/api/vlans/", headers=_auth_headers(new_token))
|
||||
assert r2.status_code == 200
|
||||
|
||||
def test_token_without_version_accepted_for_backward_compat(self, client):
|
||||
"""Token sans champ 'ver' (ancien format) est accepté : ver absent → ver=1 par défaut."""
|
||||
from jose import jwt as jose_jwt
|
||||
from routers.auth import SECRET_KEY, ALGORITHM
|
||||
from datetime import datetime, timedelta, timezone
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
old_format_token = jose_jwt.encode(
|
||||
{"sub": "admin", "exp": expire},
|
||||
SECRET_KEY,
|
||||
algorithm=ALGORITHM,
|
||||
)
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||
conn.commit()
|
||||
r = client.get("/api/auth/me", headers=_auth_headers(old_format_token))
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_token_with_wrong_version_rejected(self, client):
|
||||
"""Token avec version incorrecte est rejeté."""
|
||||
from jose import jwt as jose_jwt
|
||||
from routers.auth import SECRET_KEY, ALGORITHM
|
||||
from datetime import datetime, timedelta, timezone
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
bad_token = jose_jwt.encode(
|
||||
{"sub": "admin", "ver": 999, "exp": expire},
|
||||
SECRET_KEY,
|
||||
algorithm=ALGORITHM,
|
||||
)
|
||||
r = client.get("/api/auth/me", headers=_auth_headers(bad_token))
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SEC-FIX-003 — Validation mot de passe et username
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidation:
|
||||
def _get_valid_token(self, client):
|
||||
"""Retourne un token valide (must_change_password forcé à 0)."""
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||
conn.commit()
|
||||
return _login(client).json()["access_token"]
|
||||
|
||||
def test_password_too_short_rejected(self, client):
|
||||
token = self._get_valid_token(client)
|
||||
r = client.put(
|
||||
"/api/auth/account",
|
||||
json={"current_password": "admin", "new_password": "Short1"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "password_too_short"
|
||||
|
||||
def test_password_no_digit_rejected(self, client):
|
||||
token = self._get_valid_token(client)
|
||||
r = client.put(
|
||||
"/api/auth/account",
|
||||
json={"current_password": "admin", "new_password": "OnlyLetters"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "password_too_weak"
|
||||
|
||||
def test_password_no_letter_rejected(self, client):
|
||||
token = self._get_valid_token(client)
|
||||
r = client.put(
|
||||
"/api/auth/account",
|
||||
json={"current_password": "admin", "new_password": "12345678"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "password_too_weak"
|
||||
|
||||
def test_valid_password_accepted(self, client):
|
||||
token = self._get_valid_token(client)
|
||||
r = client.put(
|
||||
"/api/auth/account",
|
||||
json={"current_password": "admin", "new_password": "ValidPass1"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_username_invalid_chars_rejected(self, client):
|
||||
token = self._get_valid_token(client)
|
||||
r = client.put(
|
||||
"/api/auth/account",
|
||||
json={"current_password": "admin", "new_username": "bad user!"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "username_invalid"
|
||||
|
||||
def test_username_too_long_rejected(self, client):
|
||||
token = self._get_valid_token(client)
|
||||
r = client.put(
|
||||
"/api/auth/account",
|
||||
json={"current_password": "admin", "new_username": "a" * 65},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "username_invalid"
|
||||
|
||||
def test_valid_username_accepted(self, client):
|
||||
token = self._get_valid_token(client)
|
||||
r = client.put(
|
||||
"/api/auth/account",
|
||||
json={"current_password": "admin", "new_username": "admin_user.1"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_wrong_current_password_rejected(self, client):
|
||||
token = self._get_valid_token(client)
|
||||
r = client.put(
|
||||
"/api/auth/account",
|
||||
json={"current_password": "wrong", "new_password": "NewPass1!"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SEC-FIX-002 — Rate limiting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRateLimit:
|
||||
def test_ip_rate_limit_triggers_429(self, client):
|
||||
"""Après trop de tentatives par IP, le login retourne 429."""
|
||||
from routers.auth import _ip_attempts, _rate_lock, _IP_MAX
|
||||
with _rate_lock:
|
||||
_ip_attempts["testclient"] = [__import__("time").time()] * _IP_MAX
|
||||
r = _login(client)
|
||||
assert r.status_code == 429
|
||||
|
||||
def test_username_rate_limit_triggers_429(self, client):
|
||||
"""Après trop de tentatives par username, le login retourne 429."""
|
||||
from routers.auth import _login_attempts, _rate_lock, _USERNAME_MAX
|
||||
with _rate_lock:
|
||||
_login_attempts["admin"] = [__import__("time").time()] * _USERNAME_MAX
|
||||
r = _login(client)
|
||||
assert r.status_code == 429
|
||||
|
||||
def test_successful_login_clears_username_attempts(self, client):
|
||||
"""Login réussi remet à zéro le compteur username."""
|
||||
from routers.auth import _login_attempts, _rate_lock
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||
conn.commit()
|
||||
r = _login(client)
|
||||
assert r.status_code == 200
|
||||
with _rate_lock:
|
||||
assert "admin" not in _login_attempts
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Tests de validation — Phase 3
|
||||
|
||||
Couvre :
|
||||
- SEC-FIX-006 : validation des entrées discovery (dns_server, ips, cap global)
|
||||
- SEC-FIX-007 : validators Pydantic sur VlanCreate et DeviceCreate
|
||||
- SEC-FIX-013 : PRAGMA foreign_keys=ON (test indirect via FK constraint)
|
||||
- SEC-FIX-017 : suppression code orphelin Links (endpoint /api/links inexistant)
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database import engine, Base
|
||||
from main import app, _migrate_users_must_change_password, _migrate_users_token_version, _migrate_users
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_db():
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_users_must_change_password()
|
||||
_migrate_users_token_version()
|
||||
_migrate_users()
|
||||
yield
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_rate_limits():
|
||||
from routers.auth import _ip_attempts, _login_attempts, _rate_lock
|
||||
with _rate_lock:
|
||||
_ip_attempts.clear()
|
||||
_login_attempts.clear()
|
||||
yield
|
||||
with _rate_lock:
|
||||
_ip_attempts.clear()
|
||||
_login_attempts.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
def _get_token(client):
|
||||
"""Retourne un token admin valide avec must_change_password=0."""
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("UPDATE users SET must_change_password=0 WHERE username='admin'"))
|
||||
conn.commit()
|
||||
r = client.post("/api/auth/login", data={"username": "admin", "password": "admin"})
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SEC-FIX-007 — Validation VlanCreate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVlanValidation:
|
||||
def test_vlan_id_out_of_range_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/vlans/", json={"name": "Test", "vlan_id": 0}, headers=_auth(token))
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_vlan_id_max_boundary_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/vlans/", json={"name": "Test", "vlan_id": 4095}, headers=_auth(token))
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_vlan_id_valid_accepted(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/vlans/", json={"name": "Test", "vlan_id": 100, "color": "#AABBCC"}, headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_vlan_empty_name_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/vlans/", json={"name": " "}, headers=_auth(token))
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_vlan_invalid_cidr_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/vlans/", json={"name": "Test", "cidr": "not-a-cidr"}, headers=_auth(token))
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_vlan_valid_cidr_accepted(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/vlans/", json={"name": "Test", "cidr": "192.168.1.0/24", "color": "#AABBCC"}, headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_vlan_invalid_color_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/vlans/", json={"name": "Test", "color": "blue"}, headers=_auth(token))
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_vlan_valid_color_accepted(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/vlans/", json={"name": "Test", "color": "#1a2B3c"}, headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SEC-FIX-007 — Validation DeviceCreate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeviceValidation:
|
||||
def test_invalid_type_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/devices/", json={"name": "srv", "type": "supercomputer"}, headers=_auth(token))
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_valid_type_accepted(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/devices/", json={"name": "srv", "type": "server"}, headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_invalid_virt_type_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/devices/", json={"name": "srv", "virt_type": "docker"}, headers=_auth(token))
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_valid_virt_type_accepted(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/devices/", json={"name": "srv", "virt_type": "lxc"}, headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_invalid_url_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/devices/", json={"name": "srv", "url": "ftp://bad"}, headers=_auth(token))
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_valid_url_accepted(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/devices/", json={"name": "srv", "url": "https://192.168.1.1:8006"}, headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_empty_name_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/devices/", json={"name": ""}, headers=_auth(token))
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_invalid_interface_ip_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/devices/", json={
|
||||
"name": "srv",
|
||||
"interfaces": [{"name": "eth0", "ip_address": "not-an-ip"}]
|
||||
}, headers=_auth(token))
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_valid_interface_ip_accepted(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/devices/", json={
|
||||
"name": "srv",
|
||||
"interfaces": [{"name": "eth0", "ip_address": "10.0.0.1"}]
|
||||
}, headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SEC-FIX-006 — Validation discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDiscoveryValidation:
|
||||
def test_invalid_dns_server_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/discovery/scan", json={
|
||||
"dns_server": "not-an-ip",
|
||||
"targets": [{"vlan_id": 1, "cidr": "192.168.1.0/24"}]
|
||||
}, headers=_auth(token))
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_invalid_ping_ip_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/discovery/ping", json={"ips": ["1.2.3.4", "bad-ip"]}, headers=_auth(token))
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_valid_ping_ips_accepted(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/discovery/ping", json={"ips": []}, headers=_auth(token))
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_scan_oversized_cidr_rejected(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/discovery/scan", json={
|
||||
"dns_server": "8.8.8.8",
|
||||
"targets": [{"vlan_id": 1, "cidr": "10.0.0.0/8"}]
|
||||
}, headers=_auth(token))
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_scan_global_cap_rejected(self, client):
|
||||
"""Plusieurs targets dont le total dépasse MAX_HOSTS_TOTAL."""
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/discovery/scan", json={
|
||||
"dns_server": "8.8.8.8",
|
||||
"targets": [
|
||||
{"vlan_id": 1, "cidr": "10.0.0.0/22"}, # 1022 hôtes
|
||||
{"vlan_id": 2, "cidr": "10.1.0.0/22"}, # 1022 hôtes
|
||||
{"vlan_id": 3, "cidr": "10.2.0.0/22"}, # 1022 hôtes
|
||||
{"vlan_id": 4, "cidr": "10.3.0.0/22"}, # 1022 hôtes
|
||||
{"vlan_id": 5, "cidr": "10.4.0.0/22"}, # 1022 hôtes → total > 4096
|
||||
]
|
||||
}, headers=_auth(token))
|
||||
assert r.status_code == 400
|
||||
assert "total" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SEC-FIX-017 — Endpoint /api/links absent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLinksEndpointRemoved:
|
||||
def test_links_list_returns_404(self, client):
|
||||
"""Le routeur /api/links a été supprimé en phase 3."""
|
||||
token = _get_token(client)
|
||||
r = client.get("/api/links/", headers=_auth(token))
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_links_create_returns_404(self, client):
|
||||
token = _get_token(client)
|
||||
r = client.post("/api/links/", json={
|
||||
"source_device_id": 1, "target_device_id": 2, "link_type": "trunk"
|
||||
}, headers=_auth(token))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SEC-FIX-013 — PRAGMA foreign_keys=ON
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestForeignKeys:
|
||||
def test_foreign_keys_pragma_is_on(self):
|
||||
"""Vérifie que PRAGMA foreign_keys=ON est actif sur chaque connexion."""
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("PRAGMA foreign_keys")).fetchone()
|
||||
assert result[0] == 1
|
||||
Reference in New Issue
Block a user