Files
openmind/backend/tests/test_configuration.py
snoopy 9ede2c5e68 Phase 2.1.1 : durcissement avant exposition publique
Fail-fast sur la configuration. Une limite de debit illisible (10/hours,
10/banana, -1/hour) arrete desormais le demarrage avec un message nommant la
variable, au lieu de desactiver silencieusement la protection : seules les
valeurs 0, off, none, disabled et vide desactivent volontairement. Meme
traitement pour MAX_SHARED_MAPS, MAX_SHARE_SIZE_MB, DEFAULT_SHARE_EXPIRY_DAYS
et chaque entree de TRUSTED_PROXY_CIDRS.

X-Forwarded-For n'est plus cru sur la seule foi de TRUST_PROXY_HEADERS : la
connexion doit aussi provenir d'un reseau declare dans TRUSTED_PROXY_CIDRS
(ipaddress, IPv4/IPv6/CIDR). Activer la confiance sans declarer de reseau est
refuse au demarrage, faute de quoi la configuration semblerait active sans
l'etre. L'adresse retenue est normalisee : les differentes ecritures d'une meme
IPv6 ne donnent plus des compteurs distincts.

MAX_SHARED_MAPS : comptage et insertion serialises par un verrou de processus,
pour que deux creations simultanees ne franchissent pas ensemble la derniere
place. Documente comme valable pour un service applicatif unique.

Ajoute APP_BIND pour ne pas publier 8080 sur toutes les interfaces en
production, une option backup --keep pour la retention, et documente Nginx,
Traefik en override, cron et systemd.

Corrige un bug de rendu du build local : mon edition avait reecrit
docker-entrypoint.sh en CRLF, rendant le shebang introuvable dans le conteneur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:53:29 +02:00

154 lines
5.6 KiB
Python

"""Validation de la configuration au demarrage.
Une valeur illisible doit arreter l'application avec un message nommant la
variable fautive, jamais desactiver silencieusement une protection.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.config import ConfigurationError, Settings, get_settings, parse_cidrs
from app.services.rate_limit import RateLimitSyntaxError, parse_rate
@pytest.mark.parametrize(
("spec", "limit", "window"),
[
("10/hour", 10, 3600),
("120/minute", 120, 60),
("5/second", 5, 1),
("2/day", 2, 86400),
("10/h", 10, 3600),
(" 30/MINUTE ", 30, 60),
("3", 3, 60),
],
)
def test_valid_rate_specs(spec: str, limit: int, window: int) -> None:
rule = parse_rate(spec)
assert rule is not None
assert rule.limit == limit
assert rule.window_seconds == window
@pytest.mark.parametrize("spec", ["", "0", "off", "none", "disabled", " OFF "])
def test_explicit_deactivation(spec: str) -> None:
"""Desactiver une limite reste possible, mais doit etre explicite."""
assert parse_rate(spec) is None
@pytest.mark.parametrize(
"spec",
["10/hours", "abc", "-1/hour", "10/banana", "10/", "/hour", "dix/hour", "10 par heure"],
)
def test_invalid_rate_specs_are_refused(spec: str) -> None:
with pytest.raises(RateLimitSyntaxError):
parse_rate(spec)
def test_invalid_rate_message_is_actionable() -> None:
with pytest.raises(RateLimitSyntaxError) as error:
parse_rate("10/hours")
message = str(error.value)
assert "10/hours" in message
assert "hours" in message
assert "second" in message and "hour" in message
class TestSettingsValidation:
def test_valid_configuration_loads(self) -> None:
settings = Settings(
share_create_rate_limit="10/hour",
share_read_rate_limit="120/minute",
share_write_rate_limit="off",
)
assert settings.share_write_rate_limit == "off"
@pytest.mark.parametrize(
"field",
["share_create_rate_limit", "share_read_rate_limit", "share_write_rate_limit"],
)
def test_each_rate_variable_is_validated(self, field: str) -> None:
with pytest.raises(Exception) as error:
Settings(**{field: "10/banana"})
assert "banana" in str(error.value)
def test_negative_shared_map_limit_is_refused(self) -> None:
with pytest.raises(ValidationError):
Settings(max_shared_maps=-1)
def test_negative_share_size_is_refused(self) -> None:
with pytest.raises(ValidationError):
Settings(max_share_size_mb=-2)
def test_zero_share_size_means_inherit(self) -> None:
settings = Settings(max_share_size_mb=0, max_map_size_mb=10)
assert settings.max_share_size_bytes == settings.max_map_size_bytes
def test_negative_expiry_is_refused(self) -> None:
with pytest.raises(ValidationError):
Settings(default_share_expiry_days=-1)
def test_zero_expiry_is_allowed(self) -> None:
assert Settings(default_share_expiry_days=0).default_share_expiry_days == 0
def test_zero_map_size_is_refused(self) -> None:
with pytest.raises(ValidationError):
Settings(max_map_size_mb=0)
class TestTrustedProxyConfiguration:
def test_valid_cidrs(self) -> None:
networks = parse_cidrs("172.16.0.0/12, 10.0.0.0/8 ,2001:db8::/32")
assert len(networks) == 3
assert str(networks[0]) == "172.16.0.0/12"
def test_single_address_becomes_a_host_network(self) -> None:
assert str(parse_cidrs("192.0.2.7")[0]) == "192.0.2.7/32"
def test_empty_value_is_allowed(self) -> None:
assert parse_cidrs("") == []
assert parse_cidrs(" , ") == []
@pytest.mark.parametrize("value", ["999.1.1.1/8", "10.0.0.0/64", "pas-un-reseau", "10.0.0.0/"])
def test_invalid_cidr_is_refused(self, value: str) -> None:
with pytest.raises(ValueError, match="reseau valide"):
parse_cidrs(value)
def test_invalid_cidr_stops_settings(self) -> None:
with pytest.raises(Exception) as error:
Settings(trusted_proxy_cidrs="10.0.0.0/8,pas-un-reseau")
assert "pas-un-reseau" in str(error.value)
def test_trusting_proxies_without_declaring_them_is_refused(self) -> None:
"""Faire confiance sans dire a qui n'appliquerait aucune confiance."""
with pytest.raises(Exception) as error:
Settings(trust_proxy_headers=True, trusted_proxy_cidrs="")
assert "TRUSTED_PROXY_CIDRS" in str(error.value)
def test_trusting_proxies_with_networks_is_accepted(self) -> None:
settings = Settings(trust_proxy_headers=True, trusted_proxy_cidrs="172.16.0.0/12")
assert len(settings.trusted_proxy_networks) == 1
class TestStartupFailure:
def test_bad_rate_stops_startup_with_a_readable_message(self, monkeypatch) -> None:
monkeypatch.setenv("SHARE_CREATE_RATE_LIMIT", "10/banana")
get_settings.cache_clear()
with pytest.raises(ConfigurationError) as error:
get_settings()
message = str(error.value)
assert "Configuration invalide" in message
assert "SHARE_CREATE_RATE_LIMIT" in message
assert "banana" in message
get_settings.cache_clear()
def test_bad_cidr_stops_startup(self, monkeypatch) -> None:
monkeypatch.setenv("TRUSTED_PROXY_CIDRS", "10.0.0.0/8,nawak")
get_settings.cache_clear()
with pytest.raises(ConfigurationError) as error:
get_settings()
assert "TRUSTED_PROXY_CIDRS" in str(error.value)
get_settings.cache_clear()