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:
2026-05-17 09:19:19 +02:00
commit 88cf6458d0
58 changed files with 10365 additions and 0 deletions
View File
+12
View File
@@ -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")
+335
View File
@@ -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
+245
View File
@@ -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