feat(vwb): Basic auth LAN sur backend 5002 — creds dashboard, loopback exempté
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m52s
tests / Tests unitaires (sans GPU) (push) Failing after 1m52s
tests / Tests sécurité (critique) (push) Has been skipped

VWB backend exposé au LAN sans auth (point pré-clinique). Ajoute HTTP Basic auth
(mêmes identifiants que le dashboard: DASHBOARD_USER/DASHBOARD_PASSWORD) via
@app.before_request ; exempte loopback (intégration dashboard/agent_chat intacte),
/health et OPTIONS. Frontend = Create React App (pas Vite) → auth backend suffit
(navigateur LAN challengé au 1er XHR vers 5002) ; build statique = cible clinique.

Déployé + vérifié DGX: loopback 200, LAN no-creds 401, LAN+creds 200. 10 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-06-19 16:27:15 +02:00
parent ec1fb81054
commit cf81ce4c7b
2 changed files with 298 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
"""
Tests de l'auth HTTP Basic LAN du backend VWB (port 5002).
Le VWB etait expose au LAN SANS authentification. Le middleware
`_vwb_basic_auth_middleware` ajoute un challenge 401 sur toute requete
NON-loopback, avec les MEMES credentials que le dashboard
(DASHBOARD_USER / DASHBOARD_PASSWORD).
Controles cles :
- Loopback (127.0.0.1) sans credentials -> 200 (proxy dashboard / healthcheck).
- LAN (REMOTE_ADDR non loopback) sans credentials -> 401 + WWW-Authenticate.
- LAN avec mauvais mot de passe -> 401.
- LAN avec bons credentials -> passage (pas de 401).
- /health public meme en LAN.
- DASHBOARD_AUTH_DISABLED=true -> bypass total.
- DASHBOARD_PASSWORD absent -> auth inactive (mode POC degrade, pas de crash).
"""
from __future__ import annotations
import base64
import importlib
import os
import sys
from pathlib import Path
import pytest
# Le backend VWB s'importe en tant que module top-level `app`
# (cf. tests/conftest.py : `from app import app, db`). On ajoute le repertoire
# backend au path pour pouvoir le recharger avec les variables d'env voulues.
_BACKEND_DIR = Path(__file__).resolve().parent.parent
if str(_BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(_BACKEND_DIR))
# Adresse LAN simulee (non loopback)
_LAN_ADDR = "192.168.1.50"
_LAN_ENV = {"REMOTE_ADDR": _LAN_ADDR}
def _basic_auth_header(user: str, password: str) -> str:
token = base64.b64encode(f"{user}:{password}".encode()).decode()
return f"Basic {token}"
def _reload_app():
"""Recharge le module `app` pour relire les constantes d'auth depuis l'env."""
if "app" in sys.modules:
return importlib.reload(sys.modules["app"])
return importlib.import_module("app")
@pytest.fixture
def auth_enabled_client(monkeypatch):
"""Client VWB avec auth LAN active (DASHBOARD_USER/PASSWORD definis)."""
monkeypatch.setenv("DASHBOARD_USER", "lea")
monkeypatch.setenv("DASHBOARD_PASSWORD", "secret-test-pwd")
monkeypatch.delenv("DASHBOARD_AUTH_DISABLED", raising=False)
mod = _reload_app()
mod.app.config["TESTING"] = True
mod.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
with mod.app.test_client() as c:
with mod.app.app_context():
mod.db.create_all()
yield c
mod.db.drop_all()
@pytest.fixture
def auth_disabled_client(monkeypatch):
"""Client VWB avec auth desactivee (DASHBOARD_AUTH_DISABLED=true)."""
monkeypatch.setenv("DASHBOARD_AUTH_DISABLED", "true")
monkeypatch.setenv("DASHBOARD_PASSWORD", "secret-test-pwd")
mod = _reload_app()
mod.app.config["TESTING"] = True
mod.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
with mod.app.test_client() as c:
with mod.app.app_context():
mod.db.create_all()
yield c
mod.db.drop_all()
@pytest.fixture
def no_password_client(monkeypatch):
"""Client VWB sans DASHBOARD_PASSWORD (mode POC degrade : auth inactive)."""
monkeypatch.delenv("DASHBOARD_PASSWORD", raising=False)
monkeypatch.delenv("DASHBOARD_AUTH_DISABLED", raising=False)
mod = _reload_app()
mod.app.config["TESTING"] = True
mod.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
with mod.app.test_client() as c:
with mod.app.app_context():
mod.db.create_all()
yield c
mod.db.drop_all()
class TestVwbBasicAuth:
"""Auth HTTP Basic LAN sur le backend VWB (5002)."""
def test_loopback_no_creds_passes(self, auth_enabled_client):
"""Requete loopback (127.0.0.1) sans creds -> PAS de 401.
Garde-fou critique : le dashboard proxifie en loopback. La requete
ne doit jamais etre challengee (200, ou autre code applicatif != 401).
"""
resp = auth_enabled_client.get("/api/v3/session/state")
assert resp.status_code != 401, (
f"Loopback ne doit jamais etre challenge (got {resp.status_code})"
)
def test_lan_no_creds_returns_401(self, auth_enabled_client):
"""Requete LAN (non loopback) sans creds -> 401 + WWW-Authenticate."""
resp = auth_enabled_client.get(
"/api/v3/session/state", environ_base=_LAN_ENV
)
assert resp.status_code == 401
assert "WWW-Authenticate" in resp.headers
assert "Basic" in resp.headers["WWW-Authenticate"]
def test_lan_wrong_password_returns_401(self, auth_enabled_client):
"""Requete LAN avec mauvais mot de passe -> 401."""
resp = auth_enabled_client.get(
"/api/v3/session/state",
environ_base=_LAN_ENV,
headers={"Authorization": _basic_auth_header("lea", "wrong")},
)
assert resp.status_code == 401
def test_lan_wrong_user_returns_401(self, auth_enabled_client):
"""Requete LAN avec mauvais utilisateur -> 401."""
resp = auth_enabled_client.get(
"/api/v3/session/state",
environ_base=_LAN_ENV,
headers={"Authorization": _basic_auth_header("intruder", "secret-test-pwd")},
)
assert resp.status_code == 401
def test_lan_valid_credentials_pass(self, auth_enabled_client):
"""Requete LAN avec bons creds -> PAS de 401 (auth franchie)."""
resp = auth_enabled_client.get(
"/api/v3/session/state",
environ_base=_LAN_ENV,
headers={"Authorization": _basic_auth_header("lea", "secret-test-pwd")},
)
assert resp.status_code != 401, (
f"Bons creds doivent franchir l'auth (got {resp.status_code})"
)
def test_lan_malformed_header_returns_401(self, auth_enabled_client):
"""Requete LAN avec header mal forme (Bearer) -> 401."""
resp = auth_enabled_client.get(
"/api/v3/session/state",
environ_base=_LAN_ENV,
headers={"Authorization": "Bearer tototoken"},
)
assert resp.status_code == 401
def test_lan_health_is_public(self, auth_enabled_client):
"""/health reste public meme en LAN (healthcheck externe)."""
resp = auth_enabled_client.get("/health", environ_base=_LAN_ENV)
assert resp.status_code == 200
def test_lan_options_preflight_not_blocked(self, auth_enabled_client):
"""Preflight CORS (OPTIONS) en LAN -> pas de 401 (CORS preserve)."""
resp = auth_enabled_client.open(
"/api/v3/session/state", method="OPTIONS", environ_base=_LAN_ENV
)
assert resp.status_code != 401
def test_auth_disabled_bypass_lan(self, auth_disabled_client):
"""DASHBOARD_AUTH_DISABLED=true -> LAN passe sans creds."""
resp = auth_disabled_client.get(
"/api/v3/session/state", environ_base=_LAN_ENV
)
assert resp.status_code != 401
def test_no_password_degraded_lan_passes(self, no_password_client):
"""DASHBOARD_PASSWORD absent -> mode POC degrade : LAN passe (pas de crash)."""
resp = no_password_client.get(
"/api/v3/session/state", environ_base=_LAN_ENV
)
assert resp.status_code != 401
@pytest.fixture(autouse=True)
def _restore_module(monkeypatch):
"""Restaure le module `app` en mode auth desactivee apres chaque test,
pour ne pas contaminer les autres tests VWB (qui importent `app`)."""
yield
monkeypatch.setenv("DASHBOARD_AUTH_DISABLED", "true")
monkeypatch.delenv("DASHBOARD_PASSWORD", raising=False)
monkeypatch.delenv("DASHBOARD_USER", raising=False)
if "app" in sys.modules:
importlib.reload(sys.modules["app"])