Compare commits

..

14 Commits

Author SHA1 Message Date
Barcode Betty 2f1833e90d fix(api): commit after create_all in alembic env.py
SQLAlchemy 2.0 removed implicit autocommit; without an explicit
connection.commit() DDL changes from create_all() are rolled back
when the connection closes, leaving fresh databases without tables.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-04 21:36:05 +00:00
cartsnitch-cto[bot] 5532b43e38 Merge pull request #145 from cartsnitch/betty/fix-alembic-model-import
fix(api): import Base from models package to register all ORM tables
2026-04-04 21:20:11 +00:00
Barcode Betty 0be7ccd4b4 fix(api): import Base from models package to register all ORM tables
The models/__init__.py imports all ORM model classes (Store, Product,
Coupon, etc.) which registers their table definitions with Base.metadata.
Importing Base directly from models.base skips this registration, so
alembic's create_all() on fresh databases fails to create app tables.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-04 21:12:13 +00:00
cartsnitch-cto[bot] 6d37cecdba Merge pull request #143 from cartsnitch/betty/fix-session-cookie-parsing
fix(auth): parse compound Better-Auth cookie/bearer token
2026-04-04 20:39:09 +00:00
Barcode Betty 3745f5be69 fix(auth): parse compound Better-Auth cookie/bearer token to extract token part
Better-Auth sets the session cookie as "token.sessionId". The DB stores
only the token part, so passing the full compound value caused 401s.

Splits on "." for both cookie and Bearer paths.

Tests added for compound cookie, raw token cookie (regression), and
compound Bearer token.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-04 20:32:43 +00:00
cartsnitch-cto[bot] abec954320 Merge pull request #141 from cartsnitch/betty/fix-api-database-url-fallback
fix(api): accept DATABASE_URL as fallback for shared DB with auth service
2026-04-04 20:05:47 +00:00
Barcode Betty ec9deb515b fix(api): accept DATABASE_URL as fallback for shared DB with auth service
API config.py now reads CARTSNITCH_DATABASE_URL first, falls back to
DATABASE_URL (which the infra K8s overlay sets for all pods), and finally
falls back to the hardcoded default. Also normalizes plain postgresql://
to postgresql+asyncpg:// for the asyncpg driver.

Fixes CAR-510.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-04 19:52:24 +00:00
cartsnitch-cto[bot] cfed9b0482 Merge pull request #139 from cartsnitch/betty/revert-sha256-session-hash
fix(api): revert SHA-256 session token hashing — better-auth stores raw tokens
2026-04-04 19:25:23 +00:00
Barcode Betty 25edd8d5e3 fix(api): revert SHA-256 session token hashing — better-auth stores raw tokens
Better-auth v1.5.6 stores raw 32-char tokens in sessions.token, not SHA-256
hashes. The SHA-256 fix from PR #136 causes all authenticated API calls to
return 401 because the UAT sessions table contains raw tokens.

- Remove hashlib from dependencies.py; compare tokens directly
- Remove hashlib from conftest.py; store raw tokens in test DB
- Remove hashlib from test_expired_session_rejected; use raw tokens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 19:21:26 +00:00
cartsnitch-cto[bot] bd3cb3b9ab fix(api): hash session token with SHA-256 before DB lookup (#136)
fix(api): hash session token with SHA-256 before DB lookup
2026-04-04 19:06:30 +00:00
cartsnitch-cto[bot] 3bedc651c6 Merge pull request #133 from cartsnitch/fix/alembic-version-table-width
fix(api): widen alembic version_table column to 128 chars
2026-04-04 19:01:09 +00:00
Barcode Betty 138033be9b fix(api): hash session token with SHA-256 before DB lookup
Better-Auth v1.2+ stores SHA-256(raw_token) in the sessions.token
column. The cookie/Bearer header carries the raw token, so the API was
doing a plain-text lookup that would never match a hashed value —
causing all authenticated endpoints to return 401.

- Add hashlib import and hash token in _validate_session_token()
- Update conftest._create_test_user_and_session() to store hashed tokens
- Update test_expired_session_rejected() to store hashed tokens

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-04 19:00:09 +00:00
cartsnitch-cto[bot] 8ddefe82e4 fix: read __Secure- prefixed session cookie in API auth (#134)
fix: read __Secure- prefixed session cookie in API auth
2026-04-04 18:48:30 +00:00
Pawla Abdul 43ee1c3531 fix(api): widen alembic version_table column to 128 chars
Default varchar(32) alembic_version column truncates long revision IDs
like 003_make_users_hashed_password_nullable (39 chars) on fresh databases.
Set version_table_column_width=128 in both context.configure() calls.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-04 18:32:36 +00:00
6 changed files with 123 additions and 8 deletions
+4 -2
View File
@@ -6,7 +6,7 @@ from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
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
if config.config_file_name is not None:
@@ -31,6 +31,7 @@ def run_migrations_offline() -> None:
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
version_table_column_width=128,
)
with context.begin_transaction():
context.run_migrations()
@@ -44,7 +45,7 @@ def run_migrations_online() -> None:
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
context.configure(connection=connection, target_metadata=target_metadata, version_table_column_width=128)
with context.begin_transaction():
context.run_migrations()
# Create any tables defined in models but not yet created by migrations.
@@ -52,6 +53,7 @@ def run_migrations_online() -> None:
# checkfirst=True ensures this is a no-op on existing databases.
try:
Base.metadata.create_all(bind=connection, checkfirst=True)
connection.commit()
except Exception as exc:
import logging
logging.getLogger("alembic.env").warning(
+7 -3
View File
@@ -26,7 +26,8 @@ SECURE_SESSION_COOKIE_NAME = "__Secure-better-auth.session_token"
async def _validate_session_token(token: str, db: AsyncSession) -> str:
"""Validate a Better-Auth session token against the sessions table.
Returns the user_id (as str) if the session is valid and not expired.
Better-Auth stores the raw token in the DB. The cookie/Bearer header
carries the same raw token, so we compare directly.
"""
result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
@@ -70,11 +71,14 @@ async def get_current_user(
# 1. Check session cookie — prefer __Secure- variant (HTTPS) over plain (HTTP dev)
cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get(SESSION_COOKIE_NAME)
if cookie_token:
token = cookie_token
# Better-Auth cookie format is "token.sessionId" — extract just the token part
token = cookie_token.split(".")[0] if "." in cookie_token else cookie_token
# 2. Fall back to Bearer header
if not token and credentials:
token = credentials.credentials
# Callers might pass the compound value here too
raw = credentials.credentials
token = raw.split(".")[0] if "." in raw else raw
if not token:
raise HTTPException(
+12 -2
View File
@@ -1,13 +1,16 @@
import base64
from pydantic import model_validator
from pydantic import AliasChoices, Field, model_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
model_config = {"env_prefix": "CARTSNITCH_"}
database_url: str = "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
database_url: str = Field(
default="postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
validation_alias=AliasChoices("CARTSNITCH_DATABASE_URL", "DATABASE_URL"),
)
redis_url: str = "redis://localhost:6379/0"
jwt_secret_key: str = "change-me-in-production"
@@ -49,5 +52,12 @@ class Settings(BaseSettings):
) from None
return self
@model_validator(mode="after")
def normalize_database_url(self):
"""Normalize postgresql:// → postgresql+asyncpg:// for the asyncpg driver."""
if self.database_url.startswith("postgresql://"):
self.database_url = self.database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
return self
settings = Settings()
+2 -1
View File
@@ -136,7 +136,8 @@ async def client(db_engine):
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.
Returns (user_dict, session_token).
Returns (user_dict, session_token). Better-Auth stores the raw token
in the DB, so we insert it as-is.
"""
user_id = str(uuid.uuid4())
email = user_overrides.get("email", "test@example.com")
@@ -71,6 +71,56 @@ async def test_delete_me(client, auth_headers):
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_get_me_compound_cookie(client, db_engine):
"""Compound cookie value (token.sessionId) must be parsed to extract the token part."""
from tests.conftest import _create_test_user_and_session
_, session_token = await _create_test_user_and_session(
client, db_engine, email="compound@example.com", display_name="Compound User"
)
compound = f"{session_token}.B0atkJCFxK1rZlwWPMK97nVO2LnyDun7"
resp = await client.get(
"/auth/me",
headers={"Cookie": f"better-auth.session_token={compound}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "compound@example.com"
@pytest.mark.asyncio
async def test_get_me_raw_token_cookie(client, db_engine):
"""Raw token (no dot) in cookie must still work — regression guard."""
from tests.conftest import _create_test_user_and_session
_, session_token = await _create_test_user_and_session(
client, db_engine, email="rawcookie@example.com", display_name="Raw Cookie User"
)
resp = await client.get(
"/auth/me",
headers={"Cookie": f"better-auth.session_token={session_token}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "rawcookie@example.com"
@pytest.mark.asyncio
async def test_get_me_compound_bearer(client, db_engine):
"""Compound Bearer token (token.sessionId) must be parsed to extract the token part."""
from tests.conftest import _create_test_user_and_session
_, session_token = await _create_test_user_and_session(
client, db_engine, email="compoundbearer@example.com", display_name="Compound Bearer User"
)
compound = f"{session_token}.B0atkJCFxK1rZlwWPMK97nVO2LnyDun7"
resp = await client.get(
"/auth/me",
headers={"Authorization": f"Bearer {compound}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "compoundbearer@example.com"
@pytest.mark.asyncio
async def test_expired_session_rejected(client, db_engine):
"""Expired sessions must be rejected."""
+48
View File
@@ -0,0 +1,48 @@
"""Tests for Settings config, specifically the database_url env var fallback."""
import os
from cartsnitch_api.config import Settings
def test_database_url_prefers_cartsnitch_prefix():
"""CARTSNITCH_DATABASE_URL takes precedence over DATABASE_URL."""
env = {
"CARTSNITCH_DATABASE_URL": "postgresql+asyncpg://user1:pass1@host1:5432/db1",
"DATABASE_URL": "postgresql://user2:pass2@host2:5432/db2",
}
settings = Settings(**env)
assert settings.database_url == "postgresql+asyncpg://user1:pass1@host1:5432/db1"
def test_database_url_falls_back_to_database_url():
"""When CARTSNITCH_DATABASE_URL is absent, DATABASE_URL is accepted."""
env = {
"DATABASE_URL": "postgresql://user:pass@dbhost:5432/mydb",
}
settings = Settings(**env)
assert settings.database_url == "postgresql+asyncpg://user:pass@dbhost:5432/mydb"
def test_database_url_normalizes_plain_postgresql_prefix():
"""DATABASE_URL with plain postgresql:// is normalized to postgresql+asyncpg://."""
env = {
"DATABASE_URL": "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
}
settings = Settings(**env)
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
def test_database_url_preserves_asyncpg_prefix():
"""CARTSNITCH_DATABASE_URL with postgresql+asyncpg:// is left unchanged."""
env = {
"CARTSNITCH_DATABASE_URL": "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
}
settings = Settings(**env)
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
def test_database_url_default():
"""When neither env var is set, the hardcoded default is used."""
settings = Settings()
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"