Harden location db startup validation

This commit is contained in:
2026-04-19 23:02:43 +02:00
parent 8aeb0723c1
commit 1a2f9c75d9
7 changed files with 266 additions and 17 deletions
+41 -2
View File
@@ -1,12 +1,51 @@
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient
from app.config import get_settings
from app.main import create_app
def _make_alembic_config(database_url: str) -> Config:
config = Config("alembic.ini")
config.set_main_option("sqlalchemy.url", database_url)
return config
@pytest.fixture
def app():
return create_app()
def test_database_urls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
location_database_path = tmp_path / "location_test.db"
poo_database_path = tmp_path / "poo_placeholder.db"
location_database_url = f"sqlite:///{location_database_path}"
poo_database_url = f"sqlite:///{poo_database_path}"
monkeypatch.setenv("LOCATION_DATABASE_URL", location_database_url)
monkeypatch.setenv("POO_DATABASE_URL", poo_database_url)
get_settings.cache_clear()
try:
yield {
"location_path": location_database_path,
"location_url": location_database_url,
"poo_path": poo_database_path,
"poo_url": poo_database_url,
}
finally:
get_settings.cache_clear()
@pytest.fixture
def ready_location_database(test_database_urls):
command.upgrade(_make_alembic_config(test_database_urls["location_url"]), "head")
return test_database_urls
@pytest.fixture
def app(ready_location_database):
yield create_app()
@pytest.fixture
+81
View File
@@ -1,5 +1,19 @@
import sqlite3
import anyio
import pytest
from alembic import command
from fastapi.testclient import TestClient
from app.config import get_settings
from app.main import create_app
from tests.conftest import _make_alembic_config
async def _run_lifespan(app) -> None:
async with app.router.lifespan_context(app):
return None
def test_app_starts(client: TestClient) -> None:
response = client.get("/")
@@ -11,3 +25,70 @@ def test_status_endpoint(client: TestClient) -> None:
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_app_start_fails_when_location_db_missing(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{tmp_path / 'missing.db'}")
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{tmp_path / 'poo_placeholder.db'}")
get_settings.cache_clear()
app = create_app()
with pytest.raises(RuntimeError, match="Run 'python scripts/location_db_adopt.py' first"):
anyio.run(_run_lifespan, app)
get_settings.cache_clear()
def test_app_start_fails_when_location_db_exists_but_is_not_adopted(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
database_path = tmp_path / "legacy_only.db"
conn = sqlite3.connect(database_path)
conn.execute(
"""
CREATE TABLE location (
person TEXT NOT NULL,
datetime TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
PRIMARY KEY (person, datetime)
)
"""
)
conn.execute("PRAGMA user_version = 2")
conn.commit()
conn.close()
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{database_path}")
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{tmp_path / 'poo_placeholder.db'}")
get_settings.cache_clear()
app = create_app()
with pytest.raises(RuntimeError, match="is not yet Alembic-managed"):
anyio.run(_run_lifespan, app)
get_settings.cache_clear()
def test_app_start_fails_when_location_db_revision_mismatches(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
database_path = tmp_path / "wrong_revision.db"
command.upgrade(_make_alembic_config(f"sqlite:///{database_path}"), "head")
conn = sqlite3.connect(database_path)
conn.execute("UPDATE alembic_version SET version_num = 'wrong_revision'")
conn.commit()
conn.close()
monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{database_path}")
monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{tmp_path / 'poo_placeholder.db'}")
get_settings.cache_clear()
app = create_app()
with pytest.raises(RuntimeError, match="Location DB revision mismatch"):
anyio.run(_run_lifespan, app)
get_settings.cache_clear()
+44 -8
View File
@@ -25,11 +25,8 @@ def _make_alembic_config(database_url: str) -> Config:
@pytest.fixture
def location_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
database_path = tmp_path / "location_test.db"
database_url = f"sqlite:///{database_path}"
command.upgrade(_make_alembic_config(database_url), "head")
def location_client(ready_location_database, monkeypatch: pytest.MonkeyPatch):
database_url = ready_location_database["location_url"]
engine = create_engine(database_url, connect_args={"check_same_thread": False})
session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False)
@@ -122,9 +119,11 @@ def test_location_record_endpoint_keeps_legacy_lenient_number_parsing(location_c
def test_legacy_style_location_db_can_be_stamped_and_adopted(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
test_database_urls, monkeypatch: pytest.MonkeyPatch
) -> None:
database_path = tmp_path / "legacy_location.db"
database_path = test_database_urls["location_path"]
database_url = test_database_urls["location_url"]
conn = sqlite3.connect(database_path)
conn.execute(
"""
@@ -142,7 +141,6 @@ def test_legacy_style_location_db_can_be_stamped_and_adopted(
conn.commit()
conn.close()
database_url = f"sqlite:///{database_path}"
command.stamp(_make_alembic_config(database_url), LOCATION_BASELINE_REVISION)
engine = create_engine(database_url, connect_args={"check_same_thread": False})
@@ -228,6 +226,44 @@ def test_location_db_adoption_validates_and_stamps_legacy_db(tmp_path: Path) ->
assert revision == LOCATION_BASELINE_REVISION
def test_location_db_adoption_accepts_already_managed_matching_revision(
tmp_path: Path,
) -> None:
database_path = tmp_path / "managed_location.db"
command.upgrade(_make_alembic_config(f"sqlite:///{database_path}"), "head")
result = adopt_or_initialize_location_db(f"sqlite:///{database_path}")
assert result == "already_managed"
def test_location_db_adoption_fails_closed_on_alembic_revision_mismatch(
tmp_path: Path,
) -> None:
database_path = tmp_path / "wrong_revision.db"
conn = sqlite3.connect(database_path)
conn.execute(
"""
CREATE TABLE location (
person TEXT NOT NULL,
datetime TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
PRIMARY KEY (person, datetime)
)
"""
)
conn.execute("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
conn.execute("INSERT INTO alembic_version (version_num) VALUES ('wrong_revision')")
conn.execute(f"PRAGMA user_version = {EXPECTED_USER_VERSION}")
conn.commit()
conn.close()
with pytest.raises(LocationDatabaseAdoptionError, match="revision does not match"):
adopt_or_initialize_location_db(f"sqlite:///{database_path}")
def test_location_db_adoption_fails_closed_on_schema_mismatch(tmp_path: Path) -> None:
database_path = tmp_path / "bad_schema.db"
conn = sqlite3.connect(database_path)