diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddc3c49..94efbfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ env: jobs: lint: - runs-on: runners-cartsnitch + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python @@ -33,7 +33,7 @@ jobs: run: ruff check src/ tests/ test: - runs-on: runners-cartsnitch + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python @@ -46,7 +46,7 @@ jobs: run: pytest tests/ -v build-and-push: - runs-on: runners-cartsnitch + runs-on: ubuntu-latest if: github.event_name == 'push' needs: [lint, test] outputs: @@ -102,7 +102,7 @@ jobs: git push origin "v${{ steps.calver.outputs.version }}" grype: - runs-on: runners-cartsnitch + runs-on: ubuntu-latest needs: [build-and-push] if: github.event_name == 'push' steps: @@ -126,24 +126,15 @@ jobs: ignore-file: .grype.yaml deploy-dev: - runs-on: runners-cartsnitch + runs-on: ubuntu-latest needs: [grype] if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/dev' steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.CARTSNITCH_APP_ID }} - private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: infra - - name: Checkout infra repo uses: actions/checkout@v4 with: repository: cartsnitch/infra - token: ${{ steps.app-token.outputs.token }} + token: ${{ secrets.GITEA_TOKEN }} ref: main path: infra @@ -169,24 +160,15 @@ jobs: git push origin main deploy-uat: - runs-on: runners-cartsnitch + runs-on: ubuntu-latest needs: [grype] if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/uat' steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.CARTSNITCH_APP_ID }} - private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: infra - - name: Checkout infra repo uses: actions/checkout@v4 with: repository: cartsnitch/infra - token: ${{ steps.app-token.outputs.token }} + token: ${{ secrets.GITEA_TOKEN }} ref: main path: infra 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()