Skip to content

Commit d200bad

Browse files
authored
Merge pull request #10 from Devathmaj/security-hardening
Security hardening + DB init/bootstrap reliability fixes
2 parents 6429363 + 19ff4a6 commit d200bad

3 files changed

Lines changed: 113 additions & 15 deletions

File tree

tests/test_init_db.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Unit tests for database init / enum migration helpers."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
from unittest.mock import AsyncMock, MagicMock, patch
7+
8+
import pytest
9+
10+
from voucherbot.database import init_db as init
11+
from voucherbot.database.init_db import _ensure_source_type_enum
12+
13+
14+
class _FakeResult:
15+
"""Minimal stand-in for a SQLAlchemy result/mapping."""
16+
17+
def __init__(self, rows: list[Any]) -> None:
18+
self._rows = rows
19+
20+
def __iter__(self) -> Any:
21+
return iter(self._rows)
22+
23+
def scalar(self) -> Any:
24+
if not self._rows:
25+
return None
26+
return self._rows[0][0] if isinstance(self._rows[0], tuple) else self._rows[0]
27+
28+
29+
@pytest.mark.asyncio
30+
async def test_ensure_source_type_enum_skips_when_type_missing() -> None:
31+
conn = _conn_mock(scalar_value=False)
32+
engine = MagicMock()
33+
engine.connect.return_value = conn
34+
35+
with patch.object(init, "engine", engine):
36+
await _ensure_source_type_enum()
37+
38+
conn.execute.assert_not_called()
39+
conn.__aexit__.assert_awaited_once()
40+
41+
42+
def _conn_mock(scalar_value: bool, rows: list[Any] | None = None) -> Any:
43+
"""AsyncMock connection that behaves like a started ``AsyncConnection``.
44+
45+
Supports ``async with`` (starting/exit) and returns ``self`` from
46+
``execution_options`` as the real connection does.
47+
"""
48+
conn = AsyncMock()
49+
conn.__aenter__.return_value = conn
50+
conn.execution_options.return_value = conn
51+
conn.scalar.return_value = scalar_value
52+
conn.execute.return_value = _FakeResult(rows or [])
53+
return conn
54+
55+
56+
@pytest.mark.asyncio
57+
async def test_ensure_source_type_enum_adds_missing_values_only() -> None:
58+
calls: list[str] = []
59+
60+
async def fake_execute(statement: Any) -> Any:
61+
calls.append(str(statement))
62+
if "enum_range" in str(statement):
63+
return _FakeResult([("REDDIT",), ("RSS",), ("BLOG",)])
64+
return _FakeResult([])
65+
66+
conn = _conn_mock(scalar_value=True, rows=[("REDDIT",), ("RSS",), ("BLOG",)])
67+
conn.execute.side_effect = fake_execute
68+
engine = MagicMock()
69+
engine.connect.return_value = conn
70+
71+
with patch.object(init, "engine", engine):
72+
await _ensure_source_type_enum()
73+
74+
alter_sql = [c for c in calls if c.startswith("ALTER TYPE")]
75+
assert alter_sql == [
76+
"ALTER TYPE sourcetype ADD VALUE 'PEARSONVUE'",
77+
"ALTER TYPE sourcetype ADD VALUE 'TRAINING_PROVIDER'",
78+
]
79+
assert "REDDIT" not in alter_sql
80+
81+
82+
@pytest.mark.asyncio
83+
async def test_ensure_source_type_enum_closes_connection() -> None:
84+
conn = _conn_mock(scalar_value=True, rows=[])
85+
engine = MagicMock()
86+
engine.connect.return_value = conn
87+
88+
with patch.object(init, "engine", engine):
89+
await _ensure_source_type_enum()
90+
91+
conn.__aexit__.assert_awaited_once()

voucherbot/database/init_db.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,30 @@
1515
async def _ensure_source_type_enum() -> None:
1616
"""Add new SourceType enum values if they don't exist yet.
1717
18-
ALTER TYPE ... ADD VALUE cannot run inside a transaction, so we use
19-
a raw connection with autocommit.
20-
"""
21-
from sqlalchemy import inspect
18+
ALTER TYPE ... ADD VALUE cannot run inside a transaction block and any
19+
failing statement would poison an implicit transaction, so we run on an
20+
explicit autocommit connection where each statement commits on its own.
2221
22+
When the enum type itself does not exist yet (fresh DB), there is nothing
23+
to migrate — ``create_all`` creates it with the full value set.
24+
"""
2325
async with engine.connect() as conn:
24-
await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names())
25-
try:
26-
result = await conn.execute(
27-
text("SELECT unnest(enum_range(NULL::sourcetype))::text AS val")
28-
)
29-
existing = {row[0] for row in result}
30-
except Exception:
31-
existing = set()
26+
conn = await conn.execution_options(isolation_level="AUTOCOMMIT")
27+
28+
type_exists = await conn.scalar(
29+
text("SELECT to_regtype('sourcetype') IS NOT NULL")
30+
)
31+
if not type_exists:
32+
return
33+
34+
result = await conn.execute(
35+
text("SELECT unnest(enum_range(NULL::sourcetype))::text AS val")
36+
)
37+
existing = {row[0] for row in result}
3238

3339
for val in _NEW_ENUM_VALUES:
3440
if val not in existing:
3541
await conn.execute(text(f"ALTER TYPE sourcetype ADD VALUE '{val}'"))
36-
await conn.commit()
3742

3843

3944
async def init_db() -> None:

voucherbot/models/vendor_mapping.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ class VendorMapping(Base):
1010
__tablename__ = "vendor_mappings"
1111

1212
id: Mapped[int] = mapped_column(primary_key=True)
13-
url_pattern: Mapped[Optional[str]] = mapped_column(String, nullable=True)
13+
url_pattern: Mapped[Optional[str]] = mapped_column(
14+
String, nullable=True, unique=True
15+
)
1416
source_name_pattern: Mapped[Optional[str]] = mapped_column(
15-
String, nullable=True, index=True
17+
String, nullable=True, index=True, unique=True
1618
)
1719
vendor: Mapped[str] = mapped_column(String, nullable=False)

0 commit comments

Comments
 (0)