Compare commits

..

1 Commits

Author SHA1 Message Date
CartSnitch Engineer Bot c116d0bc8a feat(ci): add deploy-uat job for UAT environment
Mirrors deploy-dev job but targets apps/overlays/uat. Both deploy-dev
and deploy-uat run in parallel after all build jobs complete.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 13:23:38 +00:00
25 changed files with 151 additions and 482 deletions
+33 -111
View File
@@ -2,9 +2,9 @@ name: CI
on: on:
push: push:
branches: [main, dev, uat] branches: [main]
pull_request: pull_request:
branches: [main, dev, uat] branches: [main]
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
@@ -99,11 +99,10 @@ jobs:
build-and-push: build-and-push:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
if: github.event_name == 'push' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, test, e2e] needs: [lint, test, e2e]
outputs: outputs:
calver_tag: ${{ steps.calver.outputs.version }} calver_tag: ${{ steps.calver.outputs.version }}
sha_tag: sha-${{ github.sha }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -127,14 +126,14 @@ jobs:
echo "CalVer tag: $VERSION" echo "CalVer tag: $VERSION"
- name: Log in to Docker Hub - name: Log in to Docker Hub
if: github.event_name == 'push' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR - name: Log in to GHCR
if: github.event_name == 'push' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
@@ -147,7 +146,7 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=sha,prefix=sha-,format=long type=sha,prefix=sha-
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
@@ -155,7 +154,7 @@ jobs:
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
push: ${{ github.event_name == 'push' }} push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
target: prod target: prod
@@ -170,11 +169,10 @@ jobs:
build-and-push-auth: build-and-push-auth:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
if: github.event_name == 'push' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, test, e2e] needs: [lint, test, e2e]
outputs: outputs:
calver_tag: ${{ steps.calver.outputs.version }} calver_tag: ${{ steps.calver.outputs.version }}
sha_tag: sha-${{ github.sha }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -197,14 +195,14 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Log in to Docker Hub - name: Log in to Docker Hub
if: github.event_name == 'push' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR - name: Log in to GHCR
if: github.event_name == 'push' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
@@ -217,7 +215,7 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}
tags: | tags: |
type=sha,prefix=sha-,format=long type=sha,prefix=sha-
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
@@ -226,17 +224,16 @@ jobs:
with: with:
context: ./auth context: ./auth
file: ./auth/Dockerfile file: ./auth/Dockerfile
push: ${{ github.event_name == 'push' }} push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-and-push-receiptwitness: build-and-push-receiptwitness:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
if: github.event_name == 'push' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, test] needs: [lint, test]
outputs: outputs:
calver_tag: ${{ steps.calver.outputs.version }} calver_tag: ${{ steps.calver.outputs.version }}
sha_tag: sha-${{ github.sha }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -254,14 +251,14 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Log in to Docker Hub - name: Log in to Docker Hub
if: github.event_name == 'push' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR - name: Log in to GHCR
if: github.event_name == 'push' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
@@ -274,7 +271,7 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}
tags: | tags: |
type=sha,prefix=sha-,format=long type=sha,prefix=sha-
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
@@ -283,17 +280,16 @@ jobs:
with: with:
context: . context: .
file: ./receiptwitness/Dockerfile file: ./receiptwitness/Dockerfile
push: ${{ github.event_name == 'push' }} push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-and-push-api: build-and-push-api:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
if: github.event_name == 'push' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, test] needs: [lint, test]
outputs: outputs:
calver_tag: ${{ steps.calver.outputs.version }} calver_tag: ${{ steps.calver.outputs.version }}
sha_tag: sha-${{ github.sha }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -311,14 +307,14 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Log in to Docker Hub - name: Log in to Docker Hub
if: github.event_name == 'push' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR - name: Log in to GHCR
if: github.event_name == 'push' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
@@ -331,23 +327,23 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}
tags: | tags: |
type=sha,prefix=sha-,format=long type=sha,prefix=sha-
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push API Docker image - name: Build and push API Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: ./api context: .
file: ./api/Dockerfile file: ./api/Dockerfile
push: ${{ github.event_name == 'push' }} push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
deploy-dev: deploy-dev:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api] needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- name: Generate GitHub App token - name: Generate GitHub App token
id: app-token id: app-token
@@ -372,65 +368,29 @@ jobs:
- name: Install kustomize - name: Install kustomize
uses: imranismail/setup-kustomize@v2 uses: imranismail/setup-kustomize@v2
- name: Determine image tag for frontend
id: frontend_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update frontend image tag - name: Update frontend image tag
if: needs.build-and-push.result == 'success' if: needs.build-and-push.result == 'success'
run: | run: |
cd infra/apps/overlays/dev cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_tag.outputs.tag }} kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
- name: Determine image tag for auth
id: auth_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push-auth.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push-auth.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update auth image tag - name: Update auth image tag
if: needs.build-and-push-auth.result == 'success' if: needs.build-and-push-auth.result == 'success'
run: | run: |
cd infra/apps/overlays/dev cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/auth:${{ steps.auth_tag.outputs.tag }} kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
- name: Determine image tag for receiptwitness
id: receiptwitness_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update receiptwitness image tag - name: Update receiptwitness image tag
if: needs.build-and-push-receiptwitness.result == 'success' if: needs.build-and-push-receiptwitness.result == 'success'
run: | run: |
cd infra/apps/overlays/dev cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ steps.receiptwitness_tag.outputs.tag }} kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}
- name: Determine image tag for api
id: api_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push-api.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push-api.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update api image tag - name: Update api image tag
if: needs.build-and-push-api.result == 'success' if: needs.build-and-push-api.result == 'success'
run: | run: |
cd infra/apps/overlays/dev cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/api:${{ steps.api_tag.outputs.tag }} kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }}
- name: Commit and push to infra - name: Commit and push to infra
run: | run: |
@@ -439,13 +399,12 @@ jobs:
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com" git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/dev/kustomization.yaml git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images" git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
git pull --rebase origin main
git push origin main git push origin main
deploy-uat: deploy-uat:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api] needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/uat' || github.ref == 'refs/heads/main') if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- name: Generate GitHub App token - name: Generate GitHub App token
id: app-token id: app-token
@@ -470,65 +429,29 @@ jobs:
- name: Install kustomize - name: Install kustomize
uses: imranismail/setup-kustomize@v2 uses: imranismail/setup-kustomize@v2
- name: Determine image tag for frontend
id: frontend_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update frontend image tag - name: Update frontend image tag
if: needs.build-and-push.result == 'success' if: needs.build-and-push.result == 'success'
run: | run: |
cd infra/apps/overlays/uat cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_tag.outputs.tag }} kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
- name: Determine image tag for auth
id: auth_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push-auth.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push-auth.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update auth image tag - name: Update auth image tag
if: needs.build-and-push-auth.result == 'success' if: needs.build-and-push-auth.result == 'success'
run: | run: |
cd infra/apps/overlays/uat cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/auth:${{ steps.auth_tag.outputs.tag }} kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
- name: Determine image tag for receiptwitness
id: receiptwitness_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update receiptwitness image tag - name: Update receiptwitness image tag
if: needs.build-and-push-receiptwitness.result == 'success' if: needs.build-and-push-receiptwitness.result == 'success'
run: | run: |
cd infra/apps/overlays/uat cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ steps.receiptwitness_tag.outputs.tag }} kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}
- name: Determine image tag for api
id: api_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push-api.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push-api.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update api image tag - name: Update api image tag
if: needs.build-and-push-api.result == 'success' if: needs.build-and-push-api.result == 'success'
run: | run: |
cd infra/apps/overlays/uat cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/api:${{ steps.api_tag.outputs.tag }} kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }}
- name: Commit and push to infra - name: Commit and push to infra
run: | run: |
@@ -537,5 +460,4 @@ jobs:
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com" git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/uat/kustomization.yaml git add apps/overlays/uat/kustomization.yaml
git commit -m "ci(uat): update cartsnitch, auth, receiptwitness, and api images" git commit -m "ci(uat): update cartsnitch, auth, receiptwitness, and api images"
git pull --rebase origin main
git push origin main git push origin main
+1 -5
View File
@@ -12,14 +12,10 @@ RUN pip install --no-cache-dir --prefix=/install .
FROM python:3.12-slim AS prod FROM python:3.12-slim AS prod
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
RUN adduser --system --group --uid 1000 app RUN adduser --system --group --uid 1000 app
COPY --from=build /install /usr/local COPY --from=build /install /usr/local
COPY src/ ./src/ COPY src/ ./src/
COPY alembic.ini ./
COPY alembic/ ./alembic/
USER 1000 USER 1000
EXPOSE 8000 EXPOSE 8000
@@ -27,4 +23,4 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \ HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"] CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
+2 -12
View File
@@ -6,7 +6,7 @@ from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from alembic import context from alembic import context
from cartsnitch_api.models.base import Base # noqa: F401 — imports all models for autogenerate from cartsnitch_api.models import Base # noqa: F401 — imports all models for autogenerate
config = context.config config = context.config
if config.config_file_name is not None: if config.config_file_name is not None:
@@ -18,7 +18,7 @@ if not db_url:
"CARTSNITCH_DATABASE_URL_SYNC must be set. " "CARTSNITCH_DATABASE_URL_SYNC must be set. "
"Example: postgresql://user:pass@localhost:5432/cartsnitch" "Example: postgresql://user:pass@localhost:5432/cartsnitch"
) )
config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%")) config.set_main_option("sqlalchemy.url", db_url)
target_metadata = Base.metadata target_metadata = Base.metadata
@@ -47,16 +47,6 @@ def run_migrations_online() -> None:
context.configure(connection=connection, target_metadata=target_metadata) context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
# Create any tables defined in models but not yet created by migrations.
# This bootstraps fresh databases that have no legacy schema.
# checkfirst=True ensures this is a no-op on existing databases.
try:
Base.metadata.create_all(bind=connection, checkfirst=True)
except Exception as exc:
import logging
logging.getLogger("alembic.env").warning(
"create_all failed (non-fatal, migrations should handle table creation): %s", exc
)
if context.is_offline_mode(): if context.is_offline_mode():
@@ -33,21 +33,6 @@ def _is_fernet_token(value: str) -> bool:
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Fresh DB — table created by Base.metadata.create_all with correct TEXT type
if not inspector.has_table("user_store_accounts"):
return
# Already migrated? Skip if session_data is already TEXT (not JSON)
cols = {c["name"]: c for c in inspector.get_columns("user_store_accounts")}
if "session_data" not in cols:
return
col_type = str(cols["session_data"]["type"]).lower()
if "text" in col_type and "json" not in col_type:
return # already TEXT — nothing to do
# Change column type from JSON to TEXT to hold Fernet ciphertext # Change column type from JSON to TEXT to hold Fernet ciphertext
op.alter_column( op.alter_column(
"user_store_accounts", "user_store_accounts",
@@ -58,6 +43,7 @@ def upgrade() -> None:
postgresql_using="session_data::text", postgresql_using="session_data::text",
) )
conn = op.get_bind()
rows = conn.execute( rows = conn.execute(
text("SELECT id, session_data FROM user_store_accounts WHERE session_data IS NOT NULL") text("SELECT id, session_data FROM user_store_accounts WHERE session_data IS NOT NULL")
).fetchall() ).fetchall()
+65 -78
View File
@@ -21,94 +21,81 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# --- Extend users table for Better-Auth compatibility --- # --- Extend users table for Better-Auth compatibility ---
# Guard: on a fresh DB Base.metadata.create_all (called in env.py after migrations) op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
# creates the users table with all columns, so migration 002 must not re-run add_column. op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
if inspector.has_table("users"):
existing_user_cols = [c["name"] for c in inspector.get_columns("users")]
if "email_verified" not in existing_user_cols:
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
if "image" not in existing_user_cols:
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
# --- Create sessions table --- # --- Create sessions table ---
if not inspector.has_table("sessions"): op.create_table(
op.create_table( "sessions",
"sessions", sa.Column("id", sa.Text(), nullable=False),
sa.Column("id", sa.Text(), nullable=False), sa.Column("token", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False), sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False), sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), sa.Column("ip_address", sa.Text(), nullable=True),
sa.Column("ip_address", sa.Text(), nullable=True), sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), sa.PrimaryKeyConstraint("id"),
sa.PrimaryKeyConstraint("id"), )
) op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True) op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
# --- Create accounts table --- # --- Create accounts table ---
if not inspector.has_table("accounts"): op.create_table(
op.create_table( "accounts",
"accounts", sa.Column("id", sa.Text(), nullable=False),
sa.Column("id", sa.Text(), nullable=False), sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False), sa.Column("account_id", sa.Text(), nullable=False),
sa.Column("account_id", sa.Text(), nullable=False), sa.Column("provider_id", sa.Text(), nullable=False),
sa.Column("provider_id", sa.Text(), nullable=False), sa.Column("access_token", sa.Text(), nullable=True),
sa.Column("access_token", sa.Text(), nullable=True), sa.Column("refresh_token", sa.Text(), nullable=True),
sa.Column("refresh_token", sa.Text(), nullable=True), sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True), sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True), sa.Column("scope", sa.Text(), nullable=True),
sa.Column("scope", sa.Text(), nullable=True), sa.Column("id_token", sa.Text(), nullable=True),
sa.Column("id_token", sa.Text(), nullable=True), sa.Column("password", sa.Text(), nullable=True),
sa.Column("password", sa.Text(), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), sa.PrimaryKeyConstraint("id"),
sa.PrimaryKeyConstraint("id"), )
) op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
# --- Create verifications table --- # --- Create verifications table ---
if not inspector.has_table("verifications"): op.create_table(
op.create_table( "verifications",
"verifications", sa.Column("id", sa.Text(), nullable=False),
sa.Column("id", sa.Text(), nullable=False), sa.Column("identifier", sa.Text(), nullable=False),
sa.Column("identifier", sa.Text(), nullable=False), sa.Column("value", sa.Text(), nullable=False),
sa.Column("value", sa.Text(), nullable=False), sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), sa.PrimaryKeyConstraint("id"),
sa.PrimaryKeyConstraint("id"), )
)
# --- Migrate existing password hashes to accounts table --- # --- Migrate existing password hashes to accounts table ---
# Only run on existing (non-fresh) DBs that already have users table with data # For each user with a hashed_password, create a 'credential' account row
if inspector.has_table("users"): conn = op.get_bind()
users = conn.execute( users = conn.execute(
text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL") text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL")
).fetchall() ).fetchall()
for user_id, hashed_password in users: for user_id, hashed_password in users:
user_id_str = str(user_id) user_id_str = str(user_id)
conn.execute( conn.execute(
text( text(
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) " "INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())" "VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
), ),
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password}, {"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
) )
def downgrade() -> None: def downgrade() -> None:
op.execute(text("DROP INDEX IF EXISTS ix_accounts_user_id")) op.drop_table("verifications")
op.execute(text("DROP TABLE IF EXISTS verifications")) op.drop_table("accounts")
op.execute(text("DROP TABLE IF EXISTS accounts")) op.drop_index("ix_sessions_user_id", table_name="sessions")
op.execute(text("DROP INDEX IF EXISTS ix_sessions_user_id")) op.drop_index("ix_sessions_token", table_name="sessions")
op.execute(text("DROP INDEX IF EXISTS ix_sessions_token")) op.drop_table("sessions")
op.execute(text("DROP TABLE IF EXISTS sessions")) op.drop_column("users", "image")
op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS image")) op.drop_column("users", "email_verified")
op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS email_verified"))
@@ -19,25 +19,8 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind() op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True)
inspector = sa.inspect(conn)
# Fresh DB — nothing to alter
if not inspector.has_table("users"):
return
cols = {c["name"]: c for c in inspector.get_columns("users")}
if "hashed_password" in cols and not cols["hashed_password"]["nullable"]:
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True)
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind() op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False)
inspector = sa.inspect(conn)
if not inspector.has_table("users"):
return
cols = {c["name"]: c for c in inspector.get_columns("users")}
if "hashed_password" in cols and cols["hashed_password"]["nullable"]:
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False)
+1 -15
View File
@@ -25,21 +25,7 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind() # Step 1: Drop existing FK constraints
inspector = sa.inspect(conn)
# Fresh DB — no tables yet, nothing to convert
if not inspector.has_table("users"):
return
# Check if already TEXT (Base.metadata.create_all uses TEXT for fresh DB)
users_cols = {c["name"]: c for c in inspector.get_columns("users")}
if "id" in users_cols:
id_type = str(users_cols["id"]["type"]).lower()
if "text" in id_type and "uuid" not in id_type:
return # already TEXT — nothing to do
# Step 1: Drop existing FK constraints (ignore if they don't exist)
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey")) op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey")) op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
@@ -18,15 +18,6 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Guard: on a fresh DB Base.metadata.create_all creates users table with the column already present
if not inspector.has_table("users"):
return
existing_cols = [c["name"] for c in inspector.get_columns("users")]
if "email_inbound_token" in existing_cols:
return
# Add column nullable first so existing rows can be backfilled # Add column nullable first so existing rows can be backfilled
op.add_column( op.add_column(
"users", "users",
@@ -34,10 +25,11 @@ def upgrade() -> None:
) )
# Backfill existing users with unique tokens # Backfill existing users with unique tokens
result = conn.execute(sa.text("SELECT id FROM users WHERE email_inbound_token IS NULL")) connection = op.get_bind()
result = connection.execute(sa.text("SELECT id FROM users WHERE email_inbound_token IS NULL"))
for (user_id,) in result: for (user_id,) in result:
token = secrets.token_urlsafe(16) token = secrets.token_urlsafe(16)
conn.execute( connection.execute(
sa.text("UPDATE users SET email_inbound_token = :token WHERE id = :id"), sa.text("UPDATE users SET email_inbound_token = :token WHERE id = :id"),
{"token": token, "id": user_id}, {"token": token, "id": user_id},
) )
@@ -1,42 +0,0 @@
"""Add server_default to users.email_inbound_token.
Revision ID: 006_email_inbound_token_server_default
Revises: 005_add_email_inbound_token
Create Date: 2026-04-04
"""
import sqlalchemy as sa
from alembic import op
revision = "006_email_inbound_token_server_default"
down_revision = "005_add_email_inbound_token"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Guard: on a fresh DB Base.metadata.create_all already sets the server_default
if not inspector.has_table("users"):
return
cols = {c["name"]: c for c in inspector.get_columns("users")}
if "email_inbound_token" not in cols:
return
if cols["email_inbound_token"].get("default") is not None:
return
op.alter_column(
"users",
"email_inbound_token",
server_default=sa.text(
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
),
)
def downgrade() -> None:
op.alter_column(
"users",
"email_inbound_token",
server_default=None,
)
@@ -1,47 +0,0 @@
"""Bootstrap users table on fresh databases.
On fresh databases, migrations 001-006 skip users-table operations because
the table does not exist yet. Base.metadata.create_all() in env.py is meant
to handle this, but if it fails (import errors, etc.) the table is never
created. This migration creates the users table with raw SQL as a safety net.
Revision ID: 007_bootstrap_users_table
Revises: 006_email_inbound_token_server_default
Create Date: 2026-04-04
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "007_bootstrap_users_table"
down_revision = "006_email_inbound_token_server_default"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if inspector.has_table("users"):
return # Table already exists (non-fresh DB or create_all already ran)
conn.execute(text("""
CREATE TABLE users (
id TEXT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
hashed_password VARCHAR(255),
display_name VARCHAR(100),
email_verified BOOLEAN NOT NULL DEFAULT false,
image TEXT,
email_inbound_token VARCHAR(22) NOT NULL UNIQUE
DEFAULT replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""))
def downgrade() -> None:
op.execute(text("DROP TABLE IF EXISTS users"))
+4 -10
View File
@@ -4,7 +4,6 @@ Validates Better-Auth session tokens from cookies or Bearer header.
Sessions are verified by querying the shared sessions table directly. Sessions are verified by querying the shared sessions table directly.
""" """
import hashlib
from datetime import UTC, datetime from datetime import UTC, datetime
from fastapi import Cookie, Depends, Header, HTTPException, Request, status from fastapi import Cookie, Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -20,21 +19,16 @@ bearer_scheme = HTTPBearer(auto_error=False)
# Better-Auth session cookie name # Better-Auth session cookie name
SESSION_COOKIE_NAME = "better-auth.session_token" SESSION_COOKIE_NAME = "better-auth.session_token"
# Secure prefix used by better-auth on HTTPS deployments
SECURE_SESSION_COOKIE_NAME = "__Secure-better-auth.session_token"
async def _validate_session_token(token: str, db: AsyncSession) -> str: async def _validate_session_token(token: str, db: AsyncSession) -> str:
"""Validate a Better-Auth session token against the sessions table. """Validate a Better-Auth session token against the sessions table.
Better-Auth v1.2+ stores SHA-256(raw_token) in the DB. Returns the user_id (as str) if the session is valid and not expired.
The cookie/Bearer header carries the raw token, so we hash before lookup.
""" """
token_hash = hashlib.sha256(token.encode()).hexdigest()
result = await db.execute( result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"), text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
{"token": token_hash}, {"token": token},
) )
row = result.first() row = result.first()
@@ -71,8 +65,8 @@ async def get_current_user(
""" """
token: str | None = None token: str | None = None
# 1. Check session cookie — prefer __Secure- variant (HTTPS) over plain (HTTP dev) # 1. Check session cookie
cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get(SESSION_COOKIE_NAME) cookie_token = request.cookies.get(SESSION_COOKIE_NAME)
if cookie_token: if cookie_token:
token = cookie_token token = cookie_token
+25
View File
@@ -22,6 +22,11 @@ from cartsnitch_api.services.auth import AuthService
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
class EmailInAddressResponse(BaseModel):
email_address: str
instructions: str
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
async def get_me( async def get_me(
user_id: str = Depends(get_current_user), user_id: str = Depends(get_current_user),
@@ -65,3 +70,23 @@ async def delete_me(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found" status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) from None ) from None
@router.get("/me/email-in-address", response_model=EmailInAddressResponse)
async def get_email_in_address(
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User.email_inbound_token).where(User.id == user_id))
token = result.scalar_one_or_none()
if not token:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Email inbound token not found"
) from None
return EmailInAddressResponse(
email_address=f"receipts+{token}@receipts.cartsnitch.com",
instructions=(
"Forward your digital receipt emails to this address. "
"We currently support Meijer, Kroger, and Target receipt emails."
),
)
+2 -10
View File
@@ -4,8 +4,7 @@ import secrets
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import sqlalchemy as sa from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import AccountStatus from cartsnitch_api.constants import AccountStatus
@@ -24,20 +23,13 @@ class User(TimestampMixin, Base):
id: Mapped[str] = mapped_column(Text, primary_key=True) id: Mapped[str] = mapped_column(Text, primary_key=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(100)) display_name: Mapped[str | None] = mapped_column(String(100))
email_verified: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="false"
)
image: Mapped[str | None] = mapped_column(Text, nullable=True)
email_inbound_token: Mapped[str] = mapped_column( email_inbound_token: Mapped[str] = mapped_column(
String(22), String(22),
nullable=False, nullable=False,
unique=True, unique=True,
default=lambda: secrets.token_urlsafe(16), default=lambda: secrets.token_urlsafe(16),
server_default=sa.text(
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
),
) )
# Relationships # Relationships
+1 -7
View File
@@ -19,13 +19,7 @@ async def get_email_in_address(
svc = AuthService(db) svc = AuthService(db)
try: try:
email_address = await svc.get_email_in_address(user_id) email_address = await svc.get_email_in_address(user_id)
return EmailInAddressResponse( return EmailInAddressResponse(email_address=email_address)
email_address=email_address,
instructions=(
"Forward your digital receipt emails to this address. "
"We currently support Meijer, Kroger, and Target receipt emails."
),
)
except LookupError: except LookupError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found" status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
-1
View File
@@ -24,7 +24,6 @@ class UserResponse(BaseModel):
class EmailInAddressResponse(BaseModel): class EmailInAddressResponse(BaseModel):
email_address: str email_address: str
instructions: str
# ---------- Stores ---------- # ---------- Stores ----------
+1 -1
View File
@@ -76,4 +76,4 @@ class AuthService:
if not user: if not user:
raise LookupError("User not found") raise LookupError("User not found")
return f"receipts+{user.email_inbound_token}@receipts.cartsnitch.com" return f"{user.email_inbound_token}@email.cartsnitch.com"
+2 -5
View File
@@ -4,7 +4,6 @@ Session-based auth: tests create users and sessions directly in the DB,
matching the Better-Auth session validation flow. matching the Better-Auth session validation flow.
""" """
import hashlib
import secrets import secrets
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -137,14 +136,12 @@ async def client(db_engine):
async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_overrides) -> tuple[dict, str]: async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_overrides) -> tuple[dict, str]:
"""Create a test user and a valid session directly in the DB. """Create a test user and a valid session directly in the DB.
Returns (user_dict, session_token). Better-Auth v1.2+ stores SHA-256 Returns (user_dict, session_token).
hashed tokens in the DB, so the token is hashed before insertion.
""" """
user_id = str(uuid.uuid4()) user_id = str(uuid.uuid4())
email = user_overrides.get("email", "test@example.com") email = user_overrides.get("email", "test@example.com")
display_name = user_overrides.get("display_name", "Test User") display_name = user_overrides.get("display_name", "Test User")
session_token = secrets.token_urlsafe(32) session_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(session_token.encode()).hexdigest()
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
now = datetime.now(UTC).isoformat() now = datetime.now(UTC).isoformat()
expires = (datetime.now(UTC) + timedelta(days=7)).isoformat() expires = (datetime.now(UTC) + timedelta(days=7)).isoformat()
@@ -172,7 +169,7 @@ async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_o
), ),
{ {
"id": session_id, "id": session_id,
"token": token_hash, "token": session_token,
"user_id": user_id, "user_id": user_id,
"expires_at": expires, "expires_at": expires,
"created_at": now, "created_at": now,
+1 -3
View File
@@ -74,7 +74,6 @@ async def test_delete_me(client, auth_headers):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_expired_session_rejected(client, db_engine): async def test_expired_session_rejected(client, db_engine):
"""Expired sessions must be rejected.""" """Expired sessions must be rejected."""
import hashlib
import secrets import secrets
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -83,7 +82,6 @@ async def test_expired_session_rejected(client, db_engine):
user_id = str(uuid.uuid4()) user_id = str(uuid.uuid4())
session_token = secrets.token_urlsafe(32) session_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(session_token.encode()).hexdigest()
now = datetime.now(UTC).isoformat() now = datetime.now(UTC).isoformat()
expired = (datetime.now(UTC) - timedelta(hours=1)).isoformat() expired = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
@@ -110,7 +108,7 @@ async def test_expired_session_rejected(client, db_engine):
), ),
{ {
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"token": token_hash, "token": session_token,
"uid": user_id, "uid": user_id,
"ea": expired, "ea": expired,
"ca": now, "ca": now,
+5 -5
View File
@@ -1,4 +1,4 @@
"""Tests for GET /api/v1/me/email-in-address endpoint.""" """Tests for GET /auth/me/email-in-address endpoint."""
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
@@ -8,7 +8,7 @@ from httpx import AsyncClient
async def test_get_email_in_address_authenticated(client: AsyncClient, auth_headers: dict): async def test_get_email_in_address_authenticated(client: AsyncClient, auth_headers: dict):
"""Authenticated user gets their email-in address.""" """Authenticated user gets their email-in address."""
response = await client.get( response = await client.get(
"/api/v1/me/email-in-address", "/auth/me/email-in-address",
headers=auth_headers, headers=auth_headers,
) )
@@ -27,7 +27,7 @@ async def test_get_email_in_address_authenticated(client: AsyncClient, auth_head
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_email_in_address_unauthenticated(client: AsyncClient): async def test_get_email_in_address_unauthenticated(client: AsyncClient):
"""Unauthenticated request returns 401.""" """Unauthenticated request returns 401."""
response = await client.get("/api/v1/me/email-in-address") response = await client.get("/auth/me/email-in-address")
assert response.status_code == 401 assert response.status_code == 401
@@ -35,7 +35,7 @@ async def test_get_email_in_address_unauthenticated(client: AsyncClient):
async def test_get_email_in_address_invalid_token(client: AsyncClient): async def test_get_email_in_address_invalid_token(client: AsyncClient):
"""Invalid JWT token returns 401.""" """Invalid JWT token returns 401."""
response = await client.get( response = await client.get(
"/api/v1/me/email-in-address", "/auth/me/email-in-address",
headers={"Authorization": "Bearer invalid-token-xyz"}, headers={"Authorization": "Bearer invalid-token-xyz"},
) )
assert response.status_code == 401 assert response.status_code == 401
@@ -45,7 +45,7 @@ async def test_get_email_in_address_invalid_token(client: AsyncClient):
async def test_email_address_format(client: AsyncClient, auth_headers: dict): async def test_email_address_format(client: AsyncClient, auth_headers: dict):
"""Email address format is receipts+{22-char-urlsafe-token}@receipts.cartsnitch.com.""" """Email address format is receipts+{22-char-urlsafe-token}@receipts.cartsnitch.com."""
response = await client.get( response = await client.get(
"/api/v1/me/email-in-address", "/auth/me/email-in-address",
headers=auth_headers, headers=auth_headers,
) )
-1
View File
@@ -95,6 +95,5 @@ export const auth = betterAuth({
"https://cartsnitch.com", "https://cartsnitch.com",
"https://cartsnitch.farh.net", "https://cartsnitch.farh.net",
"https://cartsnitch.dev.farh.net", "https://cartsnitch.dev.farh.net",
"https://cartsnitch.uat.farh.net",
], ],
}); });
+1 -1
View File
@@ -14,7 +14,7 @@ if config.config_file_name is not None:
db_url = os.environ.get("CARTSNITCH_DATABASE_URL_SYNC") db_url = os.environ.get("CARTSNITCH_DATABASE_URL_SYNC")
if db_url: if db_url:
config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%")) config.set_main_option("sqlalchemy.url", db_url)
target_metadata = Base.metadata target_metadata = Base.metadata
@@ -1,37 +0,0 @@
"""Add email_inbound_token to users.
Revision ID: 001_add_email_inbound_token
Revises:
Create Date: 2026-04-02
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "001_add_email_inbound_token"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("users", sa.Column("email_inbound_token", sa.String(22), nullable=True))
op.create_unique_constraint("uq_users_email_inbound_token", "users", ["email_inbound_token"])
# Backfill existing users with generated tokens (PostgreSQL)
op.execute(
"UPDATE users SET email_inbound_token = "
"substring(replace(gen_random_uuid()::text, '-', ''), 1, 22) "
"WHERE email_inbound_token IS NULL"
)
# Alter to non-nullable
op.alter_column("users", "email_inbound_token", nullable=False)
def downgrade() -> None:
op.drop_constraint("uq_users_email_inbound_token", "users", type_="unique")
op.drop_column("users", "email_inbound_token")
+1 -11
View File
@@ -1,11 +1,10 @@
"""User and UserStoreAccount models.""" """User and UserStoreAccount models."""
import secrets
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, text from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_common.constants import AccountStatus from cartsnitch_common.constants import AccountStatus
@@ -22,15 +21,6 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users" __tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
email_inbound_token: Mapped[str] = mapped_column(
String(22),
nullable=False,
unique=True,
default=lambda: secrets.token_urlsafe(16),
server_default=text(
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
),
)
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
display_name: Mapped[str | None] = mapped_column(String(100)) display_name: Mapped[str | None] = mapped_column(String(100))
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
@@ -20,7 +20,6 @@ class UserRead(BaseModel):
id: uuid.UUID id: uuid.UUID
email: str email: str
display_name: str | None display_name: str | None
email_inbound_token: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
-34
View File
@@ -147,40 +147,6 @@ class TestStoreLocationModel:
assert loc.lat == pytest.approx(42.2808) assert loc.lat == pytest.approx(42.2808)
class TestUserModel:
def test_email_inbound_token_auto_populated(self, session):
user = User(
id=uuid.uuid4(),
email="token_test@example.com",
hashed_password="hashed",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(user)
session.commit()
assert user.email_inbound_token is not None
assert len(user.email_inbound_token) == 22
def test_email_inbound_token_unique(self, session):
user1 = User(
id=uuid.uuid4(),
email="user1@example.com",
hashed_password="hashed",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
user2 = User(
id=uuid.uuid4(),
email="user2@example.com",
hashed_password="hashed",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add_all([user1, user2])
session.commit()
assert user1.email_inbound_token != user2.email_inbound_token
class TestUserStoreAccountModel: class TestUserStoreAccountModel:
def test_account_status_enum(self, session): def test_account_status_enum(self, session):
user = User( user = User(