319 lines
12 KiB
Python
319 lines
12 KiB
Python
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from ragcore.config import Settings
|
|
|
|
|
|
def test_log_level_is_normalized(tmp_path: Path) -> None:
|
|
settings = Settings(
|
|
log_level="warning",
|
|
blob_root=tmp_path / "blobs",
|
|
parsed_root=tmp_path / "parsed",
|
|
temp_root=tmp_path / "tmp",
|
|
)
|
|
assert settings.log_level == "WARNING"
|
|
|
|
|
|
def test_invalid_log_level_is_rejected() -> None:
|
|
with pytest.raises(ValidationError):
|
|
Settings(log_level="verbose")
|
|
|
|
|
|
def _production_settings(tmp_path: Path, **overrides: object) -> Settings:
|
|
values: dict[str, object] = {
|
|
"env": "production",
|
|
"secret_key": "s" * 40,
|
|
"api_token_pepper": "p" * 40,
|
|
"qdrant_api_key": "q" * 40,
|
|
"database_url": "postgresql+psycopg://app:s3cure-db-passw0rd-xyz@postgres:5432/ragcore",
|
|
"blob_root": tmp_path / "blobs",
|
|
"parsed_root": tmp_path / "parsed",
|
|
"temp_root": tmp_path / "tmp",
|
|
"model_state_root": tmp_path / "models",
|
|
"public_base_url": "https://ragcore.example.test",
|
|
"trusted_proxy_ips": "172.30.50.0/29",
|
|
}
|
|
values.update(overrides)
|
|
return Settings(**values)
|
|
|
|
|
|
def test_production_rejects_default_secret(tmp_path: Path) -> None:
|
|
with pytest.raises(ValidationError, match="secret_key"):
|
|
Settings(
|
|
env="production",
|
|
secret_key="development-only-change-me",
|
|
api_token_pepper="p" * 40,
|
|
qdrant_api_key="q" * 40,
|
|
database_url="postgresql+psycopg://app:password@postgres:5432/ragcore",
|
|
blob_root=tmp_path / "blobs",
|
|
parsed_root=tmp_path / "parsed",
|
|
temp_root=tmp_path / "tmp",
|
|
)
|
|
|
|
|
|
def test_production_rejects_short_secret(tmp_path: Path) -> None:
|
|
with pytest.raises(ValidationError, match="secret_key"):
|
|
_production_settings(tmp_path, secret_key="too-short")
|
|
|
|
|
|
def test_production_rejects_placeholder_qdrant_key(tmp_path: Path) -> None:
|
|
with pytest.raises(ValidationError, match="qdrant_api_key"):
|
|
_production_settings(tmp_path, qdrant_api_key="replace-with-at-least-32-random-characters")
|
|
|
|
|
|
def test_production_accepts_strong_secrets_and_absolute_storage(tmp_path: Path) -> None:
|
|
settings = _production_settings(tmp_path)
|
|
|
|
assert settings.env == "production"
|
|
assert settings.blob_root.is_absolute()
|
|
|
|
|
|
def test_trusted_proxy_ips_are_normalized_and_deduplicated() -> None:
|
|
settings = Settings(trusted_proxy_ips="172.30.50.2, 172.30.50.0/29,172.30.50.2")
|
|
assert settings.trusted_proxy_ips == ("172.30.50.2", "172.30.50.0/29")
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["*", "0.0.0.0/0", "::/0", "proxy.local"])
|
|
def test_wildcard_or_non_ip_proxy_trust_is_rejected(value: str) -> None:
|
|
with pytest.raises(ValidationError, match="trusted_proxy_ips"):
|
|
Settings(trusted_proxy_ips=value)
|
|
|
|
|
|
def test_production_requires_an_explicit_trusted_proxy(tmp_path: Path) -> None:
|
|
with pytest.raises(ValidationError, match="trusted_proxy_ips"):
|
|
_production_settings(tmp_path, trusted_proxy_ips=())
|
|
|
|
|
|
def test_production_requires_an_https_public_url(tmp_path: Path) -> None:
|
|
with pytest.raises(ValidationError, match="public_base_url"):
|
|
_production_settings(tmp_path, public_base_url="http://127.0.0.1:1237")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field_name", "value"),
|
|
[
|
|
("database_url", "sqlite:///ragcore.db"),
|
|
("qdrant_url", "postgresql://qdrant:6333"),
|
|
("ollama_url", "not-a-url"),
|
|
("public_base_url", "http://"),
|
|
],
|
|
)
|
|
def test_invalid_urls_are_rejected(field_name: str, value: str) -> None:
|
|
with pytest.raises(ValidationError):
|
|
Settings(**{field_name: value})
|
|
|
|
|
|
def test_storage_path_traversal_is_rejected() -> None:
|
|
with pytest.raises(ValidationError, match="storage paths"):
|
|
Settings(blob_root=Path("../outside"))
|
|
|
|
|
|
def test_production_rejects_user_share_storage(tmp_path: Path) -> None:
|
|
with pytest.raises(ValidationError, match="direct pool path"):
|
|
_production_settings(tmp_path, blob_root=Path("/mnt/pool/appdata/ragcore/blobs"))
|
|
|
|
|
|
@pytest.mark.parametrize("password", ["ragcore", "postgres", "password", "changeme", "RAGCORE"])
|
|
def test_production_rejects_default_or_weak_database_passwords(
|
|
tmp_path: Path, password: str
|
|
) -> None:
|
|
with pytest.raises(ValidationError, match="weak password"):
|
|
_production_settings(
|
|
tmp_path,
|
|
database_url=f"postgresql+psycopg://app:{password}@postgres:5432/ragcore",
|
|
)
|
|
|
|
|
|
def test_production_rejects_short_database_password(tmp_path: Path) -> None:
|
|
with pytest.raises(ValidationError, match="weak password"):
|
|
_production_settings(
|
|
tmp_path, database_url="postgresql+psycopg://app:short1@postgres:5432/ragcore"
|
|
)
|
|
|
|
|
|
def test_production_accepts_a_strong_database_password(tmp_path: Path) -> None:
|
|
settings = _production_settings(
|
|
tmp_path,
|
|
database_url="postgresql+psycopg://app:a-genuinely-long-random-password@postgres:5432/ragcore",
|
|
)
|
|
assert settings.env == "production"
|
|
|
|
|
|
def test_verify_runtime_directories_writable_passes_for_writable_dirs(tmp_path: Path) -> None:
|
|
settings = Settings(
|
|
blob_root=tmp_path / "blobs",
|
|
parsed_root=tmp_path / "parsed",
|
|
temp_root=tmp_path / "tmp",
|
|
model_state_root=tmp_path / "models",
|
|
)
|
|
settings.ensure_runtime_directories()
|
|
settings.verify_runtime_directories_writable()
|
|
|
|
|
|
def test_verify_runtime_directories_writable_raises_for_a_missing_directory(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
settings = Settings(
|
|
blob_root=tmp_path / "blobs",
|
|
parsed_root=tmp_path / "parsed",
|
|
temp_root=tmp_path / "tmp",
|
|
model_state_root=tmp_path / "models",
|
|
)
|
|
with pytest.raises(OSError):
|
|
settings.verify_runtime_directories_writable()
|
|
|
|
|
|
def test_production_ignores_placeholder_oidc_secret_when_oidc_is_not_configured(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""oidc_client_secret defaults to a placeholder; that's fine as long as
|
|
oidc_workspace_id is unset, since the /auth/* routes never activate."""
|
|
|
|
settings = _production_settings(tmp_path)
|
|
assert settings.oidc_workspace_id is None
|
|
assert settings.oidc_client_secret.get_secret_value() == "replace-me"
|
|
|
|
|
|
def test_production_rejects_placeholder_oidc_secret_once_oidc_is_configured(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
with pytest.raises(ValidationError, match="oidc_client_secret"):
|
|
_production_settings(tmp_path, oidc_workspace_id=uuid4())
|
|
|
|
|
|
def test_production_accepts_a_strong_oidc_secret_once_configured(tmp_path: Path) -> None:
|
|
settings = _production_settings(
|
|
tmp_path, oidc_workspace_id=uuid4(), oidc_client_secret="o" * 40
|
|
)
|
|
assert settings.oidc_workspace_id is not None
|
|
|
|
|
|
def test_embedding_profiles_path_derives_from_model_state_root(tmp_path: Path) -> None:
|
|
settings = Settings(model_state_root=tmp_path / "models")
|
|
assert settings.embedding_profiles_path == tmp_path / "models" / "embedding_profiles.json"
|
|
|
|
|
|
def test_rc_embedding_bootstrap_requires_the_operator_command(tmp_path: Path) -> None:
|
|
rc_settings = _production_settings(tmp_path, env="rc")
|
|
|
|
assert rc_settings.automatic_embedding_bootstrap_enabled is False
|
|
assert Settings(env="development").automatic_embedding_bootstrap_enabled is True
|
|
assert _production_settings(tmp_path).automatic_embedding_bootstrap_enabled is True
|
|
|
|
|
|
def test_ensure_runtime_directories_creates_model_state_root(tmp_path: Path) -> None:
|
|
settings = Settings(
|
|
blob_root=tmp_path / "blobs",
|
|
parsed_root=tmp_path / "parsed",
|
|
temp_root=tmp_path / "tmp",
|
|
model_state_root=tmp_path / "models",
|
|
)
|
|
settings.ensure_runtime_directories()
|
|
assert settings.model_state_root.is_dir()
|
|
|
|
|
|
def test_model_state_root_traversal_is_rejected() -> None:
|
|
with pytest.raises(ValidationError, match="storage paths"):
|
|
Settings(model_state_root=Path("../outside"))
|
|
|
|
|
|
def test_production_rejects_user_share_model_state_root(tmp_path: Path) -> None:
|
|
with pytest.raises(ValidationError, match="direct pool path"):
|
|
_production_settings(tmp_path, model_state_root=Path("/mnt/pool/appdata/ragcore/models"))
|
|
|
|
|
|
def test_production_requires_absolute_model_state_root(tmp_path: Path) -> None:
|
|
with pytest.raises(ValidationError, match="must be absolute"):
|
|
_production_settings(tmp_path, model_state_root=Path("relative/models"))
|
|
|
|
|
|
def test_secret_repr_and_json_are_redacted() -> None:
|
|
secret = "actual-secret-value-that-must-not-leak"
|
|
settings = Settings(secret_key=secret, api_token_pepper="another-secret-value")
|
|
|
|
assert secret not in repr(settings)
|
|
assert secret not in settings.model_dump_json()
|
|
assert "**********" in settings.model_dump_json()
|
|
|
|
|
|
# -- the filesystem source allowlist ----------------------------------------
|
|
|
|
|
|
def test_source_roots_default_to_empty_so_no_folder_source_can_be_read() -> None:
|
|
"""Fail closed. There is no implicit default root: a deployment that never
|
|
named one reads no server-side paths at all."""
|
|
|
|
assert Settings().source_roots == ()
|
|
|
|
|
|
def test_source_roots_accept_the_comma_separated_environment_form() -> None:
|
|
settings = Settings(source_roots="/mnt/rag-sources, /srv/corpora")
|
|
|
|
assert settings.source_roots == (Path("/mnt/rag-sources"), Path("/srv/corpora"))
|
|
|
|
|
|
def test_source_roots_accept_a_list() -> None:
|
|
settings = Settings(source_roots=["/mnt/rag-sources"])
|
|
|
|
assert settings.source_roots == (Path("/mnt/rag-sources"),)
|
|
|
|
|
|
def test_empty_entries_are_ignored_rather_than_becoming_roots() -> None:
|
|
"""A trailing comma must not silently produce a root that means "here"."""
|
|
|
|
assert Settings(source_roots="/mnt/rag-sources,,").source_roots == (Path("/mnt/rag-sources"),)
|
|
assert Settings(source_roots="").source_roots == ()
|
|
|
|
|
|
def test_a_filesystem_root_is_rejected_at_startup() -> None:
|
|
"""Configuring "/" would make the allowlist meaningless, so it is refused
|
|
when the process boots rather than when a source is first read."""
|
|
|
|
with pytest.raises(ValidationError, match="must not be a filesystem root"):
|
|
Settings(source_roots="/")
|
|
|
|
|
|
def test_a_relative_source_root_is_rejected_at_startup() -> None:
|
|
with pytest.raises(ValidationError, match="absolute"):
|
|
Settings(source_roots="rag-sources")
|
|
|
|
|
|
def test_a_traversing_source_root_is_rejected_at_startup() -> None:
|
|
with pytest.raises(ValidationError, match="traverse"):
|
|
Settings(source_roots="/mnt/rag-sources/../..")
|
|
|
|
|
|
def test_duplicate_source_roots_collapse() -> None:
|
|
settings = Settings(source_roots="/mnt/rag-sources,/mnt/rag-sources")
|
|
|
|
assert settings.source_roots == (Path("/mnt/rag-sources"),)
|
|
|
|
|
|
def test_production_rejects_a_user_share_source_root(tmp_path: Path) -> None:
|
|
"""Same rule the storage paths follow: /mnt/user is Unraid's fuse share,
|
|
not a direct pool path."""
|
|
|
|
with pytest.raises(ValidationError, match="direct pool path"):
|
|
_production_settings(tmp_path, source_roots="/mnt/pool/rag-sources")
|
|
|
|
|
|
def test_production_accepts_a_direct_pool_source_root(tmp_path: Path) -> None:
|
|
settings = _production_settings(tmp_path, source_roots="/mnt/cache/rag-sources")
|
|
|
|
assert settings.source_roots == (Path("/mnt/cache/rag-sources"),)
|
|
|
|
|
|
def test_worker_concurrency_defaults_to_one_and_is_bounded() -> None:
|
|
"""One by default: a deployment that upgrades gets exactly the throughput
|
|
it had, until an operator asks for more."""
|
|
|
|
assert Settings(env="test").worker_concurrency == 1
|
|
assert Settings(env="test", worker_concurrency=8).worker_concurrency == 8
|
|
|
|
for rejected in (0, -1, 65):
|
|
with pytest.raises(ValidationError):
|
|
Settings(env="test", worker_concurrency=rejected)
|