refactor: replace config.yml with .env for all configuration

All settings (auth credentials, scanner ranges, status_checker interval)
now live in a single .env file via pydantic-settings. config.yml and
config.yml.example are deleted.

- Settings: add auth_username, auth_password_hash, scanner_ranges,
  status_checker_interval; add load_overrides()/save_overrides() for
  persisting runtime changes to data/scan_config.json
- auth.py: read credentials directly from settings
- scan.py: read ranges/interval from settings; write-back via save_overrides()
- scheduler.py: read interval directly from settings
- main.py: call settings.load_overrides() at startup
- docker-compose.yml: remove config.yml volume mount, add new env vars
- conftest.py: set settings fields directly instead of writing a temp config.yml
This commit is contained in:
Pouzor
2026-03-09 11:26:49 +01:00
parent acc5bc6a64
commit a56204a5f2
18 changed files with 117 additions and 212 deletions
+4 -14
View File
@@ -4,7 +4,6 @@ import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest
import yaml
from httpx import ASGITransport, AsyncClient
from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
@@ -18,20 +17,11 @@ _pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
@pytest.fixture(autouse=True, scope="session")
def test_config_file(tmp_path_factory):
"""Write a minimal config.yml for the test session and point settings at it."""
cfg = {
"auth": {
"username": "admin",
"password_hash": _pwd_ctx.hash("admin"),
},
"scanner": {"ranges": []},
"status_checker": {"interval_seconds": 3600},
}
cfg_path = tmp_path_factory.mktemp("cfg") / "config.yml"
cfg_path.write_text(yaml.dump(cfg))
def test_credentials():
"""Configure test auth credentials directly on settings."""
from app.core.config import settings
settings.config_path = str(cfg_path)
settings.auth_username = "admin"
settings.auth_password_hash = _pwd_ctx.hash("admin")
@pytest.fixture
+3 -12
View File
@@ -1,16 +1,7 @@
from unittest.mock import patch
import pytest
from httpx import AsyncClient
@pytest.fixture
def mock_credentials():
with patch("app.api.routes.auth._load_credentials", return_value=("admin", "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS")):
yield
async def test_login_success(client: AsyncClient, mock_credentials):
async def test_login_success(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
assert res.status_code == 200
data = res.json()
@@ -18,12 +9,12 @@ async def test_login_success(client: AsyncClient, mock_credentials):
assert data["token_type"] == "bearer"
async def test_login_wrong_password(client: AsyncClient, mock_credentials):
async def test_login_wrong_password(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 401
async def test_login_wrong_username(client: AsyncClient, mock_credentials):
async def test_login_wrong_username(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "notadmin", "password": "admin"})
assert res.status_code == 401
+1 -5
View File
@@ -1,16 +1,12 @@
import uuid
from unittest.mock import patch
import pytest
from httpx import AsyncClient
TOKEN_HASH = "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
@pytest.fixture
async def headers(client: AsyncClient):
with patch("app.api.routes.auth._load_credentials", return_value=("admin", TOKEN_HASH)):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
return {"Authorization": f"Bearer {res.json()['access_token']}"}
+1 -6
View File
@@ -1,15 +1,10 @@
from unittest.mock import patch
import pytest
from httpx import AsyncClient
TOKEN_HASH = "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
@pytest.fixture
async def headers(client: AsyncClient):
with patch("app.api.routes.auth._load_credentials", return_value=("admin", TOKEN_HASH)):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
+1 -6
View File
@@ -1,15 +1,10 @@
from unittest.mock import patch
import pytest
from httpx import AsyncClient
TOKEN_HASH = "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
@pytest.fixture
async def headers(client: AsyncClient):
with patch("app.api.routes.auth._load_credentials", return_value=("admin", TOKEN_HASH)):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
+2 -1
View File
@@ -49,8 +49,9 @@ async def test_trigger_scan_requires_auth(client: AsyncClient):
async def test_trigger_scan_creates_run(client: AsyncClient, headers):
with (
patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
patch("app.api.routes.scan._load_ranges", return_value=["192.168.1.0/24"]),
patch("app.api.routes.scan.settings") as mock_settings,
):
mock_settings.scanner_ranges = ["192.168.1.0/24"]
res = await client.post("/api/v1/scan/trigger", headers=headers)
assert res.status_code == 200
data = res.json()
+14 -46
View File
@@ -1,11 +1,11 @@
"""Tests for background scheduler: _load_interval, _run_status_checks, lifecycle."""
"""Tests for background scheduler: _run_status_checks, lifecycle."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.scheduler import _load_interval, _run_status_checks, start_scheduler, stop_scheduler
from app.core.scheduler import _run_status_checks, start_scheduler, stop_scheduler
from app.db.database import Base
from app.db.models import Node
@@ -25,35 +25,6 @@ def _make_node(**kwargs) -> Node:
return Node(**{**defaults, **kwargs})
# ---------------------------------------------------------------------------
# _load_interval
# ---------------------------------------------------------------------------
def test_load_interval_reads_from_config(tmp_path):
"""_load_interval returns the value set in config.yml."""
cfg = tmp_path / "config.yml"
cfg.write_text("status_checker:\n interval_seconds: 30\n")
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.config_path = str(cfg)
assert _load_interval() == 30
def test_load_interval_defaults_to_60_when_key_missing(tmp_path):
"""_load_interval returns 60 when status_checker section is absent."""
cfg = tmp_path / "config.yml"
cfg.write_text("auth:\n username: admin\n")
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.config_path = str(cfg)
assert _load_interval() == 60
def test_load_interval_defaults_to_60_on_missing_file():
"""_load_interval returns 60 when config file does not exist."""
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.config_path = "/nonexistent/path/config.yml"
assert _load_interval() == 60
# ---------------------------------------------------------------------------
# _run_status_checks
# ---------------------------------------------------------------------------
@@ -164,26 +135,23 @@ async def test_run_status_checks_handles_check_error_gracefully(mem_db):
# start_scheduler / stop_scheduler
# ---------------------------------------------------------------------------
def test_scheduler_uses_settings_interval():
"""Scheduler registers the job with the interval from settings."""
mock_sched = MagicMock()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 45
start_scheduler()
_, kwargs = mock_sched.add_job.call_args
assert kwargs["seconds"] == 45
def test_start_and_stop_scheduler():
"""Scheduler can be started and stopped without errors."""
mock_sched = MagicMock()
with patch("app.core.scheduler._load_interval", return_value=3600), \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
with patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
start_scheduler()
stop_scheduler()
mock_sched.add_job.assert_called_once()
mock_sched.start.assert_called_once()
mock_sched.shutdown.assert_called_once()
def test_start_scheduler_uses_configured_interval():
"""Scheduler registers the status_checks job with the correct interval."""
mock_sched = MagicMock()
with patch("app.core.scheduler._load_interval", return_value=120) as mock_interval, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
start_scheduler()
mock_interval.assert_called_once()
mock_sched.add_job.assert_called_once()
_, kwargs = mock_sched.add_job.call_args
assert kwargs.get("seconds") == 120
mock_sched.start.assert_called_once()