diff --git a/alembic/versions/001_add_email_inbound_token.py b/alembic/versions/001_add_email_inbound_token.py index e2e5ff2..43a6fe8 100644 --- a/alembic/versions/001_add_email_inbound_token.py +++ b/alembic/versions/001_add_email_inbound_token.py @@ -34,4 +34,4 @@ def upgrade() -> None: def downgrade() -> None: op.drop_constraint("uq_users_email_inbound_token", "users", type_="unique") - op.drop_column("users", "email_inbound_token") + op.drop_column("users", "email_inbound_token") \ No newline at end of file diff --git a/src/receiptwitness/config.py b/src/receiptwitness/config.py index 3d3690a..4843962 100644 --- a/src/receiptwitness/config.py +++ b/src/receiptwitness/config.py @@ -39,8 +39,8 @@ class ReceiptWitnessSettings(BaseSettings): if not self.session_encryption_key or self.session_encryption_key in _PLACEHOLDER_VALUES: errors.append( "RW_SESSION_ENCRYPTION_KEY must be set to a secure value. " - "Generate one with: python -c \"from cryptography.fernet import Fernet; " - 'print(Fernet.generate_key().decode())"' + "Generate one with: python -c " + '"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"' ) if self.notifications_enabled and not self.resend_api_key: errors.append( diff --git a/src/receiptwitness/shared/models/stub_purchase.py b/src/receiptwitness/shared/models/stub_purchase.py index 81e9157..5d500c3 100644 --- a/src/receiptwitness/shared/models/stub_purchase.py +++ b/src/receiptwitness/shared/models/stub_purchase.py @@ -51,14 +51,13 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base): nullable=False, ) - # Relationships (stubs — canonical definitions in cartsnitch/common) - user: Mapped["User"] = relationship(back_populates="purchases") - __table_args__ = ( Index("ix_purchases_user_store", "user_id", "store_id"), UniqueConstraint("user_id", "store_id", "receipt_id", name="uq_purchase_receipt"), ) + user: Mapped["User"] = relationship(back_populates="purchases") + class PurchaseItem(UUIDPrimaryKeyMixin, TimestampMixin, Base): """Stub: a line item on a receipt. Full definition in cartsnitch/common.""" diff --git a/src/receiptwitness/shared/models/stub_store.py b/src/receiptwitness/shared/models/stub_store.py index 6ffcc67..f08bbc5 100644 --- a/src/receiptwitness/shared/models/stub_store.py +++ b/src/receiptwitness/shared/models/stub_store.py @@ -28,7 +28,6 @@ class Store(UUIDPrimaryKeyMixin, TimestampMixin, Base): logo_url: Mapped[str | None] = mapped_column(String(500)) website_url: Mapped[str | None] = mapped_column(String(500)) - # Relationships (stubs — canonical definitions in cartsnitch/common) user_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="store") diff --git a/src/receiptwitness/shared/models/user.py b/src/receiptwitness/shared/models/user.py index 9849dc3..7ee36b5 100644 --- a/src/receiptwitness/shared/models/user.py +++ b/src/receiptwitness/shared/models/user.py @@ -5,7 +5,7 @@ import uuid from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, text from sqlalchemy.orm import Mapped, mapped_column, relationship from receiptwitness.shared.constants import AccountStatus @@ -27,6 +27,9 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): nullable=False, unique=True, default=lambda: secrets.token_urlsafe(16), + server_default=text( + "replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')" # noqa: E501 + ), ) hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) display_name: Mapped[str | None] = mapped_column(String(100)) diff --git a/tests/test_pipeline/conftest.py b/tests/test_pipeline/conftest.py index 3f02aad..baf38bd 100644 --- a/tests/test_pipeline/conftest.py +++ b/tests/test_pipeline/conftest.py @@ -1,16 +1,31 @@ """Shared test fixtures for pipeline tests.""" +import secrets + import pytest -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker from receiptwitness.shared.models import Base +from receiptwitness.shared.models.user import User + + +@event.listens_for(User, "before_insert") +def _populate_email_inbound_token(mapper, connection, target): + """Populate email_inbound_token with a secure random value when unset. + + SQLite has no gen_random_bytes() function, so we generate it in Python + instead of relying on the PostgreSQL server_default. + """ + if target.email_inbound_token is None: + target.email_inbound_token = secrets.token_urlsafe(16) @pytest.fixture def engine(): """In-memory SQLite engine for unit tests.""" eng = create_engine("sqlite:///:memory:") + User.__table__.c.email_inbound_token.server_default = None Base.metadata.create_all(eng) yield eng eng.dispose()