forked from cartsnitch/cartsnitch
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c116d0bc8a |
@@ -334,7 +334,7 @@ jobs:
|
|||||||
- 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' && github.ref == 'refs/heads/main' }}
|
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -399,7 +399,6 @@ 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:
|
||||||
@@ -461,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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class UserResponse(BaseModel):
|
|||||||
|
|
||||||
class EmailInAddressResponse(BaseModel):
|
class EmailInAddressResponse(BaseModel):
|
||||||
email_address: str
|
email_address: str
|
||||||
instructions: str
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- Stores ----------
|
# ---------- Stores ----------
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,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,6 +1,5 @@
|
|||||||
"""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
|
||||||
@@ -22,9 +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)
|
|
||||||
)
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user