fix(auth): revert to Better-Auth session-cookie auth, preserve email-in feature

- Revert auth/dependencies.py, auth/routes.py, services/auth.py, schemas.py
  to Better-Auth session-cookie auth (removed JWT register/login/refresh)
- Preserve GET /auth/me/email-in-address endpoint
- Fix UUIDString TypeDecorator: process_result_value returns uuid.UUID
  (not str) so SQLAlchemy 2.0 sentinel tracking matches UUID-to-UUID
- Fix seed_data fixture: look up real user_id from session token via
  sessions table; purchases now reference actual user FK
- Update purchase_data fixture to use session-cookie auth
- Update test_auth_endpoints, test_auth_validation to cookie-based tests
- Remove TestRegistrationErrors and TestLoginErrors (no longer applicable)
- Update test_openapi.py expected routes and count
- Update test_error_handler.py to use PATCH /auth/me validation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CartSnitch Engineer Bot
2026-04-03 09:15:00 +00:00
parent b52fae5894
commit 18ff5795ac
13 changed files with 543 additions and 591 deletions
+45 -11
View File
@@ -7,12 +7,13 @@ exercise cross-resource queries against real data.
from datetime import date, timedelta
from decimal import Decimal
from uuid import UUID
import uuid
from sqlalchemy import text
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from cartsnitch_api.auth.jwt import decode_token
from cartsnitch_api.models import (
Coupon,
NormalizedProduct,
@@ -33,17 +34,20 @@ ANCHOR_DATE = date.today()
@pytest.fixture
async def seed_data(db_engine, auth_headers):
"""Seed a full dataset and return identifiers for test assertions."""
import uuid
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
# -- Stores --
meijer = Store(name="Meijer", slug="meijer")
kroger = Store(name="Kroger", slug="kroger")
target = Store(name="Target", slug="target")
meijer = Store(name="Meijer", slug="meijer", id=uuid.uuid4())
kroger = Store(name="Kroger", slug="kroger", id=uuid.uuid4())
target = Store(name="Target", slug="target", id=uuid.uuid4())
session.add_all([meijer, kroger, target])
await session.flush()
# -- Products --
cheerios = NormalizedProduct(
id=uuid.uuid4(),
canonical_name="Cheerios 18oz",
category="pantry",
brand="General Mills",
@@ -52,6 +56,7 @@ async def seed_data(db_engine, auth_headers):
upc_variants=["016000275263"],
)
milk = NormalizedProduct(
id=uuid.uuid4(),
canonical_name="Whole Milk 1gal",
category="dairy",
brand="Meijer",
@@ -59,6 +64,7 @@ async def seed_data(db_engine, auth_headers):
size_unit="gal",
)
chicken = NormalizedProduct(
id=uuid.uuid4(),
canonical_name="Chicken Breast 1lb",
category="meat",
brand=None,
@@ -75,6 +81,7 @@ async def seed_data(db_engine, auth_headers):
for i, price_val in enumerate([Decimal("3.99"), Decimal("4.29"), Decimal("4.79")]):
prices.append(
PriceHistory(
id=uuid.uuid4(),
normalized_product_id=cheerios.id,
store_id=meijer.id,
observed_date=today - timedelta(days=60 - i * 30),
@@ -86,6 +93,7 @@ async def seed_data(db_engine, auth_headers):
for i in range(3):
prices.append(
PriceHistory(
id=uuid.uuid4(),
normalized_product_id=cheerios.id,
store_id=kroger.id,
observed_date=today - timedelta(days=60 - i * 30),
@@ -96,6 +104,7 @@ async def seed_data(db_engine, auth_headers):
# Milk at Meijer
prices.append(
PriceHistory(
id=uuid.uuid4(),
normalized_product_id=milk.id,
store_id=meijer.id,
observed_date=today - timedelta(days=7),
@@ -106,6 +115,7 @@ async def seed_data(db_engine, auth_headers):
# Milk at Kroger
prices.append(
PriceHistory(
id=uuid.uuid4(),
normalized_product_id=milk.id,
store_id=kroger.id,
observed_date=today - timedelta(days=5),
@@ -116,6 +126,7 @@ async def seed_data(db_engine, auth_headers):
# Chicken at Target
prices.append(
PriceHistory(
id=uuid.uuid4(),
normalized_product_id=chicken.id,
store_id=target.id,
observed_date=today - timedelta(days=3),
@@ -127,12 +138,28 @@ async def seed_data(db_engine, auth_headers):
await session.flush()
# -- Purchases (need the user_id from the registered test user) --
token = auth_headers["Authorization"].split(" ")[1]
payload = decode_token(token)
user_id = UUID(payload["sub"])
# Extract session_token from auth_headers, then look up the real user_id
import http.cookies
cookie_header = auth_headers.get("Cookie", "")
cookies = http.cookies.SimpleCookie()
cookies.load(cookie_header)
session_token = cookies.get("better-auth.session_token").value if "better-auth.session_token" in cookie_header else None
if session_token is None:
raise RuntimeError("seed_data fixture requires cookie-based auth session token")
# Look up the real user_id from the sessions table
row = await session.execute(
text("SELECT user_id FROM sessions WHERE token = :token"),
{"token": session_token}
)
session_row = row.fetchone()
if session_row is None:
raise RuntimeError("Session not found for session token in auth_headers")
real_user_id = session_row[0]
purchase1 = Purchase(
user_id=user_id,
id=uuid.uuid4(),
user_id=uuid.UUID(real_user_id),
store_id=meijer.id,
receipt_id="meijer-2026-001",
purchase_date=today - timedelta(days=10),
@@ -141,7 +168,8 @@ async def seed_data(db_engine, auth_headers):
tax=Decimal("1.95"),
)
purchase2 = Purchase(
user_id=user_id,
id=uuid.uuid4(),
user_id=uuid.UUID(real_user_id),
store_id=kroger.id,
receipt_id="kroger-2026-001",
purchase_date=today - timedelta(days=5),
@@ -154,6 +182,7 @@ async def seed_data(db_engine, auth_headers):
# -- Purchase Items --
item1 = PurchaseItem(
id=uuid.uuid4(),
purchase_id=purchase1.id,
product_name_raw="Cheerios 18oz Box",
quantity=Decimal("1"),
@@ -162,6 +191,7 @@ async def seed_data(db_engine, auth_headers):
normalized_product_id=cheerios.id,
)
item2 = PurchaseItem(
id=uuid.uuid4(),
purchase_id=purchase1.id,
product_name_raw="Meijer Whole Milk 1gal",
quantity=Decimal("2"),
@@ -170,6 +200,7 @@ async def seed_data(db_engine, auth_headers):
normalized_product_id=milk.id,
)
item3 = PurchaseItem(
id=uuid.uuid4(),
purchase_id=purchase2.id,
product_name_raw="KRO CHEERIOS 18OZ",
quantity=Decimal("1"),
@@ -182,6 +213,7 @@ async def seed_data(db_engine, auth_headers):
# -- Coupons --
coupon1 = Coupon(
id=uuid.uuid4(),
store_id=meijer.id,
normalized_product_id=cheerios.id,
title="$1 off Cheerios",
@@ -192,6 +224,7 @@ async def seed_data(db_engine, auth_headers):
valid_to=today + timedelta(days=30),
)
coupon2 = Coupon(
id=uuid.uuid4(),
store_id=kroger.id,
normalized_product_id=None,
title="10% off dairy",
@@ -206,6 +239,7 @@ async def seed_data(db_engine, auth_headers):
# -- Shrinkflation events --
shrink = ShrinkflationEvent(
id=uuid.uuid4(),
normalized_product_id=cheerios.id,
detected_date=today - timedelta(days=15),
old_size="20",
@@ -240,7 +274,7 @@ async def seed_data(db_engine, auth_headers):
return {
"headers": auth_headers,
"user_id": user_id,
"user_id": real_user_id,
"stores": {"meijer": meijer, "kroger": kroger, "target": target},
"products": {"cheerios": cheerios, "milk": milk, "chicken": chicken},
"purchases": {"meijer_trip": purchase1, "kroger_trip": purchase2},
+94 -145
View File
@@ -1,132 +1,103 @@
"""E2E: Auth and token validation flows."""
"""E2E: Auth and session validation flows.
import asyncio
Registration and login are handled by the Better-Auth service.
These tests validate session token handling at the API gateway level.
"""
import pytest
@pytest.mark.asyncio
class TestAuthRegistrationLogin:
"""Full registration → login → token refresh → profile flow."""
async def test_full_auth_lifecycle(self, client, db_engine):
"""Register → login → get profile → refresh → get profile again."""
# Register
reg = await client.post(
"/auth/register",
json={
"email": "lifecycle@example.com",
"password": "securepass123",
"display_name": "Lifecycle User",
},
)
assert reg.status_code == 201
tokens = reg.json()
assert "access_token" in tokens
assert "refresh_token" in tokens
assert tokens["token_type"] == "bearer"
assert tokens["expires_in"] > 0
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
# Get profile with access token
me = await client.get("/auth/me", headers=headers)
assert me.status_code == 200
assert me.json()["email"] == "lifecycle@example.com"
assert me.json()["display_name"] == "Lifecycle User"
# Sleep 1s so the new token has a different exp than the registration token
await asyncio.sleep(1)
# Login with same credentials
login = await client.post(
"/auth/login",
json={"email": "lifecycle@example.com", "password": "securepass123"},
)
assert login.status_code == 200
login_tokens = login.json()
assert login_tokens["access_token"] != tokens["access_token"]
# Refresh token
refresh = await client.post(
"/auth/refresh",
json={"refresh_token": tokens["refresh_token"]},
)
assert refresh.status_code == 200
new_tokens = refresh.json()
assert new_tokens["access_token"] != tokens["access_token"]
# Use refreshed token to access profile
new_headers = {"Authorization": f"Bearer {new_tokens['access_token']}"}
me2 = await client.get("/auth/me", headers=new_headers)
assert me2.status_code == 200
assert me2.json()["email"] == "lifecycle@example.com"
from tests.conftest import _create_test_user_and_session
@pytest.mark.asyncio
class TestTokenValidation:
"""Token edge cases and error responses."""
class TestSessionValidation:
"""Session edge cases and error responses."""
async def test_expired_token_rejected(self, client, db_engine):
"""Manually craft an expired token and verify rejection."""
import uuid
from datetime import UTC, datetime, timedelta
from jose import jwt
from cartsnitch_api.config import settings
payload = {
"sub": str(uuid.uuid4()),
"exp": datetime.now(UTC) - timedelta(minutes=5),
"type": "access",
}
token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
resp = await client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
async def test_invalid_session_token_rejected(self, client, db_engine):
resp = await client.get(
"/auth/me",
headers={"Cookie": "better-auth.session_token=not-a-real-token"},
)
assert resp.status_code == 401
async def test_invalid_token_rejected(self, client, db_engine):
resp = await client.get("/auth/me", headers={"Authorization": "Bearer not-a-real-token"})
assert resp.status_code == 401
async def test_missing_auth_header(self, client, db_engine):
async def test_missing_auth(self, client, db_engine):
resp = await client.get("/auth/me")
assert resp.status_code in (401, 403)
async def test_refresh_token_cannot_access_endpoints(self, client, db_engine):
"""A refresh token should not work as an access token."""
reg = await client.post(
"/auth/register",
json={
"email": "refresh-test@example.com",
"password": "securepass123",
"display_name": "Refresh Test",
},
async def test_bearer_token_also_works(self, client, db_engine):
"""Session tokens passed as Bearer tokens should also be accepted."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="bearer@e2e.com", display_name="Bearer E2E"
)
refresh_token = reg.json()["refresh_token"]
resp = await client.get("/auth/me", headers={"Authorization": f"Bearer {refresh_token}"})
assert resp.status_code == 401
async def test_deleted_user_token_invalid(self, client, db_engine):
"""After deleting an account, tokens should no longer work."""
reg = await client.post(
"/auth/register",
json={
"email": "delete-me@example.com",
"password": "securepass123",
"display_name": "Delete Me",
},
resp = await client.get(
"/auth/me",
headers={"Authorization": f"Bearer {session_token}"},
)
tokens = reg.json()
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
assert resp.status_code == 200
assert resp.json()["email"] == "bearer@e2e.com"
async def test_deleted_user_session_returns_not_found(self, client, db_engine):
"""After deleting a user, their session should result in 404 for profile."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="delete-me@e2e.com", display_name="Delete Me"
)
headers = {"Cookie": f"better-auth.session_token={session_token}"}
# Delete account
delete_resp = await client.delete("/auth/me", headers=headers)
assert delete_resp.status_code == 204
# Profile should fail
me = await client.get("/auth/me", headers=headers)
assert me.status_code in (401, 404)
assert me.status_code == 404
async def test_expired_session_rejected(self, client, db_engine):
"""Expired sessions must be rejected."""
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import text
user_id = str(uuid.uuid4())
session_token = secrets.token_urlsafe(32)
now = datetime.now(UTC).isoformat()
expired = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
async with db_engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_inbound_token, created_at, updated_at) "
"VALUES (:id, :email, :hp, :dn, :eit, :ca, :ua)"
),
{
"id": user_id,
"email": "expired@e2e.com",
"hp": "unused",
"dn": "Expired User",
"eit": secrets.token_urlsafe(16),
"ca": now,
"ua": now,
},
)
await conn.execute(
text(
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
"VALUES (:id, :token, :uid, :ea, :ca, :ua)"
),
{
"id": str(uuid.uuid4()),
"token": session_token,
"uid": user_id,
"ea": expired,
"ca": now,
"ua": now,
},
)
resp = await client.get(
"/auth/me",
headers={"Cookie": f"better-auth.session_token={session_token}"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
@@ -154,60 +125,38 @@ class TestAuthProtectedEndpoints:
class TestCrossUserDataIsolation:
"""Verify that users cannot access other users' data."""
async def test_user_b_cannot_access_user_a_purchases(self, client, seed_data):
"""Register a second user and verify they cannot see User A's purchases."""
# User A's purchase (from seed_data)
async def test_user_b_cannot_access_user_a_purchases(self, client, db_engine, seed_data):
"""A second user cannot see User A's purchases."""
purchase_id = str(seed_data["purchases"]["meijer_trip"].id)
# Register User B
reg = await client.post(
"/auth/register",
json={
"email": "userb@example.com",
"password": "securepass123",
"display_name": "User B",
},
_, session_token = await _create_test_user_and_session(
client, db_engine, email="userb@e2e.com", display_name="User B"
)
assert reg.status_code == 201
user_b_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
user_b_headers = {"Cookie": f"better-auth.session_token={session_token}"}
# User B tries to access User A's specific purchase
resp = await client.get(f"/purchases/{purchase_id}", headers=user_b_headers)
assert resp.status_code in (403, 404), (
"User B should not be able to access User A's purchase"
)
async def test_user_b_purchase_list_is_empty(self, client, seed_data):
"""A new user should see no purchases (not User A's purchases)."""
reg = await client.post(
"/auth/register",
json={
"email": "userc@example.com",
"password": "securepass123",
"display_name": "User C",
},
async def test_user_b_purchase_list_is_empty(self, client, db_engine, seed_data):
"""A new user should see no purchases."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="userc@e2e.com", display_name="User C"
)
assert reg.status_code == 201
user_c_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
user_c_headers = {"Cookie": f"better-auth.session_token={session_token}"}
resp = await client.get("/purchases", headers=user_c_headers)
assert resp.status_code == 200
assert len(resp.json()) == 0, "New user should have no purchases"
async def test_user_b_stores_isolated(self, client, seed_data):
async def test_user_b_stores_isolated(self, client, db_engine, seed_data):
"""User B's connected stores should be independent from User A."""
reg = await client.post(
"/auth/register",
json={
"email": "userd@example.com",
"password": "securepass123",
"display_name": "User D",
},
_, session_token = await _create_test_user_and_session(
client, db_engine, email="userd@e2e.com", display_name="User D"
)
assert reg.status_code == 201
user_d_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
user_d_headers = {"Cookie": f"better-auth.session_token={session_token}"}
# User D should have no connected stores
resp = await client.get("/me/stores", headers=user_d_headers)
assert resp.status_code == 200
assert len(resp.json()) == 0, "New user should have no connected stores"
-68
View File
@@ -5,74 +5,6 @@ import pytest
from tests.test_e2e.conftest import BAD_UUID, ZERO_UUID
@pytest.mark.asyncio
class TestRegistrationErrors:
"""Validation errors during user registration."""
async def test_short_password(self, client, db_engine):
resp = await client.post(
"/auth/register",
json={"email": "short@example.com", "password": "short", "display_name": "Test"},
)
assert resp.status_code == 422
async def test_invalid_email(self, client, db_engine):
resp = await client.post(
"/auth/register",
json={"email": "not-an-email", "password": "securepass123", "display_name": "Test"},
)
assert resp.status_code == 422
async def test_missing_fields(self, client, db_engine):
resp = await client.post("/auth/register", json={})
assert resp.status_code == 422
async def test_empty_display_name(self, client, db_engine):
resp = await client.post(
"/auth/register",
json={"email": "empty@example.com", "password": "securepass123", "display_name": ""},
)
assert resp.status_code == 422
async def test_duplicate_email(self, client, db_engine):
payload = {
"email": "dupe@example.com",
"password": "securepass123",
"display_name": "First",
}
first = await client.post("/auth/register", json=payload)
assert first.status_code == 201
second = await client.post("/auth/register", json=payload)
assert second.status_code == 409
@pytest.mark.asyncio
class TestLoginErrors:
"""Login failure modes."""
async def test_wrong_password(self, client, db_engine):
await client.post(
"/auth/register",
json={
"email": "login-err@example.com",
"password": "correctpass1",
"display_name": "Login",
},
)
resp = await client.post(
"/auth/login",
json={"email": "login-err@example.com", "password": "wrongpass123"},
)
assert resp.status_code == 401
async def test_nonexistent_user(self, client, db_engine):
resp = await client.post(
"/auth/login",
json={"email": "nobody@example.com", "password": "doesntmatter"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
class TestNotFoundErrors:
"""404 responses for missing resources."""