Compare commits

..

9 Commits

Author SHA1 Message Date
Savannah Savings 21443a266a Merge pull request 'Promote dev → uat: ruff lint fixes (CAR-1004)' (#31) from dev into uat
CI / lint (push) Successful in 6s
CI / typecheck (push) Failing after 30s
CI / test (push) Failing after 1m34s
CI / build-and-push (push) Has been skipped
CI / deploy-dev (push) Has been skipped
CI / deploy-uat (push) Failing after 26s
Promote dev → uat: ruff lint fixes (CAR-1004)
2026-05-23 23:12:10 +00:00
Savannah Savings 6799b0e7b1 Merge pull request 'promote: dev → uat (CAR-995 CI registry migration)' (#27) from dev into uat
CI / lint (push) Failing after 3s
CI / typecheck (push) Failing after 29s
CI / test (push) Failing after 49s
CI / build-and-push (push) Has been skipped
CI / deploy-dev (push) Has been skipped
CI / deploy-uat (push) Failing after 41s
promote: dev → uat (CAR-995 CI registry migration) (#27)
2026-05-23 22:31:54 +00:00
Savannah Savings 50110a54b7 Merge pull request 'Promote dev → uat: CI pipeline fixes (CAR-1000)' (#24) from dev into uat
CI / lint (push) Failing after 4s
CI / typecheck (push) Failing after 30s
CI / test (push) Failing after 50s
CI / build-and-push (push) Has been skipped
CI / deploy-dev (push) Has been skipped
CI / deploy-uat (push) Failing after 30s
Promote dev → uat: CI pipeline fixes (CAR-1000)

Promotes PR #22 fixes to UAT environment.
2026-05-23 22:14:44 +00:00
Savannah Savings 28ad343759 Merge pull request 'chore: promote dev to uat (dispose_engine fix, CAR-932)' (#20) from dev into uat
CI / lint (push) Failing after 4s
CI / test (push) Failing after 10s
CI / build-and-push (push) Has been skipped
CI / deploy-dev (push) Has been skipped
CI / typecheck (push) Failing after 17s
CI / deploy-uat (push) Failing after 33s
chore: promote dev to uat (dispose_engine fix, CAR-932)
2026-05-23 21:52:24 +00:00
Savannah Savings 06c6dbed5c Merge pull request 'promote: dev → uat (CAR-992 cors_origins fix)' (#15) from dev into uat
CI / lint (push) Failing after 4s
CI / test (push) Failing after 10s
CI / build-and-push (push) Has been skipped
CI / deploy-dev (push) Has been skipped
CI / typecheck (push) Failing after 36s
CI / deploy-uat (push) Failing after 29s
promote: dev → uat (CAR-992 cors_origins fix) (#15)
2026-05-23 20:56:06 +00:00
Savannah Savings 228a83c355 Merge pull request 'promote: dev → uat (CI trigger fix)' (#10) from dev into uat
CI / lint (push) Failing after 4s
CI / test (push) Failing after 0s
CI / build-and-push (push) Has been skipped
CI / deploy-dev (push) Has been skipped
CI / typecheck (push) Failing after 16s
CI / deploy-uat (push) Failing after 42s
promote: dev → uat (CI trigger fix) (#10)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 14:39:13 +00:00
Savannah Savings fbfedd4e8f Merge pull request 'chore: promote dev to uat (CAR-898 workflow move)' (#7) from dev into uat
chore: promote dev to uat (CAR-898 workflow move) (#7)
2026-05-21 13:05:23 +00:00
Coupon Carl 6a8db71537 Merge pull request 'ci: promote Gitea Actions conversion to UAT' (#5) from dev into uat 2026-05-21 04:55:13 +00:00
savannah-savings-cto[bot] 556b43b424 Merge pull request #2 from cartsnitch/dev
chore: promote dev to uat
2026-04-19 12:11:48 +00:00
8 changed files with 126 additions and 2294 deletions
+87
View File
@@ -175,3 +175,90 @@ jobs:
git tag "v${{ steps.calver.outputs.version }}" git tag "v${{ steps.calver.outputs.version }}"
git push origin "v${{ steps.calver.outputs.version }}" git push origin "v${{ steps.calver.outputs.version }}"
deploy-dev:
runs-on: ubuntu-latest
needs: [build-and-push]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
steps:
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ secrets.GITEA_TOKEN }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Determine image tag
id: api_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 api image tag
if: needs.build-and-push.result == 'success'
run: |
cd infra/apps/overlays/dev
kustomize edit set image ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.api_tag.outputs.tag }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update api image"
git pull --rebase origin main
git push origin main
deploy-uat:
runs-on: ubuntu-latest
needs: [build-and-push]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/uat' || github.ref == 'refs/heads/main')
steps:
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ secrets.GITEA_TOKEN }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Determine image tag
id: api_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 api image tag
if: needs.build-and-push.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.api_tag.outputs.tag }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/uat/kustomization.yaml
git commit -m "ci(uat): update api image"
git pull --rebase origin main
git push origin main
-11
View File
@@ -1,11 +0,0 @@
{
"mcpServers": {
"gitea": {
"type": "http",
"url": "https://git-mcp.farh.net/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_TOKEN}"
}
}
}
}
+1 -6
View File
@@ -15,17 +15,12 @@ dependencies = [
"sqlalchemy[asyncio]>=2.0.35", "sqlalchemy[asyncio]>=2.0.35",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"alembic>=1.13,<2.0", "alembic>=1.13,<2.0",
"psycopg2-binary>=2.9,<3.0", "psycopg2>=2.9,<3.0",
"python-jose[cryptography]>=3.3.0", "python-jose[cryptography]>=3.3.0",
"passlib[bcrypt]>=1.7.4", "passlib[bcrypt]>=1.7.4",
"httpx>=0.27.0", "httpx>=0.27.0",
"redis[hiredis]>=5.2.0", "redis[hiredis]>=5.2.0",
"cryptography>=43.0.0", "cryptography>=43.0.0",
"pytest[dev]>=9.0.3",
"pytest-asyncio[dev]>=1.3.0",
"aiosqlite[dev]>=0.22.1",
"ruff[dev]>=0.15.14",
"mypy[dev]>=1.19.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+23 -143
View File
@@ -5,133 +5,20 @@ matching the Better-Auth session validation flow.
""" """
import secrets import secrets
import uuid as uuid_lib import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import create_engine, event, text, TypeDecorator, String from sqlalchemy import create_engine, event, text
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID as PostgresUUID
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.types import CHAR, TypeEngine
from cartsnitch_api.config import settings as cartsnitch_settings
from cartsnitch_api.database import get_db from cartsnitch_api.database import get_db
from cartsnitch_api.main import create_app from cartsnitch_api.main import create_app
from cartsnitch_api.models import Base from cartsnitch_api.models import Base
cartsnitch_settings = None
class SQLiteCompatibleUUID(TypeDecorator):
"""Adapts PostgreSQL UUID for use with SQLite at test runtime.
Stores as CHAR(32) hex string, converts to Python uuid.UUID on read.
"""
impl = CHAR(32)
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
if isinstance(value, uuid_lib.UUID):
return value.hex
return str(value)
def process_result_value(self, value, dialect):
if value is None:
return None
return uuid_lib.UUID(hex=value)
class _StringUUID(TypeDecorator):
"""Fallback TypeDecorator that accepts both UUID objects and strings.
Used when the model uses Text but tests pass uuid.UUID values.
Stores as native string, returns uuid.UUID on read.
"""
impl = String(36)
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
if isinstance(value, uuid_lib.UUID):
return value.hex
return str(value)
def process_result_value(self, value, dialect):
if value is None:
return None
try:
return uuid_lib.UUID(hex=value)
except Exception:
return value
def _adapt_uuid_columns_for_sqlite(googol=None):
"""Replace PostgreSQL UUID column types with SQLiteCompatibleUUID.
PostgreSQL UUID columns generate DDL using gen_random_uuid() which SQLite
doesn't support. This replaces those column types so SQLite can bind UUIDs
as hex strings. Also sets a Python-side default so INSERTs without explicit
id succeed. Accepts an optional connection arg from run_sync.
"""
for table in Base.metadata.tables.values():
for column in table.columns.values():
if isinstance(column.type, PostgresUUID):
column.type = SQLiteCompatibleUUID()
if column.server_default is None and column.default is None:
column.default = uuid_lib.uuid4
def _adapt_text_pk_columns_for_uuid(googol=None):
"""Replace Text primary key columns that tests bind uuid.UUID values into.
User.id is a Text column but tests pass uuid.UUID objects. SQLite can't
bind UUID directly so we swap these to a compatible type. Accepts an
optional connection arg from run_sync.
"""
for table in Base.metadata.tables.values():
for column in table.columns.values():
if column.primary_key and isinstance(column.type, sa.Text):
column.type = _StringUUID()
def _adapt_fk_columns_for_uuid(googol=None):
"""Replace FK columns that reference UUID PKs but are typed as Text.
purchase.user_id, user_store_accounts.user_id/store_id, etc. are typed as str
but tests pass uuid.UUID objects for them. Accepts an optional connection arg
from run_sync.
"""
for table in Base.metadata.tables.values():
for column in table.columns.values():
if column.foreign_keys:
for fk in column.foreign_keys:
if isinstance(column.type, (sa.Text, sa.String)) and column.type.length in (None, 255):
column.type = _StringUUID()
def _strip_postgres_server_defaults(googol=None):
"""Remove PostgreSQL-specific server_default expressions for SQLite compatibility.
PostgreSQL functions like gen_random_uuid() and the base64 encoding expression
in email_inbound_token are not valid in SQLite. Accepts an optional connection
arg from run_sync.
"""
for table in Base.metadata.tables.values():
for column in table.columns.values():
if column.server_default is not None:
sd = str(column.server_default.arg)
if "gen_random_uuid" in sd or "gen_random_bytes" in sd:
column.server_default = None
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
TEST_JWT_SECRET = secrets.token_urlsafe(32) TEST_JWT_SECRET = secrets.token_urlsafe(32)
TEST_SERVICE_KEY = secrets.token_urlsafe(32) TEST_SERVICE_KEY = secrets.token_urlsafe(32)
TEST_FERNET_KEY = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=" TEST_FERNET_KEY = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
@@ -139,35 +26,33 @@ TEST_FERNET_KEY = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_test_settings(): def setup_test_settings():
from cartsnitch_api.config import settings as real_settings original_jwt = cartsnitch_settings.jwt_secret_key
original_jwt = real_settings.jwt_secret_key original_service = cartsnitch_settings.service_key
original_service = real_settings.service_key original_fernet = cartsnitch_settings.fernet_key
original_fernet = real_settings.fernet_key cartsnitch_settings.jwt_secret_key = TEST_JWT_SECRET
real_settings.jwt_secret_key = TEST_JWT_SECRET cartsnitch_settings.service_key = TEST_SERVICE_KEY
real_settings.service_key = TEST_SERVICE_KEY cartsnitch_settings.fernet_key = TEST_FERNET_KEY
real_settings.fernet_key = TEST_FERNET_KEY
yield yield
real_settings.jwt_secret_key = original_jwt cartsnitch_settings.jwt_secret_key = original_jwt
real_settings.service_key = original_service cartsnitch_settings.service_key = original_service
real_settings.fernet_key = original_fernet cartsnitch_settings.fernet_key = original_fernet
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def disable_rate_limiting(): def disable_rate_limiting():
from cartsnitch_api.config import settings as real_settings """Disable rate limiting for all tests to prevent 429 interference."""
real_settings.rate_limit_enabled = False cartsnitch_settings.rate_limit_enabled = False
yield yield
real_settings.rate_limit_enabled = True cartsnitch_settings.rate_limit_enabled = True
@pytest.fixture @pytest.fixture
def engine(): def engine():
"""Sync in-memory SQLite engine for model unit tests.""" """Sync in-memory SQLite engine for model unit tests."""
eng = create_engine("sqlite:///:memory:") eng = create_engine("sqlite:///:memory:")
_adapt_uuid_columns_for_sqlite()
_adapt_text_pk_columns_for_uuid()
_adapt_fk_columns_for_uuid()
_strip_postgres_server_defaults()
Base.metadata.create_all(eng) Base.metadata.create_all(eng)
yield eng yield eng
eng.dispose() eng.dispose()
@@ -192,10 +77,6 @@ async def db_engine():
cursor.close() cursor.close()
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(_adapt_uuid_columns_for_sqlite)
await conn.run_sync(_adapt_text_pk_columns_for_uuid)
await conn.run_sync(_adapt_fk_columns_for_uuid)
await conn.run_sync(_strip_postgres_server_defaults)
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# Create Better-Auth tables (not managed by SQLAlchemy models) # Create Better-Auth tables (not managed by SQLAlchemy models)
await conn.execute( await conn.execute(
@@ -285,11 +166,11 @@ async def _create_test_user_and_session(
Returns (user_dict, session_token). Better-Auth stores the raw token Returns (user_dict, session_token). Better-Auth stores the raw token
in the DB, so we insert it as-is. in the DB, so we insert it as-is.
""" """
user_id = str(uuid_lib.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)
session_id = str(uuid_lib.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()
@@ -297,9 +178,9 @@ async def _create_test_user_and_session(
await conn.execute( await conn.execute(
text( text(
"INSERT INTO users (id, email, hashed_password, display_name, " "INSERT INTO users (id, email, hashed_password, display_name, "
"email_verified, email_inbound_token, created_at, updated_at) " "email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hashed_password, :display_name, " "VALUES (:id, :email, :hashed_password, :display_name, :email_verified, "
":email_verified, :email_inbound_token, :created_at, :updated_at)" ":created_at, :updated_at)"
), ),
{ {
"id": user_id, "id": user_id,
@@ -307,7 +188,6 @@ async def _create_test_user_and_session(
"hashed_password": "not-used-with-better-auth", "hashed_password": "not-used-with-better-auth",
"display_name": display_name, "display_name": display_name,
"email_verified": False, "email_verified": False,
"email_inbound_token": secrets.token_urlsafe(16),
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
}, },
+2 -3
View File
@@ -139,8 +139,8 @@ async def test_expired_session_rejected(client, db_engine):
await conn.execute( await conn.execute(
text( text(
"INSERT INTO users (id, email, hashed_password, display_name, " "INSERT INTO users (id, email, hashed_password, display_name, "
"email_verified, email_inbound_token, created_at, updated_at) " "email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hp, :dn, :ev, :token, :ca, :ua)" "VALUES (:id, :email, :hp, :dn, :ev, :ca, :ua)"
), ),
{ {
"id": user_id, "id": user_id,
@@ -148,7 +148,6 @@ async def test_expired_session_rejected(client, db_engine):
"hp": "unused", "hp": "unused",
"dn": "Expired User", "dn": "Expired User",
"ev": False, "ev": False,
"token": secrets.token_urlsafe(16),
"ca": now, "ca": now,
"ua": now, "ua": now,
}, },
+2 -3
View File
@@ -66,8 +66,8 @@ class TestSessionValidation:
await conn.execute( await conn.execute(
text( text(
"INSERT INTO users (id, email, hashed_password, display_name, " "INSERT INTO users (id, email, hashed_password, display_name, "
"email_verified, email_inbound_token, created_at, updated_at) " "email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hp, :dn, :ev, :token, :ca, :ua)" "VALUES (:id, :email, :hp, :dn, :ev, :ca, :ua)"
), ),
{ {
"id": user_id, "id": user_id,
@@ -75,7 +75,6 @@ class TestSessionValidation:
"hp": "unused", "hp": "unused",
"dn": "Expired User", "dn": "Expired User",
"ev": False, "ev": False,
"token": secrets.token_urlsafe(16),
"ca": now, "ca": now,
"ua": now, "ua": now,
}, },
+11 -7
View File
@@ -1,12 +1,11 @@
"""Tests for EncryptedJSON TypeDecorator and session_data encryption.""" """Tests for EncryptedJSON TypeDecorator and session_data encryption."""
import json import json
import uuid as uuid_lib
import pytest import pytest
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import column, table, text from sqlalchemy import column, create_engine, table, text
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from cartsnitch_api.config import settings from cartsnitch_api.config import settings
@@ -16,13 +15,18 @@ from cartsnitch_api.models.user import User, UserStoreAccount
@pytest.fixture @pytest.fixture
def engine(engine): def engine():
yield engine eng = create_engine("sqlite:///:memory:")
Base.metadata.create_all(eng)
yield eng
eng.dispose()
@pytest.fixture @pytest.fixture
def session(session): def session(engine):
yield session factory = sessionmaker(bind=engine)
with factory() as sess:
yield sess
@pytest.fixture @pytest.fixture
@@ -36,7 +40,7 @@ def store(session):
@pytest.fixture @pytest.fixture
def user(session): def user(session):
u = User(id=uuid_lib.uuid4(), email="alice@example.com", hashed_password="fakehash") u = User(email="alice@example.com", hashed_password="fakehash")
session.add(u) session.add(u)
session.commit() session.commit()
session.refresh(u) session.refresh(u)
Generated
-2121
View File
File diff suppressed because it is too large Load Diff