Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,8 @@ class NewsdomProvider(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.datetime.utcnow,
onupdate=datetime.datetime.utcnow,
default=lambda: datetime.datetime.now(datetime.timezone.utc),
onupdate=lambda: datetime.datetime.now(datetime.timezone.utc),
)


Expand Down
128 changes: 128 additions & 0 deletions backend/tests/test_model_timestamp_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Timezone-aware datetime default guard for ORM columns.

Mapped datetime defaults and on-update callables must return timezone-aware
values. SQLAlchemy normalizes callable column defaults to accept an execution
context, so this guard evaluates the actual mapped callable and deliberately
lets evaluation errors fail the test instead of treating them as non-datetime
values.
"""

import datetime

import pytest
from sqlalchemy import Column, DateTime

from db.models import Base


class _NullOffsetTimezone(datetime.tzinfo):
"""tzinfo stub that is still naive under Python's datetime contract."""

def utcoffset(self, _value):
return None

def dst(self, _value):
return None

def tzname(self, _value):
return "null-offset"


def _datetime_default_callables():
for mapper in Base.registry.mappers:
for column in mapper.columns:
if not isinstance(column.type, DateTime):
continue
for kind in ("default", "onupdate"):
column_default = getattr(column, kind, None)
if column_default is None:
continue
default_callable = getattr(column_default, "arg", None)
if callable(default_callable):
yield mapper.local_table.name, column.name, kind, default_callable


def _evaluate_mapped_default(default_callable):
"""Evaluate a SQLAlchemy-mapped callable without swallowing its errors."""

return default_callable(None)


def _is_datetime_timezone_aware(value: datetime.datetime) -> bool:
return value.tzinfo is not None and value.tzinfo.utcoffset(value) is not None


def _invalid_datetime_default_entries(entries):
"""Return mapped DateTime defaults that violate the timestamp contract."""

invalid_defaults: list[str] = []
for table, column, kind, default_callable in entries:
value = _evaluate_mapped_default(default_callable)
invalid_value = not isinstance(value, datetime.datetime)
if invalid_value or not _is_datetime_timezone_aware(value):
invalid_defaults.append(f"{table}.{column} ({kind})")
return invalid_defaults


def test_datetime_column_defaults_are_timezone_aware():
invalid_defaults = _invalid_datetime_default_entries(_datetime_default_callables())

assert not invalid_defaults, (
"DateTime defaults/onupdates must return timezone-aware datetime values; "
"use lambda: datetime.datetime.now(datetime.timezone.utc): "
+ ", ".join(sorted(invalid_defaults))
)


def test_datetime_default_guard_does_not_swallow_callable_failures():
def broken_default(_context):
raise TypeError("default evaluation failed")

try:
_evaluate_mapped_default(broken_default)
except TypeError as exc:
assert str(exc) == "default evaluation failed"
else:
raise AssertionError("default evaluation failures must fail closed")


def test_datetime_default_guard_excludes_non_datetime_callable_defaults():
guarded_columns = {
(table, column, kind)
for table, column, kind, _default_callable in _datetime_default_callables()
}

assert ("security_audit_events", "event_uid", "default") not in guarded_columns


@pytest.mark.parametrize("invalid_value", ["2026-09-11T00:00:00+00:00", 1757548800, None])
def test_datetime_default_guard_rejects_non_datetime_results(invalid_value):
def invalid_default(_context, value=invalid_value):
return value

invalid_defaults = _invalid_datetime_default_entries(
[("example_table", "updated_at", "default", invalid_default)]
)

assert invalid_defaults == ["example_table.updated_at (default)"]


def test_timezone_awareness_rejects_tzinfo_with_null_offset():
value = datetime.datetime(2026, 9, 11, tzinfo=_NullOffsetTimezone())

assert not _is_datetime_timezone_aware(value)


@pytest.mark.parametrize("invalid_value", ["not-a-date", 123, None])
def test_datetime_default_guard_rejects_column_callable_results(invalid_value):
column = Column(
"event_time",
DateTime(timezone=True),
default=lambda: invalid_value,
)

invalid_defaults = _invalid_datetime_default_entries(
[("synthetic_events", column.name, "default", lambda _context: invalid_value)]
)

assert invalid_defaults == ["synthetic_events.event_time (default)"]