From ad06fe7de427a9b059a7c79f05319df54665aad8 Mon Sep 17 00:00:00 2001 From: qzl Date: Thu, 5 Feb 2026 15:13:06 +0800 Subject: [PATCH] refactor: align backend layout and supabase infra Consolidate backend modules/tests under the backend package while syncing Supabase compose/env config and related plans. --- .env.example | 209 +++----- .pre-commit-config.yaml | 4 +- AGENTS.md | 66 +-- Makefile | 4 +- README.md | 3 - apps/AGENTS.md | 4 + backend/AGENTS.md | 111 +++++ backend/alembic/README.md | 1 + backend/alembic/alembic.ini | 149 ++++++ backend/alembic/env.py | 90 ++++ backend/alembic/script.py.mako | 28 ++ .../20260205_create_profiles_table.py | 45 ++ ...d25a191d06_enable_rls_security_policies.py | 86 ++++ {api => backend}/src/__init__.py | 0 backend/src/app.py | 133 +++++ {api => backend}/src/core/__init__.py | 0 backend/src/core/auth/models.py | 9 + {api => backend}/src/core/config/__init__.py | 0 {api => backend}/src/core/config/settings.py | 93 +++- backend/src/core/db/__init__.py | 5 + backend/src/core/db/base.py | 37 ++ backend/src/core/db/base_repository.py | 84 ++++ backend/src/core/db/base_service.py | 22 + backend/src/core/db/session.py | 34 ++ backend/src/core/http/__init__.py | 5 + backend/src/core/http/models.py | 7 + backend/src/core/http/response.py | 31 ++ {api => backend}/src/core/logging/__init__.py | 0 {api => backend}/src/core/logging/celery.py | 0 {api => backend}/src/core/logging/config.py | 0 {api => backend}/src/core/logging/context.py | 0 {api => backend}/src/core/logging/filters.py | 0 .../src/core/logging/formatters.py | 0 {api => backend}/src/core/logging/handlers.py | 0 {api => backend}/src/core/logging/logger.py | 0 .../src/core/logging/middleware.py | 0 backend/src/models/__init__.py | 5 + backend/src/models/profile.py | 43 ++ backend/src/services/__init__.py | 1 + backend/src/services/base/__init__.py | 21 + backend/src/services/base/qdrant.py | 94 ++++ backend/src/services/base/redis.py | 97 ++++ .../src/services/base/service_interface.py | 84 ++++ backend/src/v1/__init__.py | 1 + backend/src/v1/auth/__init__.py | 1 + backend/src/v1/auth/dependencies.py | 7 + backend/src/v1/auth/models.py | 35 ++ backend/src/v1/auth/router.py | 49 ++ backend/src/v1/auth/service.py | 147 ++++++ backend/src/v1/infra/__init__.py | 1 + backend/src/v1/infra/dependencies.py | 12 + backend/src/v1/infra/router.py | 38 ++ backend/src/v1/infra/schemas.py | 15 + backend/src/v1/profile/__init__.py | 1 + backend/src/v1/profile/dependencies.py | 93 ++++ backend/src/v1/profile/repository.py | 72 +++ backend/src/v1/profile/router.py | 36 ++ backend/src/v1/profile/schemas.py | 33 ++ backend/src/v1/profile/service.py | 106 ++++ backend/src/v1/router.py | 19 + {api => backend}/tests/conftest.py | 4 +- backend/tests/e2e/test_auth_flow.py | 134 +++++ backend/tests/e2e/test_infra_health_e2e.py | 79 +++ .../tests/e2e/test_logging_e2e.py | 0 backend/tests/e2e/test_mobile_health_e2e.py | 57 +++ backend/tests/e2e/test_profile_flow.py | 115 +++++ .../test_base_services_integration.py | 49 ++ backend/tests/integration/test_auth_routes.py | 178 +++++++ .../test_fastapi_logging_integration.py | 0 .../integration/test_mobile_app_skeleton.py | 39 ++ .../tests/integration/test_profile_routes.py | 188 +++++++ backend/tests/unit/core/test_base_service.py | 27 ++ .../unit/database/test_base_repository.py | 78 +++ .../unit/database/test_profile_models.py | 114 +++++ .../unit/services/base/test_qdrant_service.py | 78 +++ .../unit/services/base/test_redis_service.py | 98 ++++ .../services/base/test_service_registry.py | 49 ++ .../tests/unit/test_celery_logging.py | 0 .../tests/unit/test_logging_config.py | 0 .../tests/unit/test_logging_filters.py | 0 .../tests/unit/test_logging_settings.py | 0 backend/tests/unit/test_response_envelope.py | 30 ++ .../tests/unit/test_settings_supabase_env.py | 49 ++ .../tests/unit/v1/auth/test_auth_models.py | 41 ++ .../tests/unit/v1/auth/test_auth_service.py | 74 +++ .../v1/profile/test_profile_dependencies.py | 285 +++++++++++ .../unit/v1/profile/test_profile_service.py | 172 +++++++ backend/tests/unit/v1/profile/test_schemas.py | 61 +++ docker/docker-compose.yml | 457 ------------------ docker/supabase/volumes/api/kong.yml | 284 ----------- docker/supabase/volumes/db/webhooks.sql | 208 -------- .../supabase/volumes/functions/main/index.ts | 89 ---- ...AN-base-service-redis-qdrant-2026-02-05.md | 108 +++++ .../PLAN-env-config-refactor-2026-02-05.md | 143 ++++++ docs/plans/PLAN-logging-manager-2026-01-29.md | 49 +- ...pabase-compose-base-services-2026-02-05.md | 113 +++++ .../PLAN-test-db-isolation-2026-02-05.md | 148 ++++++ infra/docker/docker-compose.yml | 407 ++++++++++++++++ infra/docker/volumes/api/kong.yml | 238 +++++++++ .../docker}/volumes/db/_supabase.sql | 1 - .../docker}/volumes/db/jwt.sql | 1 - .../docker}/volumes/db/logs.sql | 1 - .../docker}/volumes/db/pooler.sql | 1 - .../docker}/volumes/db/realtime.sql | 1 - .../docker}/volumes/db/roles.sql | 1 - infra/docker/volumes/db/webhooks.sql | 191 ++++++++ infra/docker/volumes/functions/main/index.ts | 5 + .../docker}/volumes/logs/vector.yml | 148 +++--- .../docker}/volumes/pooler/pooler.exs | 24 +- pyproject.toml | 10 +- pyrightconfig.json | 4 +- 111 files changed, 5540 insertions(+), 1362 deletions(-) create mode 100644 apps/AGENTS.md create mode 100644 backend/AGENTS.md create mode 100644 backend/alembic/README.md create mode 100644 backend/alembic/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/20260205_create_profiles_table.py create mode 100644 backend/alembic/versions/85d25a191d06_enable_rls_security_policies.py rename {api => backend}/src/__init__.py (100%) create mode 100644 backend/src/app.py rename {api => backend}/src/core/__init__.py (100%) create mode 100644 backend/src/core/auth/models.py rename {api => backend}/src/core/config/__init__.py (100%) rename {api => backend}/src/core/config/settings.py (54%) create mode 100644 backend/src/core/db/__init__.py create mode 100644 backend/src/core/db/base.py create mode 100644 backend/src/core/db/base_repository.py create mode 100644 backend/src/core/db/base_service.py create mode 100644 backend/src/core/db/session.py create mode 100644 backend/src/core/http/__init__.py create mode 100644 backend/src/core/http/models.py create mode 100644 backend/src/core/http/response.py rename {api => backend}/src/core/logging/__init__.py (100%) rename {api => backend}/src/core/logging/celery.py (100%) rename {api => backend}/src/core/logging/config.py (100%) rename {api => backend}/src/core/logging/context.py (100%) rename {api => backend}/src/core/logging/filters.py (100%) rename {api => backend}/src/core/logging/formatters.py (100%) rename {api => backend}/src/core/logging/handlers.py (100%) rename {api => backend}/src/core/logging/logger.py (100%) rename {api => backend}/src/core/logging/middleware.py (100%) create mode 100644 backend/src/models/__init__.py create mode 100644 backend/src/models/profile.py create mode 100644 backend/src/services/__init__.py create mode 100644 backend/src/services/base/__init__.py create mode 100644 backend/src/services/base/qdrant.py create mode 100644 backend/src/services/base/redis.py create mode 100644 backend/src/services/base/service_interface.py create mode 100644 backend/src/v1/__init__.py create mode 100644 backend/src/v1/auth/__init__.py create mode 100644 backend/src/v1/auth/dependencies.py create mode 100644 backend/src/v1/auth/models.py create mode 100644 backend/src/v1/auth/router.py create mode 100644 backend/src/v1/auth/service.py create mode 100644 backend/src/v1/infra/__init__.py create mode 100644 backend/src/v1/infra/dependencies.py create mode 100644 backend/src/v1/infra/router.py create mode 100644 backend/src/v1/infra/schemas.py create mode 100644 backend/src/v1/profile/__init__.py create mode 100644 backend/src/v1/profile/dependencies.py create mode 100644 backend/src/v1/profile/repository.py create mode 100644 backend/src/v1/profile/router.py create mode 100644 backend/src/v1/profile/schemas.py create mode 100644 backend/src/v1/profile/service.py create mode 100644 backend/src/v1/router.py rename {api => backend}/tests/conftest.py (69%) create mode 100644 backend/tests/e2e/test_auth_flow.py create mode 100644 backend/tests/e2e/test_infra_health_e2e.py rename {api => backend}/tests/e2e/test_logging_e2e.py (100%) create mode 100644 backend/tests/e2e/test_mobile_health_e2e.py create mode 100644 backend/tests/e2e/test_profile_flow.py create mode 100644 backend/tests/integration/services/test_base_services_integration.py create mode 100644 backend/tests/integration/test_auth_routes.py rename {api => backend}/tests/integration/test_fastapi_logging_integration.py (100%) create mode 100644 backend/tests/integration/test_mobile_app_skeleton.py create mode 100644 backend/tests/integration/test_profile_routes.py create mode 100644 backend/tests/unit/core/test_base_service.py create mode 100644 backend/tests/unit/database/test_base_repository.py create mode 100644 backend/tests/unit/database/test_profile_models.py create mode 100644 backend/tests/unit/services/base/test_qdrant_service.py create mode 100644 backend/tests/unit/services/base/test_redis_service.py create mode 100644 backend/tests/unit/services/base/test_service_registry.py rename {api => backend}/tests/unit/test_celery_logging.py (100%) rename {api => backend}/tests/unit/test_logging_config.py (100%) rename {api => backend}/tests/unit/test_logging_filters.py (100%) rename {api => backend}/tests/unit/test_logging_settings.py (100%) create mode 100644 backend/tests/unit/test_response_envelope.py create mode 100644 backend/tests/unit/test_settings_supabase_env.py create mode 100644 backend/tests/unit/v1/auth/test_auth_models.py create mode 100644 backend/tests/unit/v1/auth/test_auth_service.py create mode 100644 backend/tests/unit/v1/profile/test_profile_dependencies.py create mode 100644 backend/tests/unit/v1/profile/test_profile_service.py create mode 100644 backend/tests/unit/v1/profile/test_schemas.py delete mode 100644 docker/docker-compose.yml delete mode 100644 docker/supabase/volumes/api/kong.yml delete mode 100644 docker/supabase/volumes/db/webhooks.sql delete mode 100644 docker/supabase/volumes/functions/main/index.ts create mode 100644 docs/plans/PLAN-base-service-redis-qdrant-2026-02-05.md create mode 100644 docs/plans/PLAN-env-config-refactor-2026-02-05.md create mode 100644 docs/plans/PLAN-supabase-compose-base-services-2026-02-05.md create mode 100644 docs/plans/PLAN-test-db-isolation-2026-02-05.md create mode 100644 infra/docker/docker-compose.yml create mode 100644 infra/docker/volumes/api/kong.yml rename {docker/supabase => infra/docker}/volumes/db/_supabase.sql (98%) rename {docker/supabase => infra/docker}/volumes/db/jwt.sql (99%) rename {docker/supabase => infra/docker}/volumes/db/logs.sql (99%) rename {docker/supabase => infra/docker}/volumes/db/pooler.sql (99%) rename {docker/supabase => infra/docker}/volumes/db/realtime.sql (99%) rename {docker/supabase => infra/docker}/volumes/db/roles.sql (99%) create mode 100644 infra/docker/volumes/db/webhooks.sql create mode 100644 infra/docker/volumes/functions/main/index.ts rename {docker/supabase => infra/docker}/volumes/logs/vector.yml (60%) rename {docker/supabase => infra/docker}/volumes/pooler/pooler.exs (62%) diff --git a/.env.example b/.env.example index 284007c..041a8c1 100644 --- a/.env.example +++ b/.env.example @@ -1,162 +1,103 @@ -# 统一环境变量配置模板(根目录 .env.example) -# 使用方法:复制到 .env 并填写实际值 +# 环境变量配置模板(复制到 .env 并填写实际值) # 警告:切勿将包含真实密钥的 .env 提交到代码仓库 -# 命名规则:前缀 SOCIAL_,层级分隔符 __(例如 SOCIAL_INFRA__POSTGRES__PASSWORD) ############ -# 运行时配置(API 后端使用) +# 运行时配置 ############ -# 运行环境:dev(开发)、test(测试)、prod(生产) -SOCIAL_RUNTIME__ENVIRONMENT=dev -# 调试模式:true 开启详细日志和错误堆栈,false 生产环境建议关闭 +SOCIAL_RUNTIME__ENVIRONMENT=dev # dev / prod SOCIAL_RUNTIME__DEBUG=true -# 日志级别:DEBUG、INFO、WARNING、ERROR、CRITICAL SOCIAL_RUNTIME__LOG_LEVEL=INFO -# 是否记录 SQL 查询日志:开发调试时可开启,生产环境建议关闭 SOCIAL_RUNTIME__SQL_LOG_QUERIES=false ############ -# 应用配置(API 后端使用) +# 应用配置 ############ -# API 服务监听地址:0.0.0.0 表示所有网络接口,本地开发可用 127.0.0.1 SOCIAL_APP__HOST=0.0.0.0 -# API 服务监听端口 SOCIAL_APP__PORT=8000 -# 是否启用代码热重载:开发环境 true,生产环境 false SOCIAL_APP__RELOAD=true ############ -# 基础设施密钥(Docker 服务使用) +# Redis 配置 ############ -# PostgreSQL 数据库超级用户密码:生产环境必须更换强密码 -SOCIAL_INFRA__POSTGRES__PASSWORD=CHANGE_ME -# JWT 签名密钥(Supabase 认证服务使用):生产环境必须更换 -SOCIAL_INFRA__JWT__SECRET=CHANGE_ME -# Supabase 匿名访问密钥:用于前端匿名访问 API -SOCIAL_INFRA__SUPABASE__ANON_KEY=CHANGE_ME -# Supabase 服务角色密钥:拥有完全权限,仅后端服务使用,切勿泄露 -SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY=CHANGE_ME -# Supabase 管理后台用户名 -SOCIAL_INFRA__DASHBOARD__USERNAME=supabase -# Supabase 管理后台密码 -SOCIAL_INFRA__DASHBOARD__PASSWORD=CHANGE_ME -# Supabase 数据库连接池加密密钥 -SOCIAL_INFRA__SUPAVISOR__SECRET_KEY_BASE=CHANGE_ME -# Supabase Vault 加密密钥 -SOCIAL_INFRA__SUPAVISOR__VAULT_ENC_KEY=CHANGE_ME -# Supabase Postgres Meta 加密密钥 -SOCIAL_INFRA__PG_META__CRYPTO_KEY=CHANGE_ME -# Logflare 公共访问令牌 -SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN=CHANGE_ME -# Logflare 私有访问令牌 -SOCIAL_INFRA__LOGFLARE__PRIVATE_ACCESS_TOKEN=CHANGE_ME +SOCIAL_REDIS__PASSWORD=change-me-redis-password ############ -# 基础设施数据库配置(Docker 服务使用) +# Qdrant 配置 ############ -# PostgreSQL 容器内主机名:Docker 内部使用 -SOCIAL_INFRA__POSTGRES__HOST=db -# 数据库名称:与初始化脚本保持一致 -SOCIAL_INFRA__POSTGRES__DB=linksy -# PostgreSQL 容器内端口 -SOCIAL_INFRA__POSTGRES__PORT=54322 +SOCIAL_QDRANT__API_KEY=change-me-qdrant-key ############ -# Supavisor 数据库连接池配置(Docker 服务使用) +# Supabase(本地 Docker 与阿里云自托管保持同一变量) ############ -# 连接池代理端口(事务模式) -SOCIAL_INFRA__POOLER__PROXY_PORT_TRANSACTION=6543 -# 每个数据库连接池的默认大小 -SOCIAL_INFRA__POOLER__DEFAULT_POOL_SIZE=20 -# 连接池最大客户端连接数 -SOCIAL_INFRA__POOLER__MAX_CLIENT_CONN=100 -# 连接池租户 ID -SOCIAL_INFRA__POOLER__TENANT_ID=local-tenant -# 每个数据库的连接池大小 -SOCIAL_INFRA__POOLER__DB_POOL_SIZE=5 +# Supabase 栈使用 infra/docker/docker-compose.yml +# 仅绑定 127.0.0.1,不对局域网/公网暴露 -############ -# API 网关 Kong 配置(Docker 服务使用) -############ -# Kong HTTP 端口映射到宿主机的端口 -SOCIAL_INFRA__KONG__HTTP_PORT=8001 -# Kong HTTPS 端口映射到宿主机的端口 -SOCIAL_INFRA__KONG__HTTPS_PORT=8443 +# 基础 URL(本地默认 8000) +SOCIAL_SUPABASE__PUBLIC_SCHEME=http +SOCIAL_SUPABASE__PUBLIC_HOST=localhost +SOCIAL_SUPABASE__SITE_URL=http://localhost:3000 -############ -# PostgREST API 配置(Docker 服务使用) -############ -# PostgREST 暴露的数据库模式列表,逗号分隔 -SOCIAL_INFRA__PGRST__DB_SCHEMAS=public,storage,graphql_public +####### +# 本地 Supabase 端口(只绑定 127.0.0.1) +SOCIAL_SUPABASE__KONG_HTTP_PORT=8000 +SOCIAL_SUPABASE__KONG_HTTPS_PORT=8443 -############ -# 认证服务 GoTrue 配置(Docker 服务使用) -############ -# 站点 URL:用于生成回调链接等,通常为前端地址 -SOCIAL_INFRA__SITE__URL=http://localhost:3000 -# 允许的重定向 URL 列表,逗号分隔 -SOCIAL_INFRA__ADDITIONAL_REDIRECT_URLS= -# JWT 过期时间(秒) -SOCIAL_INFRA__JWT__EXPIRY=3600 -# 是否禁用用户注册:true 禁止,false 允许 -SOCIAL_INFRA__AUTH__DISABLE_SIGNUP=false -# API 外部访问 URL:用于 Kong 网关对外暴露的地址 -SOCIAL_INFRA__API_EXTERNAL_URL=http://localhost:8001 -############ -# Supabase 公共访问 URL:用于前端/SDK/Studio 访问(可与 API 外部地址不同) -# 反向代理场景请填代理后的公网地址 -SOCIAL_INFRA__SUPABASE__PUBLIC_URL=http://localhost:8001 -# 邮箱验证链接路径 -SOCIAL_INFRA__MAILER__URLPATHS_CONFIRMATION="/auth/v1/verify" -# 邮箱邀请链接路径 -SOCIAL_INFRA__MAILER__URLPATHS_INVITE="/auth/v1/verify" -# 邮箱找回密码链接路径 -SOCIAL_INFRA__MAILER__URLPATHS_RECOVERY="/auth/v1/verify" -# 邮箱变更确认链接路径 -SOCIAL_INFRA__MAILER__URLPATHS_EMAIL_CHANGE="/auth/v1/verify" -# 是否启用邮箱注册:true 启用,false 禁用 -SOCIAL_INFRA__EMAIL__ENABLE_SIGNUP=true -# 是否自动确认邮箱:true 注册后自动登录,false 需要验证邮箱 -SOCIAL_INFRA__EMAIL__ENABLE_AUTOCONFIRM=false -# 管理员邮箱地址:用于发送系统通知等 -SOCIAL_INFRA__SMTP__ADMIN_EMAIL=admin@example.com -# SMTP 服务器主机地址 -SOCIAL_INFRA__SMTP__HOST=supabase-mail -# SMTP 服务器端口:25(不加密)、465(SSL)、587(TLS) -SOCIAL_INFRA__SMTP__PORT=2500 -# SMTP 用户名 -SOCIAL_INFRA__SMTP__USER=fake_mail_user -# SMTP 密码 -SOCIAL_INFRA__SMTP__PASS=fake_mail_password -# 发件人显示名称 -SOCIAL_INFRA__SMTP__SENDER_NAME=fake_sender -# 是否允许匿名用户访问:true 允许,false 禁止 -SOCIAL_INFRA__AUTH__ENABLE_ANONYMOUS_USERS=false -# 是否启用手机号注册:true 启用,false 禁用 -SOCIAL_INFRA__AUTH__ENABLE_PHONE_SIGNUP=true -# 是否自动确认手机号:true 自动验证,false 需要短信验证码 -SOCIAL_INFRA__AUTH__ENABLE_PHONE_AUTOCONFIRM=true +# Postgres 连接信息(后端与 Supabase 共用密码) +SOCIAL_DATABASE__HOST=localhost +SOCIAL_DATABASE__PORT=5434 +SOCIAL_DATABASE__NAME=postgres +SOCIAL_DATABASE__USER=postgres +SOCIAL_DATABASE__PASSWORD=change-me-strong-password -############ -# Supabase Studio 配置(Docker 服务使用) -############ -# 默认组织名称 -SOCIAL_INFRA__STUDIO__DEFAULT_ORGANIZATION=Default Organization -# 默认项目名称 -SOCIAL_INFRA__STUDIO__DEFAULT_PROJECT=Default Project -# 是否启用 WebP 图片格式检测:true 启用自动转换,false 禁用 -SOCIAL_INFRA__IMGPROXY__ENABLE_WEBP_DETECTION=true -# OpenAI API 密钥:用于 Supabase AI 功能 -SOCIAL_INFRA__OPENAI__API_KEY= +# JWT/Keys(必须替换) +SOCIAL_SUPABASE__JWT_SECRET=change-me-jwt-secret-at-least-32-chars +SOCIAL_SUPABASE__ANON_KEY=replace-with-supabase-anon-key +SOCIAL_SUPABASE__SERVICE_ROLE_KEY=replace-with-supabase-service-role-key -############ -# Edge Functions 配置(Docker 服务使用) -############ -# 是否验证 JWT:true 验证,false 不验证 -SOCIAL_INFRA__FUNCTIONS__VERIFY_JWT=false +# Studio 登录 +SOCIAL_SUPABASE__DASHBOARD_USERNAME=admin +SOCIAL_SUPABASE__DASHBOARD_PASSWORD=change-me -############ -# 日志与分析配置(Docker 服务使用) -############ -# Docker Socket 路径:用于容器日志收集 -SOCIAL_INFRA__DOCKER__SOCKET_LOCATION=/var/run/docker.sock +# 核心加密 Key(必须替换) +SOCIAL_SUPABASE__SECRET_KEY_BASE=change-me-secret-key-base +SOCIAL_SUPABASE__VAULT_ENC_KEY=change-me-vault-enc-key +SOCIAL_SUPABASE__PG_META_CRYPTO_KEY=change-me-pg-meta-crypto-key + +####### +# Logflare(本地可用假值,但不要上云) +SOCIAL_SUPABASE__LOGFLARE_PUBLIC_ACCESS_TOKEN=change-me-logflare-public +SOCIAL_SUPABASE__LOGFLARE_PRIVATE_ACCESS_TOKEN=change-me-logflare-private + +####### +# Pooler +SOCIAL_SUPABASE__POOLER_TENANT_ID=local +SOCIAL_SUPABASE__POOLER_DEFAULT_POOL_SIZE=20 +SOCIAL_SUPABASE__POOLER_MAX_CLIENT_CONN=100 +SOCIAL_SUPABASE__POOLER_DB_POOL_SIZE=5 + +####### +# Auth 可选项(默认允许邮箱注册) +SOCIAL_SUPABASE__ENABLE_EMAIL_SIGNUP=true +SOCIAL_SUPABASE__ENABLE_EMAIL_AUTOCONFIRM=true +SOCIAL_SUPABASE__ENABLE_ANONYMOUS_USERS=false +SOCIAL_SUPABASE__ENABLE_PHONE_SIGNUP=false +SOCIAL_SUPABASE__ENABLE_PHONE_AUTOCONFIRM=false +SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS= +SOCIAL_SUPABASE__DISABLE_SIGNUP=false + +####### +# SMTP(上云必配,本地可留空) +SOCIAL_SUPABASE__SMTP_ADMIN_EMAIL= +SOCIAL_SUPABASE__SMTP_HOST= +SOCIAL_SUPABASE__SMTP_PORT= +SOCIAL_SUPABASE__SMTP_USER= +SOCIAL_SUPABASE__SMTP_PASS= +SOCIAL_SUPABASE__SMTP_SENDER_NAME= + +####### +# Storage/Image 可选配置 +SOCIAL_SUPABASE__PGRST_DB_SCHEMAS=public +SOCIAL_SUPABASE__FUNCTIONS_VERIFY_JWT=false +SOCIAL_SUPABASE__IMGPROXY_ENABLE_WEBP_DETECTION=true +SOCIAL_SUPABASE__STORAGE_BUCKET_PUBLIC=public +SOCIAL_SUPABASE__STORAGE_BUCKET_PRIVATE=private diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fa28f64..16e832b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,10 +4,10 @@ repos: hooks: - id: basedpyright args: [--level=error] - files: ^api/ + files: ^backend/ - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.9.6 hooks: - id: ruff - files: ^api/ + files: ^backend/ diff --git a/AGENTS.md b/AGENTS.md index 4b8ddb6..3972a65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,57 +1,19 @@ -## Docker Startup +## 目录结构 + +- `infra/`: 基础设施与运维(Docker、脚本、部署相关) +- `backend/`: FastAPI 后端 +- `apps/`: Flutter 手机端 +- `docs/`: 文档与方案 + +## Agent 规则分层 + +- 根目录 `AGENTS.md` 为通用规则,所有修改均需遵守 +- 编辑 `backend/` 目录时,必须同时遵守 `backend/AGENTS.md` +- 编辑 `apps/` 目录时,必须同时遵守 `apps/AGENTS.md` + +## Docker 启动 Always start services with the env file: ```bash docker compose --env-file .env -f docker/docker-compose.yml up -d ``` - -## Python Environment - -**MUST use uv for dependency management and virtual environment execution.** - -- All Python commands: `uv run ` -- Add dependencies: `uv add ` -- All dependencies declared in `pyproject.toml` - -## Code Quality Checks - -**Git pre-commit hook enforces code quality before commit.** - -Pre-commit hook automatically runs on api/ directory: -- `ruff check` - code style and linting -- `basedpyright` - type checking with error level - -If any error detected, commit is rejected. Fix errors before committing. -Do not bypass or weaken checks (no ignores, disables, or config relaxations). Resolve the underlying issues. - - -## TDD First Policy - -**Principle: tests before implementation.** - -### Coverage Requirements -- Minimum coverage: 80% -- Required test types: - - Unit: isolated functions, utilities, components - - Integration: API endpoints, database operations - - E2E: critical user flows (Playwright) - -### Limited Exceptions -- Docs-only changes (README, comments, formatting) may skip integration/E2E -- Non-runtime config changes may skip E2E if no behavior changes -- Any runtime code change requires unit + integration + E2E -- If an exception is used, record the reason in the PR/test notes - -### Mandatory TDD Workflow -1. Write tests (RED) - they must fail -2. Run tests - confirm failure -3. Implement minimal code (GREEN) - only to pass -4. Run tests - confirm success -5. Refactor (IMPROVE) -6. Verify coverage - must be 80%+ - -### Enforcement -- Must use the `tdd-guide` agent for new features -- Do not write implementation before tests -- Do not lower coverage requirements -- Must include unit, integration, and E2E tests diff --git a/Makefile b/Makefile index 4e03c48..5a0fdf2 100644 --- a/Makefile +++ b/Makefile @@ -129,8 +129,8 @@ api-dev: @echo " 2. 确保环境变量已配置:make env" @echo "" @echo "启动命令(示例):" - @echo " cd apps/api" - @echo " uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload" + @echo " cd backend" + @echo " uv run uvicorn src.app:app --host 0.0.0.0 --port 8000 --reload" @echo "" @echo "或使用 Python 虚拟环境:" @echo " python -m venv .venv" diff --git a/README.md b/README.md index 90ac3a7..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,3 +0,0 @@ -# Social App Monorepo - -Flutter + FastAPI + Supabase + Redis + Milvus diff --git a/apps/AGENTS.md b/apps/AGENTS.md new file mode 100644 index 0000000..51d2f5d --- /dev/null +++ b/apps/AGENTS.md @@ -0,0 +1,4 @@ +## Mobile Rules + +- Flutter 手机端规则在此维护 +- 若未声明更细规则,默认遵守根目录 `AGENTS.md` diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..cda3e36 --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,111 @@ +## Python Environment + +**MUST use uv for dependency management and virtual environment execution.** + +- All Python commands: `uv run ` +- Add dependencies: `uv add ` +- All dependencies declared in `pyproject.toml` + +## Logging + +**MUST use project logger for all runtime logging.** + +- Use project logger from `backend/src/core/logging/*` +- Prohibit: print(), logging.info/warning/error directly +- Required: structured logging with context +- Log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL + +## HTTP API Standards + +**MUST follow RESTful conventions and RFC 7807 for error responses.** + +- Errors must use `application/problem+json` with RFC 7807 fields +- No custom response envelopes for HTTP APIs +- Request and response validation must use Pydantic models + +## Environment Variables + +**Backend env access MUST go through** `backend/src/core/config/settings.py`. + +- Only use `Settings()` / `config` from `core.config.settings` +- Do not call `os.environ`, `os.getenv`, `dotenv`, or manual parsing in backend runtime code +- Tests can set env vars via `monkeypatch.setenv`, and should read values via `Settings()` unless the test is explicitly validating env plumbing +- Canonical principle: one source of truth per setting; no duplicate/derived env vars in backend code + +## Code Quality Checks + +**Git pre-commit hook enforces code quality before commit.** + +Pre-commit hook automatically runs on backend/ directory: +- `ruff check` - code style and linting +- `basedpyright` - type checking with error level + +If any error detected, commit is rejected. Fix errors before committing. +Do not bypass or weaken checks (no ignores, disables, or config relaxations). Resolve the underlying issues. + +## TDD First Policy + +**Principle: tests before implementation.** + +### Coverage Requirements +- Minimum coverage: 80% +- Required test types: + - Unit: isolated functions, utilities, components + - Integration: API endpoints, database operations + - E2E: critical user flows (Playwright) + +### Limited Exceptions +- Docs-only changes (README, comments, formatting) may skip integration/E2E +- Non-runtime config changes may skip E2E if no behavior changes +- Any runtime code change requires unit + integration + E2E +- If an exception is used, record the reason in the PR/test notes + +### Mandatory TDD Workflow +1. Write tests (RED) - they must fail +2. Run tests - confirm failure +3. Implement minimal code (GREEN) - only to pass +4. Run tests - confirm success +5. Refactor (IMPROVE) +6. Verify coverage - must be 80%+ + +### Enforcement +- Must use the `tdd-guide` agent for new features +- Do not write implementation before tests +- Do not lower coverage requirements +- Must include unit, integration, and E2E tests + +## Database Development Rules + +### Core Principle +- **Supabase**: authentication (JWT source of truth) +- **Backend**: business authorization (service layer) +- **SQLAlchemy ORM**: data access layer (async + asyncpg, service_role connection) + +### Architecture +Use `schemas / repository / service` pattern: +- `schemas.py` — Pydantic models +- `repository.py` — CRUD only, no auth, no commit (only flush), must receive session (never create session/engine) +- `service.py` — authorization + business logic + transaction boundary (must commit/rollback) +- `dependencies.py` — DI (`get_db`, `get_current_user`) + +### Auth & Data Access +- Backend must verify JWT signature and expiration (not just decode) +- Extract `user_id` from JWT `sub` claim +- Backend connects with **service_role** (bypasses RLS) +- `owner_id` always derived from JWT, never from client +- Scope queries by owner/org; public access must be explicit +- service_role key is backend-only; never expose credentials +- Prohibit calling Supabase Admin API (service_role key) from repository/service layers + +### Migrations +- **Alembic is the single source of truth** for schema migrations +- ORM model changes → `alembic revision --autogenerate` +- Raw SQL (policies, triggers, functions) → `op.execute()` +- Migrations must be reversible; no reliance on generated IDs + +### RLS Guidance +- Backend does not rely on RLS for correctness (uses service_role) +- **Backend-only tables**: RLS optional (skip to reduce maintenance) +- **Client-direct tables**: must enable RLS with policies covering select/insert/update/delete +- `alembic_version` must not be exposed to anonymous clients (revoke anon access) +- Business tables that may be exposed to clients should enable defensive RLS even if the backend does not depend on it diff --git a/backend/alembic/README.md b/backend/alembic/README.md new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README.md @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/alembic.ini b/backend/alembic/alembic.ini new file mode 100644 index 0000000..9bd1d32 --- /dev/null +++ b/backend/alembic/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..69fe158 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import asyncio +import sys +from logging.config import fileConfig +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +project_root = Path(__file__).resolve().parents[1] +src_path = project_root / "src" +if str(src_path) not in sys.path: + sys.path = [str(src_path), *sys.path] + +from core.config.settings import config # noqa: E402 +from core.db.base import Base # noqa: E402 +from models import Profile # noqa: F401,E402 + +if TYPE_CHECKING: + from sqlalchemy.engine import Connection + +alembic_config = context.config + +if alembic_config.config_file_name is not None: + fileConfig(alembic_config.config_file_name) + +target_metadata = Base.metadata + + +def _get_database_url() -> str: + database_url = config.database_url + if not database_url: + raise RuntimeError( + "DATABASE_URL is not configured. Set SOCIAL_INFRA__SUPABASE__DATABASE_URL." + ) + return database_url + + +def _build_config() -> dict[str, Any]: + section = alembic_config.get_section(alembic_config.config_ini_section) or {} + return {**section, "sqlalchemy.url": _get_database_url()} + + +def run_migrations_offline() -> None: + url = _get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + compare_server_default=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def _do_run_migrations(connection: "Connection" | Any) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = async_engine_from_config( + _build_config(), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(_do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/20260205_create_profiles_table.py b/backend/alembic/versions/20260205_create_profiles_table.py new file mode 100644 index 0000000..7a34675 --- /dev/null +++ b/backend/alembic/versions/20260205_create_profiles_table.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision = "20260205_create_profiles_table" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "profiles", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("username", sa.String(length=30), nullable=False), + sa.Column("display_name", sa.String(length=50), nullable=True), + sa.Column("avatar_url", sa.Text(), nullable=True), + sa.Column("bio", sa.String(length=200), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id", name="pk_profiles"), + sa.UniqueConstraint("username", name="uq_profiles_username"), + ) + op.create_index("ix_profiles_username", "profiles", ["username"]) + op.create_index("ix_profiles_deleted_at", "profiles", ["deleted_at"]) + + +def downgrade() -> None: + op.drop_index("ix_profiles_deleted_at", table_name="profiles") + op.drop_index("ix_profiles_username", table_name="profiles") + op.drop_table("profiles") diff --git a/backend/alembic/versions/85d25a191d06_enable_rls_security_policies.py b/backend/alembic/versions/85d25a191d06_enable_rls_security_policies.py new file mode 100644 index 0000000..c4988df --- /dev/null +++ b/backend/alembic/versions/85d25a191d06_enable_rls_security_policies.py @@ -0,0 +1,86 @@ +"""enable_rls_security_policies + +Revision ID: 85d25a191d06 +Revises: 20260205_create_profiles_table +Create Date: 2026-02-05 15:08:33.430692 + +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "85d25a191d06" +down_revision: Union[str, Sequence[str], None] = "20260205_create_profiles_table" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Enable RLS security policies. + + Security measures: + 1. Revoke anon role access to alembic_version (internal table) + 2. Enable RLS on profiles table + 3. Add defensive policies for profiles (deny all public access by default) + + Architecture: + - Backend uses service_role connection (bypasses RLS) + - RLS provides defense-in-depth security layer + - Prevents accidental direct PostgREST access + """ + + # 1. Revoke anon role access to alembic_version table + op.execute("REVOKE ALL ON TABLE public.alembic_version FROM anon") + op.execute("REVOKE ALL ON TABLE public.alembic_version FROM authenticated") + + # 2. Enable RLS on profiles table + op.execute("ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY") + + # 3. Add defensive policies for profiles table + # These policies deny all public access by default + # Backend service_role connection bypasses these policies + + # Deny all SELECT operations for anon and authenticated roles + op.execute( + "CREATE POLICY profiles_deny_public_select ON public.profiles " + "FOR SELECT TO anon, authenticated USING (false)" + ) + + # Deny all INSERT operations for anon and authenticated roles + op.execute( + "CREATE POLICY profiles_deny_public_insert ON public.profiles " + "FOR INSERT TO anon, authenticated WITH CHECK (false)" + ) + + # Deny all UPDATE operations for anon and authenticated roles + op.execute( + "CREATE POLICY profiles_deny_public_update ON public.profiles " + "FOR UPDATE TO anon, authenticated USING (false) WITH CHECK (false)" + ) + + # Deny all DELETE operations for anon and authenticated roles + op.execute( + "CREATE POLICY profiles_deny_public_delete ON public.profiles " + "FOR DELETE TO anon, authenticated USING (false)" + ) + + +def downgrade() -> None: + """Rollback RLS security policies.""" + + # 1. Drop all policies on profiles table + op.execute("DROP POLICY IF EXISTS profiles_deny_public_select ON public.profiles") + op.execute("DROP POLICY IF EXISTS profiles_deny_public_insert ON public.profiles") + op.execute("DROP POLICY IF EXISTS profiles_deny_public_update ON public.profiles") + op.execute("DROP POLICY IF EXISTS profiles_deny_public_delete ON public.profiles") + + # 2. Disable RLS on profiles table + op.execute("ALTER TABLE public.profiles DISABLE ROW LEVEL SECURITY") + + # 3. Re-grant default privileges to anon role on alembic_version + # (reverting to Alembic's default behavior) + op.execute("GRANT SELECT ON TABLE public.alembic_version TO anon") + op.execute("GRANT SELECT ON TABLE public.alembic_version TO authenticated") diff --git a/api/src/__init__.py b/backend/src/__init__.py similarity index 100% rename from api/src/__init__.py rename to backend/src/__init__.py diff --git a/backend/src/app.py b/backend/src/app.py new file mode 100644 index 0000000..db7517b --- /dev/null +++ b/backend/src/app.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +from core.config.settings import config +from core.http.models import HealthResponse +from core.http.response import build_problem_details +from core.logging import configure_logging, get_logger +from v1.router import router as mobile_router + + +configure_logging(config) + +app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=config.cors.allow_origins, + allow_credentials=config.cors.allow_credentials, + allow_methods=config.cors.allow_methods, + allow_headers=config.cors.allow_headers, +) +app.include_router(mobile_router) +logger = get_logger("api.app") + + +@app.get("/health", response_model=HealthResponse) +async def health() -> HealthResponse: + return HealthResponse(status="ok") + + +def _build_http_error_response( + request: Request, + exc: Exception, + status_code: int, + detail: object, +) -> JSONResponse: + instance = request.url.path + detail_text = detail if isinstance(detail, str) else "Request failed" + logger.warning( + "HTTP error", + status_code=status_code, + detail=detail_text, + detail_extra=detail, + path=request.url.path, + method=request.method, + ) + problem = build_problem_details( + status_code=status_code, + detail=detail_text, + instance=instance, + ) + return JSONResponse( + status_code=status_code, + content=problem.model_dump(), + media_type="application/problem+json", + ) + + +@app.exception_handler(HTTPException) +async def http_exception_handler( + request: Request, + exc: HTTPException, +) -> JSONResponse: + return _build_http_error_response( + request=request, + exc=exc, + status_code=exc.status_code, + detail=exc.detail, + ) + + +@app.exception_handler(StarletteHTTPException) +async def starlette_http_exception_handler( + request: Request, + exc: StarletteHTTPException, +) -> JSONResponse: + return _build_http_error_response( + request=request, + exc=exc, + status_code=exc.status_code, + detail=exc.detail, + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: Request, + exc: RequestValidationError, +) -> JSONResponse: + instance = request.url.path + logger.warning( + "Request validation error", + path=request.url.path, + method=request.method, + errors=exc.errors(), + ) + problem = build_problem_details( + status_code=422, + detail="Invalid request", + instance=instance, + ) + return JSONResponse( + status_code=422, + content=problem.model_dump(), + media_type="application/problem+json", + ) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler( + request: Request, + exc: Exception, +) -> JSONResponse: + instance = request.url.path + logger.exception( + "Unhandled error", + path=request.url.path, + method=request.method, + ) + problem = build_problem_details( + status_code=500, + detail="Internal Server Error", + instance=instance, + ) + return JSONResponse( + status_code=500, + content=problem.model_dump(), + media_type="application/problem+json", + ) diff --git a/api/src/core/__init__.py b/backend/src/core/__init__.py similarity index 100% rename from api/src/core/__init__.py rename to backend/src/core/__init__.py diff --git a/backend/src/core/auth/models.py b/backend/src/core/auth/models.py new file mode 100644 index 0000000..f0ee9ea --- /dev/null +++ b/backend/src/core/auth/models.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class CurrentUser: + id: UUID diff --git a/api/src/core/config/__init__.py b/backend/src/core/config/__init__.py similarity index 100% rename from api/src/core/config/__init__.py rename to backend/src/core/config/__init__.py diff --git a/api/src/core/config/settings.py b/backend/src/core/config/settings.py similarity index 54% rename from api/src/core/config/settings.py rename to backend/src/core/config/settings.py index b316bbf..8dbd0e1 100644 --- a/api/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -2,6 +2,7 @@ from __future__ import annotations from pathlib import Path from typing import ClassVar, Literal +from urllib.parse import quote from pydantic import BaseModel, Field, computed_field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -54,27 +55,79 @@ class CorsSettings(BaseModel): allow_headers: list[str] = Field(default_factory=lambda: ["*"]) +class RedisSettings(BaseModel): + host: str = "redis" + port: int = 6379 + password: str | None = None + db: int = 0 + socket_connect_timeout: float = 1.0 + socket_timeout: float = 1.0 + max_connections: int = 10 + + @computed_field + @property + def url(self) -> str: + if self.password: + password = quote(self.password, safe="") + return f"redis://:{password}@{self.host}:{self.port}/{self.db}" + return f"redis://{self.host}:{self.port}/{self.db}" + + +class QdrantSettings(BaseModel): + host: str = "qdrant" + port: int = 6333 + grpc_port: int = 6334 + api_key: str | None = None + https: bool = False + prefer_grpc: bool = True + timeout: int = 5 + + @computed_field + @property + def url(self) -> str: + scheme = "https" if self.https else "http" + return f"{scheme}://{self.host}:{self.port}" + + class SupabaseSettings(BaseModel): - url: str = "http://localhost:8001" + public_scheme: str = "http" + public_host: str = "localhost" + kong_http_port: int = 8000 anon_key: str = "CHANGE_ME" service_role_key: str = "CHANGE_ME" jwt_secret: str | None = None + @computed_field + @property + def public_url(self) -> str: + return f"{self.public_scheme}://{self.public_host}:{self.kong_http_port}" -class InfraSupabaseSettings(BaseModel): - public_url: str = "http://localhost:8001" - anon_key: str = "CHANGE_ME" - service_role_key: str = "CHANGE_ME" + @computed_field + @property + def api_external_url(self) -> str: + return self.public_url + + @computed_field + @property + def url(self) -> str: + return self.public_url -class InfraJwtSettings(BaseModel): - secret: str = "CHANGE_ME" +class DatabaseSettings(BaseModel): + host: str = "localhost" + port: int = 5432 + name: str = "postgres" + user: str = "postgres" + password: str = "CHANGE_ME" - -class InfraSettings(BaseModel): - api_external_url: str = "http://localhost:8001" - supabase: InfraSupabaseSettings = Field(default_factory=InfraSupabaseSettings) - jwt: InfraJwtSettings = Field(default_factory=InfraJwtSettings) + @computed_field + @property + def url(self) -> str: + password = quote(self.password, safe="") + return ( + f"postgresql+asyncpg://{self.user}:{password}" + f"@{self.host}:{self.port}/{self.name}" + ) def _resolve_env_file() -> str: @@ -90,16 +143,16 @@ class Settings(BaseSettings): runtime: RuntimeSettings = RuntimeSettings() app: AppSettings = AppSettings() cors: CorsSettings = CorsSettings() - infra: InfraSettings = Field(default_factory=InfraSettings) + redis: RedisSettings = RedisSettings() + qdrant: QdrantSettings = QdrantSettings() + supabase: SupabaseSettings = SupabaseSettings() + + database: DatabaseSettings = DatabaseSettings() @computed_field - def supabase(self) -> SupabaseSettings: - return SupabaseSettings( - url=self.infra.supabase.public_url or self.infra.api_external_url, - anon_key=self.infra.supabase.anon_key, - service_role_key=self.infra.supabase.service_role_key, - jwt_secret=self.infra.jwt.secret, - ) + @property + def database_url(self) -> str: + return self.database.url model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( env_file=_resolve_env_file(), diff --git a/backend/src/core/db/__init__.py b/backend/src/core/db/__init__.py new file mode 100644 index 0000000..22c20ff --- /dev/null +++ b/backend/src/core/db/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from core.db.session import AsyncSessionLocal, engine, get_db + +__all__ = ["AsyncSessionLocal", "engine", "get_db"] diff --git a/backend/src/core/db/base.py b/backend/src/core/db/base.py new file mode 100644 index 0000000..3b118e6 --- /dev/null +++ b/backend/src/core/db/base.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + """Base class for all ORM models.""" + + pass + + +class TimestampMixin: + """Adds created_at and updated_at timestamps.""" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class SoftDeleteMixin: + """Adds soft delete timestamp column.""" + + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) diff --git a/backend/src/core/db/base_repository.py b/backend/src/core/db/base_repository.py new file mode 100644 index 0000000..bf191bd --- /dev/null +++ b/backend/src/core/db/base_repository.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Generic, TypeVar + +from sqlalchemy import Select, select, update +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db.base import Base + +ModelType = TypeVar("ModelType", bound=Base) + + +class BaseRepository(Generic[ModelType]): + _session: AsyncSession + _model: type[ModelType] + + def __init__(self, session: AsyncSession, model: type[ModelType]) -> None: + self._session = session + self._model = model + + def _deleted_at_column(self) -> Any | None: + return getattr(self._model, "deleted_at", None) + + def _apply_soft_delete_filter(self, stmt: Select) -> Select: + deleted_at = self._deleted_at_column() + if deleted_at is None: + return stmt + return stmt.where(deleted_at.is_(None)) + + async def get_by_id(self, entity_id: Any) -> ModelType | None: + id_column = getattr(self._model, "id") + stmt = select(self._model).where(id_column == entity_id) + stmt = self._apply_soft_delete_filter(stmt) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def get_one(self, *filters: Any) -> ModelType | None: + stmt = select(self._model).where(*filters) + stmt = self._apply_soft_delete_filter(stmt) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def update_by_id( + self, entity_id: Any, update_data: dict[str, Any] + ) -> ModelType | None: + if not update_data: + return await self.get_by_id(entity_id) + + id_column = getattr(self._model, "id") + stmt = update(self._model).where(id_column == entity_id) + deleted_at = self._deleted_at_column() + if deleted_at is not None: + stmt = stmt.where(deleted_at.is_(None)) + stmt = stmt.values(**update_data).returning(self._model) + + try: + result = await self._session.execute(stmt) + await self._session.flush() + return result.scalar_one_or_none() + except SQLAlchemyError: + raise + + async def soft_delete_by_id(self, entity_id: Any) -> ModelType | None: + deleted_at = self._deleted_at_column() + if deleted_at is None: + raise ValueError("Soft delete is not supported for this model") + + id_column = getattr(self._model, "id") + stmt = ( + update(self._model) + .where(id_column == entity_id) + .where(deleted_at.is_(None)) + .values(deleted_at=datetime.now(timezone.utc)) + .returning(self._model) + ) + + try: + result = await self._session.execute(stmt) + await self._session.flush() + return result.scalar_one_or_none() + except SQLAlchemyError: + raise diff --git a/backend/src/core/db/base_service.py b/backend/src/core/db/base_service.py new file mode 100644 index 0000000..afde80f --- /dev/null +++ b/backend/src/core/db/base_service.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import HTTPException + +from core.auth.models import CurrentUser + + +class BaseService: + _current_user: CurrentUser | None + + def __init__(self, current_user: CurrentUser | None) -> None: + self._current_user = current_user + + def require_current_user(self) -> CurrentUser: + if self._current_user is None: + raise HTTPException(status_code=401, detail="Unauthorized") + return self._current_user + + def require_user_id(self) -> UUID: + return self.require_current_user().id diff --git a/backend/src/core/db/session.py b/backend/src/core/db/session.py new file mode 100644 index 0000000..0b4a9cf --- /dev/null +++ b/backend/src/core/db/session.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from core.config.settings import config + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncEngine + +engine: AsyncEngine = create_async_engine( + config.database_url, + echo=config.runtime.sql_log_queries, + pool_pre_ping=True, +) + +AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """Dependency that provides a database session. + + The session is automatically closed when the request completes. + Note: The caller (service layer) is responsible for commit/rollback. + """ + async with AsyncSessionLocal() as session: + yield session diff --git a/backend/src/core/http/__init__.py b/backend/src/core/http/__init__.py new file mode 100644 index 0000000..8fa5ebb --- /dev/null +++ b/backend/src/core/http/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from core.http.response import ProblemDetails, build_problem_details + +__all__ = ["ProblemDetails", "build_problem_details"] diff --git a/backend/src/core/http/models.py b/backend/src/core/http/models.py new file mode 100644 index 0000000..a31ae83 --- /dev/null +++ b/backend/src/core/http/models.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: str diff --git a/backend/src/core/http/response.py b/backend/src/core/http/response.py new file mode 100644 index 0000000..24a4c9c --- /dev/null +++ b/backend/src/core/http/response.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from http import HTTPStatus + +from pydantic import BaseModel + + +class ProblemDetails(BaseModel): + type: str = "about:blank" + title: str + status: int + detail: str + instance: str | None = None + + +def build_problem_details( + *, + status_code: int, + detail: str, + type_value: str = "about:blank", + title: str | None = None, + instance: str | None = None, +) -> ProblemDetails: + resolved_title = title or HTTPStatus(status_code).phrase + return ProblemDetails( + type=type_value, + title=resolved_title, + status=status_code, + detail=detail, + instance=instance, + ) diff --git a/api/src/core/logging/__init__.py b/backend/src/core/logging/__init__.py similarity index 100% rename from api/src/core/logging/__init__.py rename to backend/src/core/logging/__init__.py diff --git a/api/src/core/logging/celery.py b/backend/src/core/logging/celery.py similarity index 100% rename from api/src/core/logging/celery.py rename to backend/src/core/logging/celery.py diff --git a/api/src/core/logging/config.py b/backend/src/core/logging/config.py similarity index 100% rename from api/src/core/logging/config.py rename to backend/src/core/logging/config.py diff --git a/api/src/core/logging/context.py b/backend/src/core/logging/context.py similarity index 100% rename from api/src/core/logging/context.py rename to backend/src/core/logging/context.py diff --git a/api/src/core/logging/filters.py b/backend/src/core/logging/filters.py similarity index 100% rename from api/src/core/logging/filters.py rename to backend/src/core/logging/filters.py diff --git a/api/src/core/logging/formatters.py b/backend/src/core/logging/formatters.py similarity index 100% rename from api/src/core/logging/formatters.py rename to backend/src/core/logging/formatters.py diff --git a/api/src/core/logging/handlers.py b/backend/src/core/logging/handlers.py similarity index 100% rename from api/src/core/logging/handlers.py rename to backend/src/core/logging/handlers.py diff --git a/api/src/core/logging/logger.py b/backend/src/core/logging/logger.py similarity index 100% rename from api/src/core/logging/logger.py rename to backend/src/core/logging/logger.py diff --git a/api/src/core/logging/middleware.py b/backend/src/core/logging/middleware.py similarity index 100% rename from api/src/core/logging/middleware.py rename to backend/src/core/logging/middleware.py diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py new file mode 100644 index 0000000..8b2f3f2 --- /dev/null +++ b/backend/src/models/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from models.profile import Profile + +__all__ = ["Profile"] diff --git a/backend/src/models/profile.py b/backend/src/models/profile.py new file mode 100644 index 0000000..8ec69e1 --- /dev/null +++ b/backend/src/models/profile.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin + + +class Profile(TimestampMixin, SoftDeleteMixin, Base): + """User profile model. + + Note: The `id` column references auth.users(id) in Supabase. + This is a business table managed by SQLAlchemy, with the foreign key + relationship to Supabase's auth schema handled at the database level. + """ + + __tablename__: str = "profiles" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + ) + username: Mapped[str] = mapped_column( + String(30), + unique=True, + nullable=False, + index=True, + ) + display_name: Mapped[str | None] = mapped_column( + String(50), + nullable=True, + ) + avatar_url: Mapped[str | None] = mapped_column( + Text, + nullable=True, + ) + bio: Mapped[str | None] = mapped_column( + String(200), + nullable=True, + ) diff --git a/backend/src/services/__init__.py b/backend/src/services/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/services/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/services/base/__init__.py b/backend/src/services/base/__init__.py new file mode 100644 index 0000000..186b326 --- /dev/null +++ b/backend/src/services/base/__init__.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from services.base.qdrant import QdrantService, qdrant_service +from services.base.redis import RedisService, redis_service +from services.base.service_interface import ( + BaseServiceProvider, + ServiceRegistry, + register_service, + register_service_instance, +) + +__all__ = [ + "BaseServiceProvider", + "QdrantService", + "RedisService", + "ServiceRegistry", + "qdrant_service", + "redis_service", + "register_service", + "register_service_instance", +] diff --git a/backend/src/services/base/qdrant.py b/backend/src/services/base/qdrant.py new file mode 100644 index 0000000..6973430 --- /dev/null +++ b/backend/src/services/base/qdrant.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Dict, Optional + +from qdrant_client import QdrantClient + +from core.config.settings import QdrantSettings, config + +from .service_interface import BaseServiceProvider, register_service_instance + + +class QdrantService(BaseServiceProvider): + def __init__(self, settings: QdrantSettings | None = None) -> None: + super().__init__("qdrant") + self._settings = settings or config.qdrant + self._client: Optional[QdrantClient] = None + + def _build_client(self) -> QdrantClient: + return QdrantClient( + url=self._settings.url, + api_key=self._settings.api_key, + timeout=self._settings.timeout, + prefer_grpc=self._settings.prefer_grpc, + ) + + def _require_client(self) -> QdrantClient: + client = self._client + if client is None: + raise RuntimeError("Qdrant client is not initialized") + return client + + async def initialize(self, **_: Any) -> bool: + try: + client = self._build_client() + collections = await asyncio.to_thread(client.get_collections) + self.logger.info( + "Qdrant service initialized", + collections_count=len(collections.collections), + ) + self._client = client + self._set_initialized(True) + return True + except Exception as exc: # noqa: BLE001 + self.logger.warning("Qdrant service initialization failed", error=str(exc)) + self._client = None + self._set_initialized(False) + return False + + async def close(self) -> bool: + client = self._client + if client is None: + return True + try: + close = getattr(client, "close", None) + if callable(close): + await asyncio.to_thread(close) + self.logger.info("Qdrant service closed") + self._client = None + self._set_initialized(False) + return True + except Exception as exc: # noqa: BLE001 + self.logger.exception("Qdrant service close failed", error=str(exc)) + self._client = None + self._set_initialized(False) + return False + + async def health_check(self) -> Dict[str, Any]: + client = self._client + if client is None: + return {"status": "unhealthy", "details": {"error": "not initialized"}} + try: + collections = await asyncio.to_thread(client.get_collections) + return { + "status": "healthy", + "details": { + "connected": True, + "collections_count": len(collections.collections), + "collections": [ + collection.name for collection in collections.collections[:5] + ], + }, + } + except Exception as exc: # noqa: BLE001 + self.logger.warning("Qdrant health check failed", error=str(exc)) + return {"status": "unhealthy", "details": {"error": str(exc)}} + + def get_client(self) -> QdrantClient: + return self._require_client() + + +qdrant_service: QdrantService = register_service_instance("qdrant", QdrantService()) + +__all__ = ["QdrantService", "qdrant_service"] diff --git a/backend/src/services/base/redis.py b/backend/src/services/base/redis.py new file mode 100644 index 0000000..8d0cf79 --- /dev/null +++ b/backend/src/services/base/redis.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import inspect +from typing import Any, Dict, Optional + +import redis.asyncio as redis + +from core.config.settings import RedisSettings, config + +from .service_interface import BaseServiceProvider, register_service_instance + + +class RedisService(BaseServiceProvider): + def __init__(self, settings: RedisSettings | None = None) -> None: + super().__init__("redis") + self._settings = settings or config.redis + self._client: Optional[redis.Redis] = None + + def _build_client(self) -> redis.Redis: + return redis.from_url( + self._settings.url, + decode_responses=True, + socket_connect_timeout=self._settings.socket_connect_timeout, + socket_timeout=self._settings.socket_timeout, + max_connections=self._settings.max_connections, + ) + + def _require_client(self) -> redis.Redis: + client = self._client + if client is None: + raise RuntimeError("Redis client is not initialized") + return client + + async def initialize(self, **_: Any) -> bool: + try: + client = self._build_client() + ping_result = client.ping() + if inspect.isawaitable(ping_result): + await ping_result + self._client = client + self._set_initialized(True) + self.logger.info("Redis service initialized") + return True + except Exception as exc: # noqa: BLE001 + self.logger.warning("Redis service initialization failed", error=str(exc)) + self._client = None + self._set_initialized(False) + return False + + async def close(self) -> bool: + client = self._client + if client is None: + return True + try: + await client.aclose() + self.logger.info("Redis service closed") + self._client = None + self._set_initialized(False) + return True + except Exception as exc: # noqa: BLE001 + self.logger.exception("Redis service close failed", error=str(exc)) + return False + + async def health_check(self) -> Dict[str, Any]: + client = self._client + if client is None: + return {"status": "unhealthy", "details": {"error": "not initialized"}} + try: + ping_result = client.ping() + ping = ( + await ping_result if inspect.isawaitable(ping_result) else ping_result + ) + info_result = client.info() + info = ( + await info_result if inspect.isawaitable(info_result) else info_result + ) + return { + "status": "healthy" if ping else "unhealthy", + "details": { + "ping": ping, + "redis_version": info.get("redis_version"), + "connected_clients": info.get("connected_clients"), + "used_memory": info.get("used_memory_human"), + "uptime_in_seconds": info.get("uptime_in_seconds"), + }, + } + except Exception as exc: # noqa: BLE001 + self.logger.warning("Redis health check failed", error=str(exc)) + return {"status": "unhealthy", "details": {"error": str(exc)}} + + def get_client(self) -> redis.Redis: + return self._require_client() + + +redis_service: RedisService = register_service_instance("redis", RedisService()) + +__all__ = ["RedisService", "redis_service"] diff --git a/backend/src/services/base/service_interface.py b/backend/src/services/base/service_interface.py new file mode 100644 index 0000000..0e96aa5 --- /dev/null +++ b/backend/src/services/base/service_interface.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Callable, Dict, Optional, TypeVar + +from core.logging import get_logger + + +class BaseServiceProvider(ABC): + def __init__(self, service_name: str) -> None: + self.service_name = service_name + self._initialized = False + self.logger = get_logger("services.base").bind(service=service_name) + + @abstractmethod + async def initialize(self, **kwargs: Any) -> bool: + raise NotImplementedError + + @abstractmethod + async def close(self) -> bool: + raise NotImplementedError + + @abstractmethod + async def health_check(self) -> Dict[str, Any]: + raise NotImplementedError + + @property + def is_initialized(self) -> bool: + return self._initialized + + def _set_initialized(self, value: bool) -> None: + self._initialized = value + + def get_service_info(self) -> Dict[str, Any]: + return { + "name": self.service_name, + "initialized": self._initialized, + "type": self.__class__.__name__, + } + + +class ServiceRegistry: + _services: Dict[str, Callable[..., BaseServiceProvider]] = {} + + @classmethod + def register( + cls, service_name: str, factory: Callable[..., BaseServiceProvider] + ) -> None: + cls._services = {**cls._services, service_name: factory} + + @classmethod + def get_service_factory( + cls, service_name: str + ) -> Optional[Callable[..., BaseServiceProvider]]: + return cls._services.get(service_name) + + @classmethod + def list_services(cls) -> list[str]: + return sorted(cls._services.keys()) + + @classmethod + def create_service( + cls, service_name: str, **kwargs: Any + ) -> Optional[BaseServiceProvider]: + factory = cls.get_service_factory(service_name) + if not factory: + return None + return factory(**kwargs) + + +def register_service(service_name: str) -> Callable[[type], type]: + def decorator(service_class: type) -> type: + ServiceRegistry.register(service_name, service_class) + return service_class + + return decorator + + +TService = TypeVar("TService", bound=BaseServiceProvider) + + +def register_service_instance(service_name: str, service: TService) -> TService: + ServiceRegistry.register(service_name, lambda: service) + return service diff --git a/backend/src/v1/__init__.py b/backend/src/v1/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/v1/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/v1/auth/__init__.py b/backend/src/v1/auth/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/v1/auth/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/v1/auth/dependencies.py b/backend/src/v1/auth/dependencies.py new file mode 100644 index 0000000..46b54b8 --- /dev/null +++ b/backend/src/v1/auth/dependencies.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from v1.auth.service import AuthService, SupabaseAuthGateway + + +def get_auth_service() -> AuthService: + return AuthService(gateway=SupabaseAuthGateway()) diff --git a/backend/src/v1/auth/models.py b/backend/src/v1/auth/models.py new file mode 100644 index 0000000..2297901 --- /dev/null +++ b/backend/src/v1/auth/models.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from pydantic import BaseModel, EmailStr, Field + + +class SignupRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=6) + display_name: str | None = None + + +class LoginRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=6) + + +class RefreshRequest(BaseModel): + refresh_token: str = Field(min_length=1) + + +class LogoutRequest(BaseModel): + refresh_token: str = Field(min_length=1) + + +class AuthUser(BaseModel): + id: str + email: EmailStr + + +class AuthTokenResponse(BaseModel): + access_token: str + refresh_token: str + expires_in: int + token_type: str + user: AuthUser diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py new file mode 100644 index 0000000..46da862 --- /dev/null +++ b/backend/src/v1/auth/router.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Response + +from v1.auth.dependencies import get_auth_service +from v1.auth.models import ( + AuthTokenResponse, + LoginRequest, + LogoutRequest, + RefreshRequest, + SignupRequest, +) +from v1.auth.service import AuthService + + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/signup", response_model=AuthTokenResponse) +async def signup( + payload: SignupRequest, + service: AuthService = Depends(get_auth_service), +) -> AuthTokenResponse: + return await service.signup(payload) + + +@router.post("/login", response_model=AuthTokenResponse) +async def login( + payload: LoginRequest, + service: AuthService = Depends(get_auth_service), +) -> AuthTokenResponse: + return await service.login(payload) + + +@router.post("/refresh", response_model=AuthTokenResponse) +async def refresh( + payload: RefreshRequest, + service: AuthService = Depends(get_auth_service), +) -> AuthTokenResponse: + return await service.refresh(payload) + + +@router.post("/logout", status_code=204) +async def logout( + payload: LogoutRequest, + service: AuthService = Depends(get_auth_service), +) -> Response: + await service.logout(payload.refresh_token) + return Response(status_code=204) diff --git a/backend/src/v1/auth/service.py b/backend/src/v1/auth/service.py new file mode 100644 index 0000000..3172c8d --- /dev/null +++ b/backend/src/v1/auth/service.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Protocol, cast + +from fastapi import HTTPException +from supabase import AuthError, create_client + +from core.config.settings import SupabaseSettings, config +from core.logging import get_logger +from v1.auth.models import ( + AuthTokenResponse, + AuthUser, + LoginRequest, + RefreshRequest, + SignupRequest, +) + + +logger = get_logger("v1.auth.service") + + +class AuthServiceGateway(Protocol): + async def signup(self, request: SignupRequest) -> AuthTokenResponse: + raise NotImplementedError + + async def login(self, request: LoginRequest) -> AuthTokenResponse: + raise NotImplementedError + + async def refresh(self, request: RefreshRequest) -> AuthTokenResponse: + raise NotImplementedError + + async def logout(self, refresh_token: str | None) -> None: + raise NotImplementedError + + +class SupabaseAuthGateway(AuthServiceGateway): + _client: Any + + def __init__(self) -> None: + settings: SupabaseSettings = config.supabase + self._client = create_client(settings.url, settings.anon_key) + + async def signup(self, request: SignupRequest) -> AuthTokenResponse: + payload: dict[str, Any] = { + "email": request.email, + "password": request.password, + } + if request.display_name: + payload = { + **payload, + "data": {"display_name": request.display_name}, + } + try: + sign_up = cast(Any, self._client.auth.sign_up) + response = await asyncio.to_thread(sign_up, payload) + return _map_auth_response(response, "Authentication failed") + except AuthError as exc: + logger.warning("Signup failed", error=str(exc)) + raise HTTPException( + status_code=401, detail="Authentication failed" + ) from exc + + async def login(self, request: LoginRequest) -> AuthTokenResponse: + payload: dict[str, Any] = {"email": request.email, "password": request.password} + try: + sign_in = cast(Any, self._client.auth.sign_in_with_password) + response = await asyncio.to_thread(sign_in, payload) + return _map_auth_response(response, "Invalid credentials") + except AuthError as exc: + logger.warning("Login failed", error=str(exc)) + raise HTTPException(status_code=401, detail="Invalid credentials") from exc + + async def refresh(self, request: RefreshRequest) -> AuthTokenResponse: + try: + response = await asyncio.to_thread( + self._client.auth.refresh_session, + request.refresh_token, + ) + return _map_auth_response(response, "Invalid refresh token") + except AuthError as exc: + logger.warning("Refresh failed", error=str(exc)) + raise HTTPException( + status_code=401, detail="Invalid refresh token" + ) from exc + + async def logout(self, refresh_token: str | None) -> None: + if not refresh_token: + raise HTTPException(status_code=401, detail="Missing refresh token") + try: + response = await asyncio.to_thread( + self._client.auth.refresh_session, + refresh_token, + ) + session = getattr(response, "session", None) + if session is None: + raise HTTPException(status_code=401, detail="Invalid refresh token") + await asyncio.to_thread( + self._client.auth.set_session, + str(session.access_token), + str(session.refresh_token), + ) + await asyncio.to_thread(self._client.auth.sign_out) + except AuthError as exc: + logger.warning("Logout failed", error=str(exc)) + raise HTTPException( + status_code=401, detail="Invalid refresh token" + ) from exc + + +class AuthService: + _gateway: AuthServiceGateway + + def __init__(self, gateway: AuthServiceGateway) -> None: + self._gateway = gateway + + async def signup(self, request: SignupRequest) -> AuthTokenResponse: + return await self._gateway.signup(request) + + async def login(self, request: LoginRequest) -> AuthTokenResponse: + return await self._gateway.login(request) + + async def refresh(self, request: RefreshRequest) -> AuthTokenResponse: + return await self._gateway.refresh(request) + + async def logout(self, refresh_token: str | None) -> None: + await self._gateway.logout(refresh_token) + + +def _map_auth_response(response: object, failure_message: str) -> AuthTokenResponse: + session = getattr(response, "session", None) + user = getattr(response, "user", None) + if session is None or user is None: + raise HTTPException(status_code=401, detail=failure_message) + + email = getattr(user, "email", None) + if not email: + raise HTTPException(status_code=401, detail=failure_message) + + auth_user = AuthUser(id=str(user.id), email=str(email)) + return AuthTokenResponse( + access_token=str(session.access_token), + refresh_token=str(session.refresh_token), + expires_in=int(session.expires_in or 0), + token_type=str(session.token_type), + user=auth_user, + ) diff --git a/backend/src/v1/infra/__init__.py b/backend/src/v1/infra/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/v1/infra/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/v1/infra/dependencies.py b/backend/src/v1/infra/dependencies.py new file mode 100644 index 0000000..cbd36c7 --- /dev/null +++ b/backend/src/v1/infra/dependencies.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from services.base.redis import RedisService, redis_service +from services.base.qdrant import QdrantService, qdrant_service + + +def get_redis_service() -> RedisService: + return redis_service + + +def get_qdrant_service() -> QdrantService: + return qdrant_service diff --git a/backend/src/v1/infra/router.py b/backend/src/v1/infra/router.py new file mode 100644 index 0000000..22c023e --- /dev/null +++ b/backend/src/v1/infra/router.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from services.base.qdrant import QdrantService +from services.base.redis import RedisService +from v1.infra.dependencies import get_qdrant_service, get_redis_service +from v1.infra.schemas import InfraHealthResponse, ServiceHealth + + +router = APIRouter(prefix="/infra", tags=["infra"]) + + +@router.get("/health", response_model=InfraHealthResponse) +async def infra_health( + redis_service: RedisService = Depends(get_redis_service), + qdrant_service: QdrantService = Depends(get_qdrant_service), +) -> InfraHealthResponse: + if not redis_service.is_initialized: + await redis_service.initialize() + if not qdrant_service.is_initialized: + await qdrant_service.initialize() + + redis_health = await redis_service.health_check() + qdrant_health = await qdrant_service.health_check() + status = ( + "healthy" + if redis_health["status"] == "healthy" and qdrant_health["status"] == "healthy" + else "unhealthy" + ) + + return InfraHealthResponse( + status=status, + services={ + "redis": ServiceHealth(**redis_health), + "qdrant": ServiceHealth(**qdrant_health), + }, + ) diff --git a/backend/src/v1/infra/schemas.py b/backend/src/v1/infra/schemas.py new file mode 100644 index 0000000..a051ca5 --- /dev/null +++ b/backend/src/v1/infra/schemas.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import Any, Dict, Literal + +from pydantic import BaseModel + + +class ServiceHealth(BaseModel): + status: Literal["healthy", "unhealthy"] + details: Dict[str, Any] + + +class InfraHealthResponse(BaseModel): + status: Literal["healthy", "unhealthy"] + services: Dict[str, ServiceHealth] diff --git a/backend/src/v1/profile/__init__.py b/backend/src/v1/profile/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/v1/profile/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/v1/profile/dependencies.py b/backend/src/v1/profile/dependencies.py new file mode 100644 index 0000000..9088e71 --- /dev/null +++ b/backend/src/v1/profile/dependencies.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +import jwt +from fastapi import Depends, Header, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from core.config.settings import config +from core.db import get_db +from core.logging import get_logger +from core.auth.models import CurrentUser +from v1.profile.repository import SQLAlchemyProfileRepository +from v1.profile.service import ProfileService + +logger = get_logger("v1.profile.dependencies") + + +def get_current_user(authorization: str | None = Header(default=None)) -> CurrentUser: + if not authorization: + logger.warning("JWT validation failed: missing authorization header") + raise HTTPException(status_code=401, detail="Unauthorized") + + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + logger.warning("JWT validation failed: invalid authorization scheme") + raise HTTPException(status_code=401, detail="Unauthorized") + + secret = config.supabase.jwt_secret + if not secret: + logger.error("JWT validation failed: secret not configured") + raise HTTPException(status_code=503, detail="JWT secret not configured") + + supabase_url = config.supabase.public_url.rstrip("/") + expected_issuer = f"{supabase_url}/auth/v1" + + try: + payload = jwt.decode( + token, + secret, + algorithms=["HS256"], + audience="authenticated", + issuer=expected_issuer, + options={ + "verify_aud": True, + "verify_iss": True, + "verify_exp": True, + "require": ["sub", "aud", "iss", "exp"], + }, + ) + except jwt.ExpiredSignatureError: + logger.warning("JWT validation failed: token expired") + raise HTTPException(status_code=401, detail="Unauthorized") + except jwt.InvalidAudienceError: + logger.warning("JWT validation failed: invalid audience") + raise HTTPException(status_code=401, detail="Unauthorized") + except jwt.InvalidIssuerError: + logger.warning("JWT validation failed: invalid issuer") + raise HTTPException(status_code=401, detail="Unauthorized") + except jwt.InvalidSignatureError: + logger.warning("JWT validation failed: invalid signature") + raise HTTPException(status_code=401, detail="Unauthorized") + except jwt.DecodeError: + logger.warning("JWT validation failed: malformed token") + raise HTTPException(status_code=401, detail="Unauthorized") + except jwt.PyJWTError as exc: + logger.warning( + "JWT validation failed: unknown error", error_type=type(exc).__name__ + ) + raise HTTPException(status_code=401, detail="Unauthorized") from exc + + subject = payload.get("sub") + if not isinstance(subject, str) or not subject: + logger.warning("JWT validation failed: missing or invalid subject claim") + raise HTTPException(status_code=401, detail="Unauthorized") + + try: + user_id = UUID(subject) + except ValueError: + logger.warning("JWT validation failed: invalid UUID in subject") + raise HTTPException(status_code=401, detail="Unauthorized") + + logger.debug("JWT validation successful", user_id=str(user_id)) + return CurrentUser(id=user_id) + + +def get_profile_service( + session: Annotated[AsyncSession, Depends(get_db)], + user: Annotated[CurrentUser, Depends(get_current_user)], +) -> ProfileService: + repository = SQLAlchemyProfileRepository(session) + return ProfileService(repository=repository, session=session, current_user=user) diff --git a/backend/src/v1/profile/repository.py b/backend/src/v1/profile/repository.py new file mode 100644 index 0000000..faaffa8 --- /dev/null +++ b/backend/src/v1/profile/repository.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol +from uuid import UUID + +from sqlalchemy.exc import SQLAlchemyError + +from core.db.base_repository import BaseRepository +from core.logging import get_logger +from models.profile import Profile + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = get_logger("v1.profile.repository") + + +class ProfileRepository(Protocol): + """Protocol defining the profile repository interface.""" + + async def get_by_user_id(self, user_id: UUID) -> Profile | None: + """Get profile by user ID.""" + ... + + async def get_by_username(self, username: str) -> Profile | None: + """Get profile by username.""" + ... + + async def update_by_user_id( + self, user_id: UUID, update_data: dict[str, str | None] + ) -> Profile | None: + """Update profile by user ID. Returns updated profile or None if not found.""" + ... + + +class SQLAlchemyProfileRepository(BaseRepository[Profile]): + """SQLAlchemy implementation of ProfileRepository. + + Note: This repository only performs CRUD operations. + - No commit (only flush) - service layer handles transactions + - No auth logic - service layer handles authorization + - No HTTP exceptions - returns None or raises SQLAlchemyError + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(session, Profile) + + async def get_by_user_id(self, user_id: UUID) -> Profile | None: + try: + return await self.get_by_id(user_id) + except SQLAlchemyError: + logger.exception("Profile lookup failed", user_id=str(user_id)) + raise + + async def get_by_username(self, username: str) -> Profile | None: + try: + return await self.get_one(Profile.username == username) + except SQLAlchemyError: + logger.exception("Profile lookup failed", username=username) + raise + + async def update_by_user_id( + self, user_id: UUID, update_data: dict[str, str | None] + ) -> Profile | None: + if not update_data: + return await self.get_by_user_id(user_id) + + try: + return await self.update_by_id(user_id, update_data) + except SQLAlchemyError: + logger.exception("Profile update failed", user_id=str(user_id)) + raise diff --git a/backend/src/v1/profile/router.py b/backend/src/v1/profile/router.py new file mode 100644 index 0000000..89e3ea4 --- /dev/null +++ b/backend/src/v1/profile/router.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path + +from v1.profile.dependencies import get_profile_service +from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest +from v1.profile.service import ProfileService + +router = APIRouter(prefix="/profile", tags=["profile"]) + + +@router.get("/me", response_model=ProfileResponse) +async def get_me( + service: Annotated[ProfileService, Depends(get_profile_service)], +) -> ProfileResponse: + return await service.get_me() + + +@router.patch("/me", response_model=ProfileResponse) +async def update_me( + payload: ProfileUpdateRequest, + service: Annotated[ProfileService, Depends(get_profile_service)], +) -> ProfileResponse: + return await service.update_me(payload) + + +@router.get("/{username}", response_model=ProfileResponse) +async def get_by_username( + username: Annotated[ + str, Path(min_length=3, max_length=30, pattern="^[a-zA-Z0-9_]+$") + ], + service: Annotated[ProfileService, Depends(get_profile_service)], +) -> ProfileResponse: + return await service.get_by_username(username) diff --git a/backend/src/v1/profile/schemas.py b/backend/src/v1/profile/schemas.py new file mode 100644 index 0000000..64db607 --- /dev/null +++ b/backend/src/v1/profile/schemas.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pydantic import AnyHttpUrl, BaseModel, Field, field_validator, model_validator + + +class ProfileResponse(BaseModel): + id: str + username: str + display_name: str | None = None + avatar_url: str | None = None + bio: str | None = None + + +class ProfileUpdateRequest(BaseModel): + display_name: str | None = Field(default=None, max_length=50) + avatar_url: str | None = Field(default=None) + bio: str | None = Field(default=None, max_length=200) + + @field_validator("avatar_url", mode="before") + @classmethod + def validate_avatar_url(cls, v: str | None) -> str | None: + if v is None: + return None + parsed = AnyHttpUrl(v) + if parsed.scheme not in ("http", "https"): + raise ValueError("avatar_url must use http or https scheme") + return str(parsed) + + @model_validator(mode="after") + def require_one_field(self) -> "ProfileUpdateRequest": + if self.display_name is None and self.avatar_url is None and self.bio is None: + raise ValueError("At least one field must be provided") + return self diff --git a/backend/src/v1/profile/service.py b/backend/src/v1/profile/service.py new file mode 100644 index 0000000..11449d9 --- /dev/null +++ b/backend/src/v1/profile/service.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import HTTPException +from sqlalchemy.exc import SQLAlchemyError + +from core.auth.models import CurrentUser +from core.db.base_service import BaseService +from core.logging import get_logger +from v1.profile.repository import ProfileRepository +from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = get_logger("v1.profile.service") + + +class ProfileService(BaseService): + """Profile service handling business logic and transactions. + + Responsibilities: + - Authorization checks + - Transaction boundary (commit/rollback) + - Converting ORM models to response schemas + """ + + _repository: ProfileRepository + _session: AsyncSession + + def __init__( + self, + repository: ProfileRepository, + session: AsyncSession, + current_user: CurrentUser | None, + ) -> None: + super().__init__(current_user=current_user) + self._repository = repository + self._session = session + + async def get_me(self) -> ProfileResponse: + user_id = self.require_user_id() + try: + profile = await self._repository.get_by_user_id(user_id) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="Profile store unavailable") + + if profile is None: + raise HTTPException(status_code=404, detail="Profile not found") + return ProfileResponse( + id=str(profile.id), + username=profile.username, + display_name=profile.display_name, + avatar_url=profile.avatar_url, + bio=profile.bio, + ) + + async def update_me(self, update: ProfileUpdateRequest) -> ProfileResponse: + user_id = self.require_user_id() + update_data: dict[str, str | None] = { + key: value + for key, value in { + "display_name": update.display_name, + "avatar_url": update.avatar_url, + "bio": update.bio, + }.items() + if value is not None + } + + if not update_data: + raise HTTPException(status_code=400, detail="No fields to update") + + try: + profile = await self._repository.update_by_user_id(user_id, update_data) + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException(status_code=503, detail="Profile store unavailable") + + if profile is None: + raise HTTPException(status_code=404, detail="Profile not found") + + return ProfileResponse( + id=str(profile.id), + username=profile.username, + display_name=profile.display_name, + avatar_url=profile.avatar_url, + bio=profile.bio, + ) + + async def get_by_username(self, username: str) -> ProfileResponse: + try: + profile = await self._repository.get_by_username(username) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="Profile store unavailable") + + if profile is None: + raise HTTPException(status_code=404, detail="Profile not found") + return ProfileResponse( + id=str(profile.id), + username=profile.username, + display_name=profile.display_name, + avatar_url=profile.avatar_url, + bio=profile.bio, + ) diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py new file mode 100644 index 0000000..7500184 --- /dev/null +++ b/backend/src/v1/router.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from core.http.models import HealthResponse +from v1.auth.router import router as auth_router +from v1.infra.router import router as infra_router +from v1.profile.router import router as profile_router + + +router = APIRouter(prefix="/api/v1") +router.include_router(auth_router) +router.include_router(infra_router) +router.include_router(profile_router) + + +@router.get("/health", response_model=HealthResponse) +async def health() -> HealthResponse: + return HealthResponse(status="ok") diff --git a/api/tests/conftest.py b/backend/tests/conftest.py similarity index 69% rename from api/tests/conftest.py rename to backend/tests/conftest.py index 1118019..fdb5ddd 100644 --- a/api/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,6 +6,6 @@ from pathlib import Path def pytest_configure() -> None: root = Path(__file__).resolve().parents[2] - src_path = root / "api" / "src" + src_path = root / "backend" / "src" if str(src_path) not in sys.path: - sys.path.append(str(src_path)) + sys.path.insert(0, str(src_path)) diff --git a/backend/tests/e2e/test_auth_flow.py b/backend/tests/e2e/test_auth_flow.py new file mode 100644 index 0000000..003bf0c --- /dev/null +++ b/backend/tests/e2e/test_auth_flow.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import json +import socket +import threading +import time + +from playwright.sync_api import sync_playwright +import uvicorn + +from app import app +from v1.auth.dependencies import get_auth_service +from v1.auth.models import ( + AuthTokenResponse, + AuthUser, + LoginRequest, + RefreshRequest, + SignupRequest, +) +from v1.auth.service import AuthService + + +class FakeE2EAuthService(AuthService): + def __init__(self) -> None: + self._user = AuthUser(id="user-1", email="user@example.com") + + async def signup(self, request: SignupRequest) -> AuthTokenResponse: + return AuthTokenResponse( + access_token="access-1", + refresh_token="refresh-1", + expires_in=3600, + token_type="bearer", + user=self._user, + ) + + async def login(self, request: LoginRequest) -> AuthTokenResponse: + return AuthTokenResponse( + access_token="access-2", + refresh_token="refresh-2", + expires_in=3600, + token_type="bearer", + user=self._user, + ) + + async def refresh(self, request: RefreshRequest) -> AuthTokenResponse: + return AuthTokenResponse( + access_token="access-3", + refresh_token="refresh-3", + expires_in=3600, + token_type="bearer", + user=self._user, + ) + + async def logout(self, refresh_token: str | None) -> None: + return None + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + if sock.connect_ex((host, port)) == 0: + return + time.sleep(0.05) + raise RuntimeError("Server did not start in time") + + +def _start_server(host: str, port: int): + config = uvicorn.Config(app, host=host, port=port, log_level="info") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + _wait_for_port(host, port) + return server, thread + + +def test_auth_flow_e2e() -> None: + app.dependency_overrides[get_auth_service] = lambda: FakeE2EAuthService() + host = "127.0.0.1" + port = _find_free_port() + server, thread = _start_server(host, port) + + try: + with sync_playwright() as playwright: + request_context = playwright.request.new_context( + base_url=f"http://{host}:{port}" + ) + try: + signup = request_context.post( + "/api/v1/auth/signup", + data=json.dumps( + {"email": "user@example.com", "password": "secret123"} + ), + headers={"Content-Type": "application/json"}, + ) + assert signup.status == 200 + assert signup.json()["access_token"] == "access-1" + + login = request_context.post( + "/api/v1/auth/login", + data=json.dumps( + {"email": "user@example.com", "password": "secret123"} + ), + headers={"Content-Type": "application/json"}, + ) + assert login.status == 200 + assert login.json()["access_token"] == "access-2" + + refresh = request_context.post( + "/api/v1/auth/refresh", + data=json.dumps({"refresh_token": "refresh-2"}), + headers={"Content-Type": "application/json"}, + ) + assert refresh.status == 200 + assert refresh.json()["access_token"] == "access-3" + + logout = request_context.post( + "/api/v1/auth/logout", + data=json.dumps({"refresh_token": "refresh-3"}), + headers={"Content-Type": "application/json"}, + ) + assert logout.status == 204 + finally: + request_context.dispose() + finally: + app.dependency_overrides = {} + server.should_exit = True + thread.join(timeout=5) diff --git a/backend/tests/e2e/test_infra_health_e2e.py b/backend/tests/e2e/test_infra_health_e2e.py new file mode 100644 index 0000000..62e99a4 --- /dev/null +++ b/backend/tests/e2e/test_infra_health_e2e.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import socket +import threading +import time + +from playwright.sync_api import sync_playwright +import uvicorn + +from app import app +from v1.infra.dependencies import get_qdrant_service, get_redis_service + + +class _FakeService: + def __init__(self) -> None: + self._initialized = True + + @property + def is_initialized(self) -> bool: + return self._initialized + + async def initialize(self) -> bool: + return True + + async def health_check(self) -> dict[str, object]: + return {"status": "healthy", "details": {}} + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + if sock.connect_ex((host, port)) == 0: + return + time.sleep(0.05) + raise RuntimeError("Server did not start in time") + + +def _start_server(host: str, port: int): + config = uvicorn.Config(app, host=host, port=port, log_level="info") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + _wait_for_port(host, port) + return server, thread + + +def test_infra_health_e2e() -> None: + app.dependency_overrides[get_redis_service] = lambda: _FakeService() + app.dependency_overrides[get_qdrant_service] = lambda: _FakeService() + + host = "127.0.0.1" + port = _find_free_port() + server, thread = _start_server(host, port) + + try: + with sync_playwright() as playwright: + request_context = playwright.request.new_context( + base_url=f"http://{host}:{port}" + ) + try: + response = request_context.get("/api/v1/infra/health") + assert response.status == 200 + body = response.json() + assert body["status"] == "healthy" + assert "redis" in body["services"] + assert "qdrant" in body["services"] + finally: + request_context.dispose() + finally: + server.should_exit = True + thread.join(timeout=5) + app.dependency_overrides = {} diff --git a/api/tests/e2e/test_logging_e2e.py b/backend/tests/e2e/test_logging_e2e.py similarity index 100% rename from api/tests/e2e/test_logging_e2e.py rename to backend/tests/e2e/test_logging_e2e.py diff --git a/backend/tests/e2e/test_mobile_health_e2e.py b/backend/tests/e2e/test_mobile_health_e2e.py new file mode 100644 index 0000000..dfc1b18 --- /dev/null +++ b/backend/tests/e2e/test_mobile_health_e2e.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import socket +import threading +import time + +from playwright.sync_api import sync_playwright +import uvicorn + +from app import app + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + if sock.connect_ex((host, port)) == 0: + return + time.sleep(0.05) + raise RuntimeError("Server did not start in time") + + +def _start_server(host: str, port: int): + config = uvicorn.Config(app, host=host, port=port, log_level="info") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + _wait_for_port(host, port) + return server, thread + + +def test_mobile_health_e2e() -> None: + host = "127.0.0.1" + port = _find_free_port() + server, thread = _start_server(host, port) + + try: + with sync_playwright() as playwright: + request_context = playwright.request.new_context( + base_url=f"http://{host}:{port}" + ) + try: + response = request_context.get("/api/v1/health") + assert response.status == 200 + body = response.json() + assert body["status"] == "ok" + finally: + request_context.dispose() + finally: + server.should_exit = True + thread.join(timeout=5) diff --git a/backend/tests/e2e/test_profile_flow.py b/backend/tests/e2e/test_profile_flow.py new file mode 100644 index 0000000..da5cb15 --- /dev/null +++ b/backend/tests/e2e/test_profile_flow.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json +import socket +import threading +import time +from uuid import UUID + +from playwright.sync_api import sync_playwright +import uvicorn + +from app import app +from core.auth.models import CurrentUser +from v1.profile.dependencies import get_current_user, get_profile_service +from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest + + +class FakeProfileService: + """Fake service for E2E testing.""" + + def __init__(self, profile: ProfileResponse) -> None: + self._profile = profile + + async def get_me(self) -> ProfileResponse: + return self._profile + + async def update_me(self, update: ProfileUpdateRequest) -> ProfileResponse: + return ProfileResponse( + id=self._profile.id, + username=self._profile.username, + display_name=( + update.display_name + if update.display_name is not None + else self._profile.display_name + ), + avatar_url=( + update.avatar_url + if update.avatar_url is not None + else self._profile.avatar_url + ), + bio=update.bio if update.bio is not None else self._profile.bio, + ) + + async def get_by_username(self, username: str) -> ProfileResponse: + return self._profile + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + if sock.connect_ex((host, port)) == 0: + return + time.sleep(0.05) + raise RuntimeError("Server did not start in time") + + +def _start_server(host: str, port: int): + config = uvicorn.Config(app, host=host, port=port, log_level="info") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + _wait_for_port(host, port) + return server, thread + + +def test_profile_flow_e2e() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + profile = ProfileResponse( + id=str(user_id), + username="demo", + display_name="Demo User", + avatar_url=None, + bio=None, + ) + app.dependency_overrides[get_profile_service] = lambda: FakeProfileService(profile) # type: ignore[return-value] + app.dependency_overrides[get_current_user] = lambda: CurrentUser(id=user_id) + + host = "127.0.0.1" + port = _find_free_port() + server, thread = _start_server(host, port) + + try: + with sync_playwright() as playwright: + request_context = playwright.request.new_context( + base_url=f"http://{host}:{port}" + ) + try: + me = request_context.get("/api/v1/profile/me") + assert me.status == 200 + assert me.json()["username"] == "demo" + + updated = request_context.patch( + "/api/v1/profile/me", + data=json.dumps({"display_name": "Updated"}), + headers={"Content-Type": "application/json"}, + ) + assert updated.status == 200 + assert updated.json()["display_name"] == "Updated" + + public = request_context.get("/api/v1/profile/demo") + assert public.status == 200 + assert public.json()["username"] == "demo" + finally: + request_context.dispose() + finally: + app.dependency_overrides = {} + server.should_exit = True + thread.join(timeout=5) diff --git a/backend/tests/integration/services/test_base_services_integration.py b/backend/tests/integration/services/test_base_services_integration.py new file mode 100644 index 0000000..4ca74c4 --- /dev/null +++ b/backend/tests/integration/services/test_base_services_integration.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import socket + +import pytest + +from core.config.settings import Settings +from services.base.qdrant import QdrantService +from services.base.redis import RedisService + + +def _can_connect(host: str, port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(0.2) + return sock.connect_ex((host, port)) == 0 + + +@pytest.mark.asyncio +async def test_redis_service_health_check_integration() -> None: + host = "127.0.0.1" + port = 6379 + if not _can_connect(host, port): + pytest.skip("Redis is not running on localhost:6379") + + config = Settings() + settings = config.redis.model_copy(update={"host": host, "port": port}) + service = RedisService(settings=settings) + + assert await service.initialize() is True + health = await service.health_check() + assert health["status"] == "healthy" + assert await service.close() is True + + +@pytest.mark.asyncio +async def test_qdrant_service_health_check_integration() -> None: + host = "127.0.0.1" + port = 6333 + if not _can_connect(host, port): + pytest.skip("Qdrant is not running on localhost:6333") + + config = Settings() + settings = config.qdrant.model_copy(update={"host": host, "port": port}) + service = QdrantService(settings=settings) + + assert await service.initialize() is True + health = await service.health_check() + assert health["status"] == "healthy" + assert await service.close() is True diff --git a/backend/tests/integration/test_auth_routes.py b/backend/tests/integration/test_auth_routes.py new file mode 100644 index 0000000..3e7d203 --- /dev/null +++ b/backend/tests/integration/test_auth_routes.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from typing import Callable + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app import app +from v1.auth.dependencies import get_auth_service +from v1.auth.models import ( + AuthTokenResponse, + AuthUser, + LoginRequest, + RefreshRequest, + SignupRequest, +) +from v1.auth.service import AuthService + + +class FakeAuthService(AuthService): + def __init__(self, token_response: AuthTokenResponse) -> None: + self._token_response = token_response + + async def signup(self, request: SignupRequest) -> AuthTokenResponse: + return self._token_response + + async def login(self, request: LoginRequest) -> AuthTokenResponse: + raise HTTPException(status_code=401, detail="Invalid credentials") + + async def refresh(self, request: RefreshRequest) -> AuthTokenResponse: + raise HTTPException(status_code=401, detail="Invalid refresh token") + + async def logout(self, refresh_token: str | None) -> None: + return None + + +def _override_auth_service(service: AuthService) -> Callable[[], AuthService]: + def _get_service() -> AuthService: + return service + + return _get_service + + +def test_signup_returns_token_response() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/signup", + json={"email": "user@example.com", "password": "secret123"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["access_token"] == "access" + assert body["refresh_token"] == "refresh" + assert body["user"]["email"] == "user@example.com" + finally: + app.dependency_overrides = {} + + +def test_login_invalid_returns_problem_details() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "wrongpw"}, + ) + assert response.status_code == 401 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["title"] == "Unauthorized" + assert body["status"] == 401 + assert body["detail"] == "Invalid credentials" + finally: + app.dependency_overrides = {} + + +def test_refresh_invalid_returns_problem_details() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/refresh", + json={"refresh_token": "invalid"}, + ) + assert response.status_code == 401 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["title"] == "Unauthorized" + assert body["status"] == 401 + assert body["detail"] == "Invalid refresh token" + finally: + app.dependency_overrides = {} + + +def test_logout_returns_no_content() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/logout", + json={"refresh_token": "refresh"}, + ) + assert response.status_code == 204 + assert response.content == b"" + finally: + app.dependency_overrides = {} + + +def test_signup_validation_error_returns_problem_details() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post("/api/v1/auth/signup", json={}) + assert response.status_code == 422 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["title"] == "Unprocessable Content" + assert body["status"] == 422 + assert body["detail"] == "Invalid request" + finally: + app.dependency_overrides = {} diff --git a/api/tests/integration/test_fastapi_logging_integration.py b/backend/tests/integration/test_fastapi_logging_integration.py similarity index 100% rename from api/tests/integration/test_fastapi_logging_integration.py rename to backend/tests/integration/test_fastapi_logging_integration.py diff --git a/backend/tests/integration/test_mobile_app_skeleton.py b/backend/tests/integration/test_mobile_app_skeleton.py new file mode 100644 index 0000000..8a55537 --- /dev/null +++ b/backend/tests/integration/test_mobile_app_skeleton.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app import app + + +def test_app_health_returns_envelope() -> None: + client = TestClient(app) + + response = client.get("/health") + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + + +def test_mobile_router_health_returns_envelope() -> None: + client = TestClient(app) + + response = client.get("/api/v1/health") + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + + +def test_not_found_returns_error_envelope() -> None: + client = TestClient(app) + + response = client.get("/missing-route") + + assert response.status_code == 404 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["type"] == "about:blank" + assert body["title"] == "Not Found" + assert body["status"] == 404 + assert body["detail"] == "Not Found" diff --git a/backend/tests/integration/test_profile_routes.py b/backend/tests/integration/test_profile_routes.py new file mode 100644 index 0000000..07f8165 --- /dev/null +++ b/backend/tests/integration/test_profile_routes.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from typing import Callable +from uuid import UUID + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app import app +from core.auth.models import CurrentUser +from v1.profile.dependencies import get_current_user, get_profile_service +from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest +from v1.profile.service import ProfileService + + +class FakeProfileService: + """Fake service for integration testing.""" + + def __init__(self, profile: ProfileResponse) -> None: + self._profile = profile + + async def get_me(self) -> ProfileResponse: + if self._profile.id is None: + raise HTTPException(status_code=404, detail="Profile not found") + return self._profile + + async def update_me(self, update: ProfileUpdateRequest) -> ProfileResponse: + if self._profile.id is None: + raise HTTPException(status_code=404, detail="Profile not found") + return ProfileResponse( + id=self._profile.id, + username=self._profile.username, + display_name=( + update.display_name + if update.display_name is not None + else self._profile.display_name + ), + avatar_url=( + update.avatar_url + if update.avatar_url is not None + else self._profile.avatar_url + ), + bio=update.bio if update.bio is not None else self._profile.bio, + ) + + async def get_by_username(self, username: str) -> ProfileResponse: + if username != self._profile.username: + raise HTTPException(status_code=404, detail="Profile not found") + return self._profile + + +def _override_profile_service( + service: FakeProfileService, +) -> Callable[[], ProfileService]: + def _get_service() -> ProfileService: + return service # type: ignore[return-value] + + return _get_service + + +def _override_current_user(user_id: UUID) -> Callable[[], CurrentUser]: + def _get_user() -> CurrentUser: + return CurrentUser(id=user_id) + + return _get_user + + +def test_get_me_returns_profile() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + profile = ProfileResponse( + id=str(user_id), + username="demo", + display_name="Demo User", + avatar_url=None, + bio=None, + ) + app.dependency_overrides[get_profile_service] = _override_profile_service( + FakeProfileService(profile) + ) + app.dependency_overrides[get_current_user] = _override_current_user(user_id) + + client = TestClient(app) + try: + response = client.get("/api/v1/profile/me") + assert response.status_code == 200 + body = response.json() + assert body["username"] == "demo" + finally: + app.dependency_overrides = {} + + +def test_patch_me_updates_profile() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + profile = ProfileResponse( + id=str(user_id), + username="demo", + display_name="Demo User", + avatar_url=None, + bio=None, + ) + app.dependency_overrides[get_profile_service] = _override_profile_service( + FakeProfileService(profile) + ) + app.dependency_overrides[get_current_user] = _override_current_user(user_id) + + client = TestClient(app) + try: + response = client.patch( + "/api/v1/profile/me", + json={"display_name": "Updated"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["display_name"] == "Updated" + finally: + app.dependency_overrides = {} + + +def test_get_profile_by_username() -> None: + profile = ProfileResponse( + id="00000000-0000-0000-0000-000000000001", + username="demo", + display_name="Demo User", + avatar_url=None, + bio=None, + ) + app.dependency_overrides[get_profile_service] = _override_profile_service( + FakeProfileService(profile) + ) + + client = TestClient(app) + try: + response = client.get("/api/v1/profile/demo") + assert response.status_code == 200 + body = response.json() + assert body["username"] == "demo" + finally: + app.dependency_overrides = {} + + +def test_profile_not_found_returns_problem_details() -> None: + profile = ProfileResponse( + id="00000000-0000-0000-0000-000000000001", + username="demo", + display_name="Demo User", + avatar_url=None, + bio=None, + ) + app.dependency_overrides[get_profile_service] = _override_profile_service( + FakeProfileService(profile) + ) + + client = TestClient(app) + try: + response = client.get("/api/v1/profile/unknown") + assert response.status_code == 404 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["title"] == "Not Found" + assert body["status"] == 404 + finally: + app.dependency_overrides = {} + + +def test_patch_me_validation_error_returns_problem_details() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + profile = ProfileResponse( + id=str(user_id), + username="demo", + display_name="Demo User", + avatar_url=None, + bio=None, + ) + app.dependency_overrides[get_profile_service] = _override_profile_service( + FakeProfileService(profile) + ) + app.dependency_overrides[get_current_user] = _override_current_user(user_id) + + client = TestClient(app) + try: + response = client.patch("/api/v1/profile/me", json={}) + assert response.status_code == 422 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["title"] == "Unprocessable Content" + assert body["status"] == 422 + finally: + app.dependency_overrides = {} diff --git a/backend/tests/unit/core/test_base_service.py b/backend/tests/unit/core/test_base_service.py new file mode 100644 index 0000000..c21110a --- /dev/null +++ b/backend/tests/unit/core/test_base_service.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from uuid import UUID + +from core.auth.models import CurrentUser +from core.db.base_service import BaseService + + +def test_require_current_user_raises_when_missing() -> None: + service = BaseService(current_user=None) + + with pytest.raises(HTTPException) as exc_info: + service.require_current_user() + + assert exc_info.value.status_code == 401 + + +def test_require_current_user_returns_user() -> None: + user = CurrentUser(id=UUID("00000000-0000-0000-0000-000000000001")) + service = BaseService(current_user=user) + + result = service.require_current_user() + + assert result.id == user.id diff --git a/backend/tests/unit/database/test_base_repository.py b/backend/tests/unit/database/test_base_repository.py new file mode 100644 index 0000000..0cdb410 --- /dev/null +++ b/backend/tests/unit/database/test_base_repository.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import String +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin +from core.db.base_repository import BaseRepository + + +class Widget(SoftDeleteMixin, Base): + __tablename__ = "widgets" + + id: Mapped[UUID] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50), nullable=False) + + +@pytest.fixture +async def db_engine(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest.fixture +async def db_session(db_engine): + async_session = async_sessionmaker( + bind=db_engine, + class_=AsyncSession, + expire_on_commit=False, + ) + async with async_session() as session: + yield session + await session.rollback() + + +@pytest.mark.asyncio +async def test_get_by_id_filters_soft_deleted(db_session: AsyncSession) -> None: + repository = BaseRepository(db_session, Widget) + widget_id = uuid4() + + widget = Widget(id=widget_id, name="widget") + db_session.add(widget) + await db_session.commit() + + found = await repository.get_by_id(widget_id) + assert found is not None + + deleted = await repository.soft_delete_by_id(widget_id) + assert deleted is not None + assert deleted.deleted_at is not None + + missing = await repository.get_by_id(widget_id) + assert missing is None + + +@pytest.mark.asyncio +async def test_soft_delete_sets_timestamp(db_session: AsyncSession) -> None: + repository = BaseRepository(db_session, Widget) + widget_id = uuid4() + + widget = Widget(id=widget_id, name="widget") + db_session.add(widget) + await db_session.commit() + + deleted = await repository.soft_delete_by_id(widget_id) + assert deleted is not None + assert isinstance(deleted.deleted_at, datetime) + deleted_at = deleted.deleted_at + if deleted_at.tzinfo is None: + deleted_at = deleted_at.replace(tzinfo=timezone.utc) + assert deleted_at <= datetime.now(timezone.utc) diff --git a/backend/tests/unit/database/test_profile_models.py b/backend/tests/unit/database/test_profile_models.py new file mode 100644 index 0000000..af68cd3 --- /dev/null +++ b/backend/tests/unit/database/test_profile_models.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from core.db.base import Base +from models.profile import Profile + + +@pytest.fixture +async def db_engine(): + """Create in-memory SQLite engine for testing.""" + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + echo=False, + ) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest.fixture +async def db_session(db_engine): + """Create a database session for testing.""" + async_session = async_sessionmaker( + bind=db_engine, + class_=AsyncSession, + expire_on_commit=False, + ) + async with async_session() as session: + yield session + await session.rollback() + + +@pytest.mark.asyncio +async def test_profile_model_create(db_session: AsyncSession) -> None: + """Test creating a Profile model.""" + profile_id = uuid4() + profile = Profile( + id=profile_id, + username="testuser", + display_name="Test User", + ) + db_session.add(profile) + await db_session.commit() + await db_session.refresh(profile) + + assert profile.id == profile_id + assert profile.username == "testuser" + assert profile.display_name == "Test User" + assert profile.created_at is not None + assert profile.updated_at is not None + assert profile.deleted_at is None + + +@pytest.mark.asyncio +async def test_profile_model_get_by_id(db_session: AsyncSession) -> None: + """Test retrieving a Profile by ID.""" + profile_id = uuid4() + profile = Profile( + id=profile_id, + username="testuser", + display_name="Test User", + ) + db_session.add(profile) + await db_session.commit() + + result = await db_session.get(Profile, profile_id) + assert result is not None + assert result.username == "testuser" + + +@pytest.mark.asyncio +async def test_profile_model_get_by_username(db_session: AsyncSession) -> None: + """Test retrieving a Profile by username.""" + profile = Profile( + id=uuid4(), + username="testuser", + display_name="Test User", + ) + db_session.add(profile) + await db_session.commit() + + result = await db_session.execute( + select(Profile).where(Profile.username == "testuser") + ) + found = result.scalar_one() + assert found is not None + assert found.username == "testuser" + + +@pytest.mark.asyncio +async def test_profile_model_update(db_session: AsyncSession) -> None: + """Test updating a Profile.""" + profile = Profile( + id=uuid4(), + username="testuser", + display_name="Test User", + bio="Old bio", + ) + db_session.add(profile) + await db_session.commit() + + profile.display_name = "Updated User" + profile.bio = "New bio" + await db_session.commit() + await db_session.refresh(profile) + + assert profile.display_name == "Updated User" + assert profile.bio == "New bio" diff --git a/backend/tests/unit/services/base/test_qdrant_service.py b/backend/tests/unit/services/base/test_qdrant_service.py new file mode 100644 index 0000000..9c506cb --- /dev/null +++ b/backend/tests/unit/services/base/test_qdrant_service.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import pytest + +from core.config.settings import QdrantSettings +from services.base.qdrant import QdrantService + + +class _FakeCollection: + def __init__(self, name: str) -> None: + self.name = name + + +class _FakeCollections: + def __init__(self) -> None: + self.collections = [_FakeCollection("default")] + + +class _FakeQdrantClient: + def get_collections(self) -> _FakeCollections: + return _FakeCollections() + + +@pytest.mark.asyncio +async def test_initialize_success(monkeypatch: pytest.MonkeyPatch) -> None: + service = QdrantService(settings=QdrantSettings(host="localhost", port=6333)) + + def _build_client(_: QdrantService) -> _FakeQdrantClient: + return _FakeQdrantClient() + + monkeypatch.setattr(QdrantService, "_build_client", _build_client) + + result = await service.initialize() + + assert result is True + assert service.is_initialized is True + + health = await service.health_check() + assert health["status"] == "healthy" + + +@pytest.mark.asyncio +async def test_initialize_failure(monkeypatch: pytest.MonkeyPatch) -> None: + service = QdrantService(settings=QdrantSettings(host="localhost", port=6333)) + + def _build_client(_: QdrantService) -> _FakeQdrantClient: + raise RuntimeError("boom") + + monkeypatch.setattr(QdrantService, "_build_client", _build_client) + + result = await service.initialize() + + assert result is False + assert service.is_initialized is False + + +@pytest.mark.asyncio +async def test_health_check_returns_unhealthy_when_not_initialized() -> None: + service = QdrantService(settings=QdrantSettings(host="localhost", port=6333)) + + health = await service.health_check() + + assert health["status"] == "unhealthy" + + +@pytest.mark.asyncio +async def test_close_is_idempotent() -> None: + service = QdrantService(settings=QdrantSettings(host="localhost", port=6333)) + + assert await service.close() is True + assert service.is_initialized is False + + +def test_get_client_raises_before_init() -> None: + service = QdrantService(settings=QdrantSettings(host="localhost", port=6333)) + + with pytest.raises(RuntimeError): + service.get_client() diff --git a/backend/tests/unit/services/base/test_redis_service.py b/backend/tests/unit/services/base/test_redis_service.py new file mode 100644 index 0000000..706d0fd --- /dev/null +++ b/backend/tests/unit/services/base/test_redis_service.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import pytest + +from core.config.settings import RedisSettings +from services.base.redis import RedisService + + +class _FakeRedisClient: + def __init__(self) -> None: + self.closed = False + + async def ping(self) -> bool: + return True + + async def info(self) -> dict[str, object]: + return { + "redis_version": "7.2", + "connected_clients": 1, + "used_memory_human": "1M", + "uptime_in_seconds": 10, + } + + async def aclose(self) -> None: + self.closed = True + + +@pytest.mark.asyncio +async def test_initialize_success(monkeypatch: pytest.MonkeyPatch) -> None: + service = RedisService(settings=RedisSettings(host="localhost", port=6379)) + + def _build_client(_: RedisService) -> _FakeRedisClient: + return _FakeRedisClient() + + monkeypatch.setattr(RedisService, "_build_client", _build_client) + + result = await service.initialize() + + assert result is True + assert service.is_initialized is True + + health = await service.health_check() + assert health["status"] == "healthy" + + +@pytest.mark.asyncio +async def test_initialize_failure(monkeypatch: pytest.MonkeyPatch) -> None: + service = RedisService(settings=RedisSettings(host="localhost", port=6379)) + + def _build_client(_: RedisService) -> _FakeRedisClient: + raise RuntimeError("boom") + + monkeypatch.setattr(RedisService, "_build_client", _build_client) + + result = await service.initialize() + + assert result is False + assert service.is_initialized is False + + +@pytest.mark.asyncio +async def test_close_is_idempotent() -> None: + service = RedisService(settings=RedisSettings(host="localhost", port=6379)) + + assert await service.close() is True + assert service.is_initialized is False + + +@pytest.mark.asyncio +async def test_health_check_uninitialized() -> None: + service = RedisService(settings=RedisSettings(host="localhost", port=6379)) + + health = await service.health_check() + + assert health["status"] == "unhealthy" + + +@pytest.mark.asyncio +async def test_close_closes_client(monkeypatch: pytest.MonkeyPatch) -> None: + service = RedisService(settings=RedisSettings(host="localhost", port=6379)) + client = _FakeRedisClient() + + def _build_client(_: RedisService) -> _FakeRedisClient: + return client + + monkeypatch.setattr(RedisService, "_build_client", _build_client) + + assert await service.initialize() is True + assert await service.close() is True + assert client.closed is True + assert service.is_initialized is False + + +def test_get_client_raises_before_init() -> None: + service = RedisService(settings=RedisSettings(host="localhost", port=6379)) + + with pytest.raises(RuntimeError): + service.get_client() diff --git a/backend/tests/unit/services/base/test_service_registry.py b/backend/tests/unit/services/base/test_service_registry.py new file mode 100644 index 0000000..f95250d --- /dev/null +++ b/backend/tests/unit/services/base/test_service_registry.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from services.base.service_interface import ( + BaseServiceProvider, + ServiceRegistry, + register_service, + register_service_instance, +) + + +class _DummyService(BaseServiceProvider): + def __init__(self, name: str = "dummy") -> None: + super().__init__(name) + + async def initialize(self, **_: object) -> bool: + self._set_initialized(True) + return True + + async def close(self) -> bool: + self._set_initialized(False) + return True + + async def health_check(self) -> dict[str, object]: + return {"status": "healthy", "details": {}} + + +def test_register_service_and_create_service() -> None: + @register_service("dummy-service") + class _RegisteredService(_DummyService): + pass + + created = ServiceRegistry.create_service("dummy-service") + + assert created is not None + assert created.get_service_info()["name"] == "dummy" + + +def test_register_service_instance_returns_same_instance() -> None: + instance = _DummyService("singleton") + + returned = register_service_instance("dummy-singleton", instance) + created = ServiceRegistry.create_service("dummy-singleton") + + assert returned is instance + assert created is instance + + +def test_create_service_returns_none_for_missing() -> None: + assert ServiceRegistry.create_service("missing-service") is None diff --git a/api/tests/unit/test_celery_logging.py b/backend/tests/unit/test_celery_logging.py similarity index 100% rename from api/tests/unit/test_celery_logging.py rename to backend/tests/unit/test_celery_logging.py diff --git a/api/tests/unit/test_logging_config.py b/backend/tests/unit/test_logging_config.py similarity index 100% rename from api/tests/unit/test_logging_config.py rename to backend/tests/unit/test_logging_config.py diff --git a/api/tests/unit/test_logging_filters.py b/backend/tests/unit/test_logging_filters.py similarity index 100% rename from api/tests/unit/test_logging_filters.py rename to backend/tests/unit/test_logging_filters.py diff --git a/api/tests/unit/test_logging_settings.py b/backend/tests/unit/test_logging_settings.py similarity index 100% rename from api/tests/unit/test_logging_settings.py rename to backend/tests/unit/test_logging_settings.py diff --git a/backend/tests/unit/test_response_envelope.py b/backend/tests/unit/test_response_envelope.py new file mode 100644 index 0000000..5e8ef20 --- /dev/null +++ b/backend/tests/unit/test_response_envelope.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from core.http.response import ProblemDetails, build_problem_details + + +def test_problem_details_defaults() -> None: + result = build_problem_details(status_code=401, detail="Unauthorized") + + assert isinstance(result, ProblemDetails) + assert result.type == "about:blank" + assert result.title == "Unauthorized" + assert result.status == 401 + assert result.detail == "Unauthorized" + assert result.instance is None + + +def test_problem_details_overrides() -> None: + result = build_problem_details( + status_code=409, + detail="Conflict", + type_value="https://example.com/problems/conflict", + title="Conflict", + instance="/api/mobile/auth/signup", + ) + + assert result.type == "https://example.com/problems/conflict" + assert result.title == "Conflict" + assert result.status == 409 + assert result.detail == "Conflict" + assert result.instance == "/api/mobile/auth/signup" diff --git a/backend/tests/unit/test_settings_supabase_env.py b/backend/tests/unit/test_settings_supabase_env.py new file mode 100644 index 0000000..9bfec42 --- /dev/null +++ b/backend/tests/unit/test_settings_supabase_env.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from pytest import MonkeyPatch + +from core.config.settings import Settings + + +def test_social_prefixed_supabase_env_populates_settings( + monkeypatch: MonkeyPatch, +) -> None: + monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_SCHEME", "https") + monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_HOST", "public.example") + monkeypatch.setenv("SOCIAL_SUPABASE__KONG_HTTP_PORT", "8443") + monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key") + monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key") + monkeypatch.setenv("SOCIAL_SUPABASE__JWT_SECRET", "jwt-secret") + monkeypatch.setenv("SOCIAL_DATABASE__HOST", "db") + monkeypatch.setenv("SOCIAL_DATABASE__PORT", "5432") + monkeypatch.setenv("SOCIAL_DATABASE__NAME", "app") + monkeypatch.setenv("SOCIAL_DATABASE__USER", "user") + monkeypatch.setenv("SOCIAL_DATABASE__PASSWORD", "pass") + + settings = Settings() + + assert settings.supabase.public_url == "https://public.example:8443" + assert settings.supabase.api_external_url == "https://public.example:8443" + assert settings.supabase.anon_key == "anon-key" + assert settings.supabase.service_role_key == "service-key" + assert settings.supabase.jwt_secret == "jwt-secret" + + supabase_settings = settings.model_dump()["supabase"] + assert supabase_settings["public_url"] == "https://public.example:8443" + assert supabase_settings["api_external_url"] == "https://public.example:8443" + assert supabase_settings["anon_key"] == "anon-key" + assert supabase_settings["service_role_key"] == "service-key" + assert supabase_settings["jwt_secret"] == "jwt-secret" + assert settings.database_url == "postgresql+asyncpg://user:pass@db:5432/app" + + +def test_social_prefixed_api_external_url_is_loaded( + monkeypatch: MonkeyPatch, +) -> None: + monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_SCHEME", "https") + monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_HOST", "api.example") + monkeypatch.setenv("SOCIAL_SUPABASE__KONG_HTTP_PORT", "8443") + + settings = Settings() + + assert settings.supabase.api_external_url == "https://api.example:8443" diff --git a/backend/tests/unit/v1/auth/test_auth_models.py b/backend/tests/unit/v1/auth/test_auth_models.py new file mode 100644 index 0000000..8123e27 --- /dev/null +++ b/backend/tests/unit/v1/auth/test_auth_models.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from v1.auth.models import ( + AuthTokenResponse, + AuthUser, + LoginRequest, + RefreshRequest, + SignupRequest, +) + + +def test_signup_requires_valid_email() -> None: + with pytest.raises(ValidationError): + SignupRequest(email="not-an-email", password="secret123") + + +def test_login_requires_valid_email() -> None: + with pytest.raises(ValidationError): + LoginRequest(email="invalid", password="secret123") + + +def test_refresh_requires_token() -> None: + with pytest.raises(ValidationError): + RefreshRequest(refresh_token="") + + +def test_auth_token_response_maps_user() -> None: + user = AuthUser(id="user-1", email="user@example.com") + response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + + assert response.user.id == "user-1" + assert response.user.email == "user@example.com" diff --git a/backend/tests/unit/v1/auth/test_auth_service.py b/backend/tests/unit/v1/auth/test_auth_service.py new file mode 100644 index 0000000..b42690c --- /dev/null +++ b/backend/tests/unit/v1/auth/test_auth_service.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import pytest + +from v1.auth.models import ( + AuthTokenResponse, + AuthUser, + LoginRequest, + RefreshRequest, + SignupRequest, +) +from v1.auth.service import AuthService, AuthServiceGateway + + +class FakeGateway(AuthServiceGateway): + def __init__(self, response: AuthTokenResponse) -> None: + self._response = response + + async def signup(self, request: SignupRequest) -> AuthTokenResponse: + return self._response + + async def login(self, request: LoginRequest) -> AuthTokenResponse: + return self._response + + async def refresh(self, request: RefreshRequest) -> AuthTokenResponse: + return self._response + + async def logout(self, refresh_token: str | None) -> None: + return None + + +@pytest.mark.asyncio +async def test_signup_maps_response() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + service = AuthService(gateway=FakeGateway(token_response)) + + result = await service.signup( + SignupRequest(email="user@example.com", password="secret123") + ) + + assert result.access_token == "access" + assert result.refresh_token == "refresh" + assert result.user.id == "user-1" + + +class LogoutAssertingGateway(AuthServiceGateway): + def __init__(self, expected_refresh_token: str) -> None: + self._expected_refresh_token = expected_refresh_token + + async def signup(self, request: SignupRequest) -> AuthTokenResponse: + raise NotImplementedError + + async def login(self, request: LoginRequest) -> AuthTokenResponse: + raise NotImplementedError + + async def refresh(self, request: RefreshRequest) -> AuthTokenResponse: + raise NotImplementedError + + async def logout(self, refresh_token: str | None) -> None: + assert refresh_token == self._expected_refresh_token + + +@pytest.mark.asyncio +async def test_logout_forwards_refresh_token() -> None: + service = AuthService(gateway=LogoutAssertingGateway("refresh-token")) + + await service.logout("refresh-token") diff --git a/backend/tests/unit/v1/profile/test_profile_dependencies.py b/backend/tests/unit/v1/profile/test_profile_dependencies.py new file mode 100644 index 0000000..3b4c0e5 --- /dev/null +++ b/backend/tests/unit/v1/profile/test_profile_dependencies.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import time +from typing import Any +from uuid import UUID + +import jwt +import pytest +from fastapi import HTTPException + +from core.auth.models import CurrentUser +from v1.profile.dependencies import get_current_user + + +class TestGetCurrentUser: + """Tests for JWT validation in get_current_user dependency.""" + + @pytest.fixture + def jwt_secret(self) -> str: + return "super-secret-jwt-token-with-at-least-32-characters" + + @pytest.fixture + def valid_user_id(self) -> str: + return "00000000-0000-0000-0000-000000000123" + + @pytest.fixture + def valid_payload(self, valid_user_id: str) -> dict[str, Any]: + """Valid JWT payload with all required claims.""" + now = int(time.time()) + return { + "sub": valid_user_id, + "aud": "authenticated", + "iss": "http://localhost:8001/auth/v1", + "exp": now + 3600, # 1 hour from now + "iat": now, + } + + def _create_token(self, payload: dict[str, Any], secret: str) -> str: + return jwt.encode(payload, secret, algorithm="HS256") + + def test_valid_token_returns_current_user( + self, + jwt_secret: str, + valid_payload: dict[str, Any], + valid_user_id: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Valid JWT with correct aud/iss/exp should return CurrentUser.""" + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_scheme", + "http", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_host", + "localhost", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.kong_http_port", + 8001, + ) + + token = self._create_token(valid_payload, jwt_secret) + authorization = f"Bearer {token}" + + result = get_current_user(authorization=authorization) + + assert isinstance(result, CurrentUser) + assert result.id == UUID(valid_user_id) + + def test_missing_authorization_raises_401(self) -> None: + """Missing Authorization header should raise 401.""" + with pytest.raises(HTTPException) as exc_info: + get_current_user(authorization=None) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Unauthorized" + + def test_invalid_scheme_raises_401(self) -> None: + """Non-Bearer scheme should raise 401.""" + with pytest.raises(HTTPException) as exc_info: + get_current_user(authorization="Basic dXNlcjpwYXNz") + + assert exc_info.value.status_code == 401 + + def test_expired_token_raises_401( + self, + jwt_secret: str, + valid_payload: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Expired JWT should raise 401.""" + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_scheme", + "http", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_host", + "localhost", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.kong_http_port", + 8001, + ) + + valid_payload["exp"] = int(time.time()) - 3600 # 1 hour ago + token = self._create_token(valid_payload, jwt_secret) + + with pytest.raises(HTTPException) as exc_info: + get_current_user(authorization=f"Bearer {token}") + + assert exc_info.value.status_code == 401 + + def test_invalid_audience_raises_401( + self, + jwt_secret: str, + valid_payload: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """JWT with wrong audience should raise 401.""" + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_scheme", + "http", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_host", + "localhost", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.kong_http_port", + 8001, + ) + + valid_payload["aud"] = "wrong-audience" + token = self._create_token(valid_payload, jwt_secret) + + with pytest.raises(HTTPException) as exc_info: + get_current_user(authorization=f"Bearer {token}") + + assert exc_info.value.status_code == 401 + + def test_invalid_issuer_raises_401( + self, + jwt_secret: str, + valid_payload: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """JWT with wrong issuer should raise 401.""" + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_scheme", + "http", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_host", + "localhost", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.kong_http_port", + 8001, + ) + + valid_payload["iss"] = "http://malicious-site.com/auth/v1" + token = self._create_token(valid_payload, jwt_secret) + + with pytest.raises(HTTPException) as exc_info: + get_current_user(authorization=f"Bearer {token}") + + assert exc_info.value.status_code == 401 + + def test_missing_subject_raises_401( + self, + jwt_secret: str, + valid_payload: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """JWT without 'sub' claim should raise 401.""" + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_scheme", + "http", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_host", + "localhost", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.kong_http_port", + 8001, + ) + + del valid_payload["sub"] + token = self._create_token(valid_payload, jwt_secret) + + with pytest.raises(HTTPException) as exc_info: + get_current_user(authorization=f"Bearer {token}") + + assert exc_info.value.status_code == 401 + + def test_wrong_secret_raises_401( + self, + jwt_secret: str, + valid_payload: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """JWT signed with wrong secret should raise 401.""" + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_scheme", + "http", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_host", + "localhost", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.kong_http_port", + 8001, + ) + + token = self._create_token( + valid_payload, "wrong-secret-key-that-is-long-enough" + ) + + with pytest.raises(HTTPException) as exc_info: + get_current_user(authorization=f"Bearer {token}") + + assert exc_info.value.status_code == 401 + + def test_jwt_secret_not_configured_raises_503( + self, valid_payload: dict[str, Any], monkeypatch: pytest.MonkeyPatch + ) -> None: + """Missing JWT secret in config should raise 503.""" + monkeypatch.setattr("v1.profile.dependencies.config.supabase.jwt_secret", None) + + with pytest.raises(HTTPException) as exc_info: + get_current_user(authorization="Bearer some-token") + + assert exc_info.value.status_code == 503 + assert exc_info.value.detail == "JWT secret not configured" + + def test_invalid_uuid_in_subject_raises_401( + self, + jwt_secret: str, + valid_payload: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """JWT with non-UUID 'sub' claim should raise 401.""" + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_scheme", + "http", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.public_host", + "localhost", + ) + monkeypatch.setattr( + "v1.profile.dependencies.config.supabase.kong_http_port", + 8001, + ) + + valid_payload["sub"] = "not-a-valid-uuid" + token = self._create_token(valid_payload, jwt_secret) + + with pytest.raises(HTTPException) as exc_info: + get_current_user(authorization=f"Bearer {token}") + + assert exc_info.value.status_code == 401 diff --git a/backend/tests/unit/v1/profile/test_profile_service.py b/backend/tests/unit/v1/profile/test_profile_service.py new file mode 100644 index 0000000..598e33f --- /dev/null +++ b/backend/tests/unit/v1/profile/test_profile_service.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID + +import pytest +from fastapi import HTTPException + +from core.auth.models import CurrentUser +from models.profile import Profile +from v1.profile.repository import ProfileRepository +from v1.profile.schemas import ProfileUpdateRequest +from v1.profile.service import ProfileService + + +def _create_mock_profile( + user_id: UUID = UUID("00000000-0000-0000-0000-000000000001"), + username: str = "demo", + display_name: str | None = "Demo User", + avatar_url: str | None = None, + bio: str | None = None, +) -> Profile: + """Create a mock Profile ORM object.""" + profile = MagicMock(spec=Profile) + profile.id = user_id + profile.username = username + profile.display_name = display_name + profile.avatar_url = avatar_url + profile.bio = bio + return profile + + +class FakeRepo: + """Fake repository for testing that conforms to ProfileRepository protocol.""" + + def __init__(self, profile: Profile | None) -> None: + self._profile = profile + + async def get_by_user_id(self, user_id: UUID) -> Profile | None: + if self._profile and user_id == self._profile.id: + return self._profile + return None + + async def get_by_username(self, username: str) -> Profile | None: + if self._profile and username == self._profile.username: + return self._profile + return None + + async def update_by_user_id( + self, user_id: UUID, update_data: dict[str, str | None] + ) -> Profile | None: + if not self._profile or user_id != self._profile.id: + return None + # Apply updates to mock + for key, value in update_data.items(): + if hasattr(self._profile, key): + setattr(self._profile, key, value) + return self._profile + + +# Verify FakeRepo implements the protocol +_repo_check: ProfileRepository = FakeRepo(None) + + +@pytest.fixture +def mock_session() -> AsyncMock: + """Create a mock AsyncSession.""" + session = AsyncMock() + session.commit = AsyncMock() + session.rollback = AsyncMock() + return session + + +@pytest.mark.asyncio +async def test_get_me_returns_profile(mock_session: AsyncMock) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + profile = _create_mock_profile(user_id=user_id, username="demo") + user = CurrentUser(id=user_id) + service = ProfileService( + repository=FakeRepo(profile), + session=mock_session, + current_user=user, + ) + + result = await service.get_me() + + assert result.username == "demo" + assert result.id == str(user_id) + + +@pytest.mark.asyncio +async def test_get_me_not_found_raises_404(mock_session: AsyncMock) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + user = CurrentUser(id=user_id) + service = ProfileService( + repository=FakeRepo(None), + session=mock_session, + current_user=user, + ) + + with pytest.raises(HTTPException) as exc_info: + await service.get_me() + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_update_me_updates_fields(mock_session: AsyncMock) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + profile = _create_mock_profile(user_id=user_id, username="demo") + user = CurrentUser(id=user_id) + service = ProfileService( + repository=FakeRepo(profile), + session=mock_session, + current_user=user, + ) + + result = await service.update_me(ProfileUpdateRequest(display_name="Updated")) + + assert result.display_name == "Updated" + mock_session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_update_me_no_fields_raises_400(mock_session: AsyncMock) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + profile = _create_mock_profile(user_id=user_id) + user = CurrentUser(id=user_id) + service = ProfileService( + repository=FakeRepo(profile), + session=mock_session, + current_user=user, + ) + + # Create a request with all None values by bypassing validation + update = MagicMock(spec=ProfileUpdateRequest) + update.display_name = None + update.avatar_url = None + update.bio = None + + with pytest.raises(HTTPException) as exc_info: + await service.update_me(update) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_get_by_username_returns_profile(mock_session: AsyncMock) -> None: + profile = _create_mock_profile(username="demo") + service = ProfileService( + repository=FakeRepo(profile), + session=mock_session, + current_user=CurrentUser(id=UUID("00000000-0000-0000-0000-000000000001")), + ) + + result = await service.get_by_username("demo") + + assert result.username == "demo" + + +@pytest.mark.asyncio +async def test_get_by_username_not_found_raises_404(mock_session: AsyncMock) -> None: + service = ProfileService( + repository=FakeRepo(None), + session=mock_session, + current_user=CurrentUser(id=UUID("00000000-0000-0000-0000-000000000001")), + ) + + with pytest.raises(HTTPException) as exc_info: + await service.get_by_username("unknown") + + assert exc_info.value.status_code == 404 diff --git a/backend/tests/unit/v1/profile/test_schemas.py b/backend/tests/unit/v1/profile/test_schemas.py new file mode 100644 index 0000000..f7ee8a8 --- /dev/null +++ b/backend/tests/unit/v1/profile/test_schemas.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest + + +def test_profile_response_maps_fields() -> None: + response = ProfileResponse( + id="user-1", + username="demo", + display_name="Demo User", + avatar_url=None, + bio=None, + ) + + assert response.id == "user-1" + assert response.username == "demo" + + +def test_profile_update_requires_one_field() -> None: + with pytest.raises(ValidationError): + ProfileUpdateRequest() + + +def test_profile_update_accepts_valid_https_url() -> None: + request = ProfileUpdateRequest(avatar_url="https://example.com/avatar.png") + assert request.avatar_url == "https://example.com/avatar.png" + + +def test_profile_update_accepts_valid_http_url() -> None: + request = ProfileUpdateRequest( + avatar_url="http://localhost:8001/storage/avatar.png" + ) + assert request.avatar_url == "http://localhost:8001/storage/avatar.png" + + +def test_profile_update_rejects_invalid_url() -> None: + with pytest.raises(ValidationError) as exc_info: + ProfileUpdateRequest(avatar_url="not-a-valid-url") + + errors = exc_info.value.errors() + assert len(errors) == 1 + assert "avatar_url" in str(errors[0]["loc"]) + + +def test_profile_update_rejects_javascript_url() -> None: + with pytest.raises(ValidationError): + ProfileUpdateRequest(avatar_url="javascript:alert('xss')") + + +def test_profile_update_rejects_data_url() -> None: + with pytest.raises(ValidationError): + ProfileUpdateRequest(avatar_url="data:text/html,") + + +def test_profile_update_accepts_none_avatar_url_with_other_field() -> None: + request = ProfileUpdateRequest(display_name="Test", avatar_url=None) + assert request.avatar_url is None + assert request.display_name == "Test" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml deleted file mode 100644 index 708a6db..0000000 --- a/docker/docker-compose.yml +++ /dev/null @@ -1,457 +0,0 @@ -name: social-app-local - -services: - redis: - image: redis:7-alpine - container_name: social-local-redis - restart: unless-stopped - ports: - - "6379:6379" - volumes: - - redis_data:/data - command: redis-server --appendonly yes - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 3s - retries: 5 - - qdrant: - image: qdrant/qdrant:latest - container_name: social-local-qdrant - restart: unless-stopped - ports: - - "6333:6333" - - "6334:6334" - volumes: - - qdrant_data:/qdrant/storage - - studio: - container_name: supabase-studio - image: supabase/studio:2026.01.27-sha-6aa59ff - restart: unless-stopped - healthcheck: - test: - [ - "CMD", - "node", - "-e", - "fetch('http://studio:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})", - ] - timeout: 10s - interval: 5s - retries: 3 - depends_on: - analytics: - condition: service_healthy - environment: - HOSTNAME: "::" - STUDIO_PG_META_URL: http://meta:8080 - POSTGRES_PORT: ${SOCIAL_INFRA__POSTGRES__PORT} - POSTGRES_HOST: ${SOCIAL_INFRA__POSTGRES__HOST} - POSTGRES_DB: ${SOCIAL_INFRA__POSTGRES__DB} - POSTGRES_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD} - PG_META_CRYPTO_KEY: ${SOCIAL_INFRA__PG_META__CRYPTO_KEY} - DEFAULT_ORGANIZATION_NAME: ${SOCIAL_INFRA__STUDIO__DEFAULT_ORGANIZATION} - DEFAULT_PROJECT_NAME: ${SOCIAL_INFRA__STUDIO__DEFAULT_PROJECT} - OPENAI_API_KEY: ${SOCIAL_INFRA__OPENAI__API_KEY:-} - SUPABASE_URL: http://kong:8000 - SUPABASE_PUBLIC_URL: ${SOCIAL_INFRA__SUPABASE__PUBLIC_URL} - SUPABASE_ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY} - SUPABASE_SERVICE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY} - AUTH_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET} - LOGFLARE_API_KEY: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN} - LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN} - LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PRIVATE_ACCESS_TOKEN} - LOGFLARE_URL: http://analytics:4000 - NEXT_PUBLIC_ENABLE_LOGS: true - NEXT_ANALYTICS_BACKEND_PROVIDER: postgres - SNIPPETS_MANAGEMENT_FOLDER: /app/snippets - volumes: - - ./supabase/volumes/snippets:/app/snippets:Z - - kong: - container_name: supabase-kong - image: kong:2.8.1 - restart: unless-stopped - ports: - - ${SOCIAL_INFRA__KONG__HTTP_PORT}:8000/tcp - - ${SOCIAL_INFRA__KONG__HTTPS_PORT}:8443/tcp - volumes: - - ./supabase/volumes/api/kong.yml:/home/kong/temp.yml:ro,z - depends_on: - analytics: - condition: service_healthy - environment: - KONG_DATABASE: "off" - KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml - KONG_DNS_ORDER: LAST,A,CNAME - KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction - KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k - KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k - SUPABASE_ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY} - SUPABASE_SERVICE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY} - DASHBOARD_USERNAME: ${SOCIAL_INFRA__DASHBOARD__USERNAME} - DASHBOARD_PASSWORD: ${SOCIAL_INFRA__DASHBOARD__PASSWORD} - entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' - - auth: - container_name: supabase-auth - image: supabase/gotrue:v2.185.0 - restart: unless-stopped - healthcheck: - test: - [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://localhost:9999/health", - ] - timeout: 5s - interval: 5s - retries: 3 - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - environment: - GOTRUE_API_HOST: 0.0.0.0 - GOTRUE_API_PORT: 9999 - API_EXTERNAL_URL: ${SOCIAL_INFRA__API_EXTERNAL_URL} - GOTRUE_DB_DRIVER: postgres - GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB} - GOTRUE_SITE_URL: ${SOCIAL_INFRA__SITE__URL} - GOTRUE_URI_ALLOW_LIST: ${SOCIAL_INFRA__ADDITIONAL_REDIRECT_URLS} - GOTRUE_DISABLE_SIGNUP: ${SOCIAL_INFRA__AUTH__DISABLE_SIGNUP} - GOTRUE_JWT_ADMIN_ROLES: service_role - GOTRUE_JWT_AUD: authenticated - GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated - GOTRUE_JWT_EXP: ${SOCIAL_INFRA__JWT__EXPIRY} - GOTRUE_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET} - GOTRUE_EXTERNAL_EMAIL_ENABLED: ${SOCIAL_INFRA__EMAIL__ENABLE_SIGNUP} - GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${SOCIAL_INFRA__AUTH__ENABLE_ANONYMOUS_USERS} - GOTRUE_MAILER_AUTOCONFIRM: ${SOCIAL_INFRA__EMAIL__ENABLE_AUTOCONFIRM} - GOTRUE_SMTP_ADMIN_EMAIL: ${SOCIAL_INFRA__SMTP__ADMIN_EMAIL} - GOTRUE_SMTP_HOST: ${SOCIAL_INFRA__SMTP__HOST} - GOTRUE_SMTP_PORT: ${SOCIAL_INFRA__SMTP__PORT} - GOTRUE_SMTP_USER: ${SOCIAL_INFRA__SMTP__USER} - GOTRUE_SMTP_PASS: ${SOCIAL_INFRA__SMTP__PASS} - GOTRUE_SMTP_SENDER_NAME: ${SOCIAL_INFRA__SMTP__SENDER_NAME} - GOTRUE_MAILER_URLPATHS_INVITE: ${SOCIAL_INFRA__MAILER__URLPATHS_INVITE} - GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${SOCIAL_INFRA__MAILER__URLPATHS_CONFIRMATION} - GOTRUE_MAILER_URLPATHS_RECOVERY: ${SOCIAL_INFRA__MAILER__URLPATHS_RECOVERY} - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${SOCIAL_INFRA__MAILER__URLPATHS_EMAIL_CHANGE} - GOTRUE_EXTERNAL_PHONE_ENABLED: ${SOCIAL_INFRA__AUTH__ENABLE_PHONE_SIGNUP} - GOTRUE_SMS_AUTOCONFIRM: ${SOCIAL_INFRA__AUTH__ENABLE_PHONE_AUTOCONFIRM} - - rest: - container_name: supabase-rest - image: postgrest/postgrest:v14.3 - restart: unless-stopped - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - environment: - PGRST_DB_URI: postgres://authenticator:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB} - PGRST_DB_SCHEMAS: ${SOCIAL_INFRA__PGRST__DB_SCHEMAS} - PGRST_DB_ANON_ROLE: anon - PGRST_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET} - PGRST_DB_USE_LEGACY_GUCS: "false" - PGRST_APP_SETTINGS_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET} - PGRST_APP_SETTINGS_JWT_EXP: ${SOCIAL_INFRA__JWT__EXPIRY} - command: ["postgrest"] - - realtime: - container_name: realtime-dev.supabase-realtime - image: supabase/realtime:v2.72.0 - restart: unless-stopped - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - healthcheck: - test: - [ - "CMD-SHELL", - 'curl -sSfL --head -o /dev/null -H "Authorization: Bearer ${SOCIAL_INFRA__SUPABASE__ANON_KEY}" http://localhost:4000/api/tenants/realtime-dev/health', - ] - timeout: 5s - interval: 30s - retries: 3 - start_period: 10s - environment: - PORT: 4000 - DB_HOST: ${SOCIAL_INFRA__POSTGRES__HOST} - DB_PORT: ${SOCIAL_INFRA__POSTGRES__PORT} - DB_USER: supabase_admin - DB_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD} - DB_NAME: ${SOCIAL_INFRA__POSTGRES__DB} - DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime" - DB_ENC_KEY: supabaserealtime - API_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET} - ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY} - SECRET_KEY_BASE: ${SOCIAL_INFRA__SUPAVISOR__SECRET_KEY_BASE} - ERL_AFLAGS: -proto_dist inet_tcp - DNS_NODES: "''" - RLIMIT_NOFILE: "10000" - APP_NAME: realtime - SEED_SELF_HOST: "true" - RUN_JANITOR: "true" - DISABLE_HEALTHCHECK_LOGGING: "true" - - storage: - container_name: supabase-storage - image: supabase/storage-api:v1.33.5 - restart: unless-stopped - volumes: - - ./supabase/volumes/storage:/var/lib/storage:z - healthcheck: - test: - [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://storage:5000/status", - ] - timeout: 5s - interval: 5s - retries: 3 - depends_on: - db: - condition: service_healthy - rest: - condition: service_started - imgproxy: - condition: service_started - environment: - ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY} - SERVICE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY} - POSTGREST_URL: http://rest:3000 - PGRST_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET} - DATABASE_URL: postgres://supabase_storage_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB} - REQUEST_ALLOW_X_FORWARDED_PATH: "true" - FILE_SIZE_LIMIT: 52428800 - STORAGE_BACKEND: file - FILE_STORAGE_BACKEND_PATH: /var/lib/storage - TENANT_ID: stub - REGION: stub - GLOBAL_S3_BUCKET: stub - ENABLE_IMAGE_TRANSFORMATION: "true" - IMGPROXY_URL: http://imgproxy:5001 - - imgproxy: - container_name: supabase-imgproxy - image: darthsim/imgproxy:v3.30.1 - restart: unless-stopped - volumes: - - ./supabase/volumes/storage:/var/lib/storage:z - healthcheck: - test: ["CMD", "imgproxy", "health"] - timeout: 5s - interval: 5s - retries: 3 - environment: - IMGPROXY_BIND: ":5001" - IMGPROXY_LOCAL_FILESYSTEM_ROOT: / - IMGPROXY_USE_ETAG: "true" - IMGPROXY_ENABLE_WEBP_DETECTION: ${SOCIAL_INFRA__IMGPROXY__ENABLE_WEBP_DETECTION} - IMGPROXY_MAX_SRC_RESOLUTION: 16.8 - - meta: - container_name: supabase-meta - image: supabase/postgres-meta:v0.95.2 - restart: unless-stopped - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - environment: - PG_META_PORT: 8080 - PG_META_DB_HOST: ${SOCIAL_INFRA__POSTGRES__HOST} - PG_META_DB_PORT: ${SOCIAL_INFRA__POSTGRES__PORT} - PG_META_DB_NAME: ${SOCIAL_INFRA__POSTGRES__DB} - PG_META_DB_USER: supabase_admin - PG_META_DB_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD} - CRYPTO_KEY: ${SOCIAL_INFRA__PG_META__CRYPTO_KEY} - - functions: - container_name: supabase-edge-functions - image: supabase/edge-runtime:v1.70.0 - restart: unless-stopped - volumes: - - ./supabase/volumes/functions:/home/deno/functions:Z - depends_on: - analytics: - condition: service_healthy - environment: - JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET} - SUPABASE_URL: http://kong:8000 - SUPABASE_ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY} - SUPABASE_SERVICE_ROLE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY} - SUPABASE_DB_URL: postgresql://postgres:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB} - VERIFY_JWT: "${SOCIAL_INFRA__FUNCTIONS__VERIFY_JWT}" - command: ["start", "--main-service", "/home/deno/functions/main"] - - analytics: - container_name: supabase-analytics - image: supabase/logflare:1.30.3 - restart: unless-stopped - ports: - - 4000:4000 - healthcheck: - test: ["CMD", "curl", "http://localhost:4000/health"] - timeout: 5s - interval: 5s - retries: 10 - depends_on: - db: - condition: service_healthy - environment: - LOGFLARE_NODE_HOST: 127.0.0.1 - DB_USERNAME: supabase_admin - DB_DATABASE: _supabase - DB_HOSTNAME: ${SOCIAL_INFRA__POSTGRES__HOST} - DB_PORT: ${SOCIAL_INFRA__POSTGRES__PORT} - DB_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD} - DB_SCHEMA: _analytics - LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN} - LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PRIVATE_ACCESS_TOKEN} - LOGFLARE_SINGLE_TENANT: true - LOGFLARE_SUPABASE_MODE: true - POSTGRES_BACKEND_URL: postgresql://supabase_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/_supabase - POSTGRES_BACKEND_SCHEMA: _analytics - LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true - - db: - container_name: supabase-db - image: supabase/postgres:15.8.1.085 - restart: unless-stopped - volumes: - - ./supabase/volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z - - ./supabase/volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z - - ./supabase/volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z - - ./supabase/volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z - - ./supabase/volumes/db/data:/var/lib/postgresql/data:Z - - ./supabase/volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z - - ./supabase/volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z - - ./supabase/volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z - - db-config:/etc/postgresql-custom - healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"] - interval: 5s - timeout: 5s - retries: 10 - depends_on: - vector: - condition: service_healthy - environment: - POSTGRES_HOST: /var/run/postgresql - PGPORT: ${SOCIAL_INFRA__POSTGRES__PORT} - POSTGRES_PORT: ${SOCIAL_INFRA__POSTGRES__PORT} - PGPASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD} - POSTGRES_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD} - PGDATABASE: ${SOCIAL_INFRA__POSTGRES__DB} - POSTGRES_DB: ${SOCIAL_INFRA__POSTGRES__DB} - JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET} - JWT_EXP: ${SOCIAL_INFRA__JWT__EXPIRY} - command: - [ - "postgres", - "-c", - "config_file=/etc/postgresql/postgresql.conf", - "-c", - "log_min_messages=fatal", - ] - - vector: - container_name: supabase-vector - image: timberio/vector:0.28.1-alpine - restart: unless-stopped - volumes: - - ./supabase/volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z - - ${SOCIAL_INFRA__DOCKER__SOCKET_LOCATION}:/var/run/docker.sock:ro,z - healthcheck: - test: - [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://vector:9001/health", - ] - timeout: 5s - interval: 5s - retries: 3 - environment: - LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN} - command: ["--config", "/etc/vector/vector.yml"] - security_opt: - - "label=disable" - - supavisor: - container_name: supabase-pooler - image: supabase/supavisor:2.7.4 - restart: unless-stopped - ports: - - ${SOCIAL_INFRA__POSTGRES__PORT}:5432 - - ${SOCIAL_INFRA__POOLER__PROXY_PORT_TRANSACTION}:6543 - volumes: - - ./supabase/volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z - healthcheck: - test: - [ - "CMD", - "curl", - "-sSfL", - "--head", - "-o", - "/dev/null", - "http://127.0.0.1:4000/api/health", - ] - interval: 10s - timeout: 5s - retries: 5 - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - environment: - PORT: 4000 - POSTGRES_PORT: ${SOCIAL_INFRA__POSTGRES__PORT} - POSTGRES_DB: ${SOCIAL_INFRA__POSTGRES__DB} - POSTGRES_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD} - DATABASE_URL: ecto://supabase_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/_supabase - CLUSTER_POSTGRES: true - SECRET_KEY_BASE: ${SOCIAL_INFRA__SUPAVISOR__SECRET_KEY_BASE} - VAULT_ENC_KEY: ${SOCIAL_INFRA__SUPAVISOR__VAULT_ENC_KEY} - API_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET} - METRICS_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET} - REGION: local - ERL_AFLAGS: -proto_dist inet_tcp - POOLER_TENANT_ID: ${SOCIAL_INFRA__POOLER__TENANT_ID} - POOLER_DEFAULT_POOL_SIZE: ${SOCIAL_INFRA__POOLER__DEFAULT_POOL_SIZE} - POOLER_MAX_CLIENT_CONN: ${SOCIAL_INFRA__POOLER__MAX_CLIENT_CONN} - POOLER_POOL_MODE: transaction - DB_POOL_SIZE: ${SOCIAL_INFRA__POOLER__DB_POOL_SIZE} - command: - [ - "/bin/sh", - "-c", - '/app/bin/migrate && /app/bin/supavisor eval "$$(cat /etc/pooler/pooler.exs)" && /app/bin/server', - ] - -volumes: - redis_data: - qdrant_data: - db-config: diff --git a/docker/supabase/volumes/api/kong.yml b/docker/supabase/volumes/api/kong.yml deleted file mode 100644 index 2deb345..0000000 --- a/docker/supabase/volumes/api/kong.yml +++ /dev/null @@ -1,284 +0,0 @@ -_format_version: '2.1' -_transform: true - -### -### Consumers / Users -### -consumers: - - username: DASHBOARD - - username: anon - keyauth_credentials: - - key: $SUPABASE_ANON_KEY - - username: service_role - keyauth_credentials: - - key: $SUPABASE_SERVICE_KEY - -### -### Access Control List -### -acls: - - consumer: anon - group: anon - - consumer: service_role - group: admin - -### -### Dashboard credentials -### -basicauth_credentials: - - consumer: DASHBOARD - username: '$DASHBOARD_USERNAME' - password: '$DASHBOARD_PASSWORD' - -### -### API Routes -### -services: - ## Open Auth routes - - name: auth-v1-open - url: http://auth:9999/verify - routes: - - name: auth-v1-open - strip_path: true - paths: - - /auth/v1/verify - plugins: - - name: cors - - name: auth-v1-open-callback - url: http://auth:9999/callback - routes: - - name: auth-v1-open-callback - strip_path: true - paths: - - /auth/v1/callback - plugins: - - name: cors - - name: auth-v1-open-authorize - url: http://auth:9999/authorize - routes: - - name: auth-v1-open-authorize - strip_path: true - paths: - - /auth/v1/authorize - plugins: - - name: cors - - ## Secure Auth routes - - name: auth-v1 - _comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*' - url: http://auth:9999/ - routes: - - name: auth-v1-all - strip_path: true - paths: - - /auth/v1/ - plugins: - - name: cors - - name: key-auth - config: - hide_credentials: false - - name: acl - config: - hide_groups_header: true - allow: - - admin - - anon - - ## Secure REST routes - - name: rest-v1 - _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' - url: http://rest:3000/ - routes: - - name: rest-v1-all - strip_path: true - paths: - - /rest/v1/ - plugins: - - name: cors - - name: key-auth - config: - hide_credentials: true - - name: acl - config: - hide_groups_header: true - allow: - - admin - - anon - - ## Secure GraphQL routes - - name: graphql-v1 - _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql' - url: http://rest:3000/rpc/graphql - routes: - - name: graphql-v1-all - strip_path: true - paths: - - /graphql/v1 - plugins: - - name: cors - - name: key-auth - config: - hide_credentials: true - - name: request-transformer - config: - add: - headers: - - Content-Profile:graphql_public - - name: acl - config: - hide_groups_header: true - allow: - - admin - - anon - - ## Secure Realtime routes - - name: realtime-v1-ws - _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' - url: http://realtime-dev.supabase-realtime:4000/socket - protocol: ws - routes: - - name: realtime-v1-ws - strip_path: true - paths: - - /realtime/v1/ - plugins: - - name: cors - - name: key-auth - config: - hide_credentials: false - - name: acl - config: - hide_groups_header: true - allow: - - admin - - anon - - name: realtime-v1-rest - _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' - url: http://realtime-dev.supabase-realtime:4000/api - protocol: http - routes: - - name: realtime-v1-rest - strip_path: true - paths: - - /realtime/v1/api - plugins: - - name: cors - - name: key-auth - config: - hide_credentials: false - - name: acl - config: - hide_groups_header: true - allow: - - admin - - anon - - ## Storage routes: the storage server manages its own auth - - name: storage-v1 - _comment: 'Storage: /storage/v1/* -> http://storage:5000/*' - url: http://storage:5000/ - routes: - - name: storage-v1-all - strip_path: true - paths: - - /storage/v1/ - plugins: - - name: cors - - ## Edge Functions routes - - name: functions-v1 - _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' - url: http://functions:9000/ - routes: - - name: functions-v1-all - strip_path: true - paths: - - /functions/v1/ - plugins: - - name: cors - - ## Analytics routes - - name: analytics-v1 - _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*' - url: http://analytics:4000/ - routes: - - name: analytics-v1-all - strip_path: true - paths: - - /analytics/v1/ - - ## Secure Database routes - - name: meta - _comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*' - url: http://meta:8080/ - routes: - - name: meta-all - strip_path: true - paths: - - /pg/ - plugins: - - name: key-auth - config: - hide_credentials: false - - name: acl - config: - hide_groups_header: true - allow: - - admin - - ## Block access to /api/mcp - - name: mcp-blocker - _comment: 'Block direct access to /api/mcp' - url: http://studio:3000/api/mcp - routes: - - name: mcp-blocker-route - strip_path: true - paths: - - /api/mcp - plugins: - - name: request-termination - config: - status_code: 403 - message: "Access is forbidden." - - ## MCP endpoint - local access - - name: mcp - _comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)' - url: http://studio:3000/api/mcp - routes: - - name: mcp - strip_path: true - paths: - - /mcp - plugins: - # Block access to /mcp by default - - name: request-termination - config: - status_code: 403 - message: "Access is forbidden." - # Enable local access (danger zone!) - # 1. Comment out the 'request-termination' section above - # 2. Uncomment the entire section below, including 'deny' - # 3. Add your local IPs to the 'allow' list - #- name: cors - #- name: ip-restriction - # config: - # allow: - # - 127.0.0.1 - # - ::1 - # deny: [] - - ## Protected Dashboard - catch all remaining routes - - name: dashboard - _comment: 'Studio: /* -> http://studio:3000/*' - url: http://studio:3000/ - routes: - - name: dashboard-all - strip_path: true - paths: - - / - plugins: - - name: cors - - name: basic-auth - config: - hide_credentials: true diff --git a/docker/supabase/volumes/db/webhooks.sql b/docker/supabase/volumes/db/webhooks.sql deleted file mode 100644 index 5837b86..0000000 --- a/docker/supabase/volumes/db/webhooks.sql +++ /dev/null @@ -1,208 +0,0 @@ -BEGIN; - -- Create pg_net extension - CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; - -- Create supabase_functions schema - CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; - GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; - ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; - ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; - ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; - -- supabase_functions.migrations definition - CREATE TABLE supabase_functions.migrations ( - version text PRIMARY KEY, - inserted_at timestamptz NOT NULL DEFAULT NOW() - ); - -- Initial supabase_functions migration - INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); - -- supabase_functions.hooks definition - CREATE TABLE supabase_functions.hooks ( - id bigserial PRIMARY KEY, - hook_table_id integer NOT NULL, - hook_name text NOT NULL, - created_at timestamptz NOT NULL DEFAULT NOW(), - request_id bigint - ); - CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); - CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); - COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; - CREATE FUNCTION supabase_functions.http_request() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ - DECLARE - request_id bigint; - payload jsonb; - url text := TG_ARGV[0]::text; - method text := TG_ARGV[1]::text; - headers jsonb DEFAULT '{}'::jsonb; - params jsonb DEFAULT '{}'::jsonb; - timeout_ms integer DEFAULT 1000; - BEGIN - IF url IS NULL OR url = 'null' THEN - RAISE EXCEPTION 'url argument is missing'; - END IF; - - IF method IS NULL OR method = 'null' THEN - RAISE EXCEPTION 'method argument is missing'; - END IF; - - IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN - headers = '{"Content-Type": "application/json"}'::jsonb; - ELSE - headers = TG_ARGV[2]::jsonb; - END IF; - - IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN - params = '{}'::jsonb; - ELSE - params = TG_ARGV[3]::jsonb; - END IF; - - IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN - timeout_ms = 1000; - ELSE - timeout_ms = TG_ARGV[4]::integer; - END IF; - - CASE - WHEN method = 'GET' THEN - SELECT http_get INTO request_id FROM net.http_get( - url, - params, - headers, - timeout_ms - ); - WHEN method = 'POST' THEN - payload = jsonb_build_object( - 'old_record', OLD, - 'record', NEW, - 'type', TG_OP, - 'table', TG_TABLE_NAME, - 'schema', TG_TABLE_SCHEMA - ); - - SELECT http_post INTO request_id FROM net.http_post( - url, - payload, - params, - headers, - timeout_ms - ); - ELSE - RAISE EXCEPTION 'method argument % is invalid', method; - END CASE; - - INSERT INTO supabase_functions.hooks - (hook_table_id, hook_name, request_id) - VALUES - (TG_RELID, TG_NAME, request_id); - - RETURN NEW; - END - $function$; - -- Supabase super admin - DO - $$ - BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_roles - WHERE rolname = 'supabase_functions_admin' - ) - THEN - CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; - END IF; - END - $$; - GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; - GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; - GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; - ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; - ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; - ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; - ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; - GRANT supabase_functions_admin TO postgres; - -- Remove unused supabase_pg_net_admin role - DO - $$ - BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_roles - WHERE rolname = 'supabase_pg_net_admin' - ) - THEN - REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; - DROP OWNED BY supabase_pg_net_admin; - DROP ROLE supabase_pg_net_admin; - END IF; - END - $$; - -- pg_net grants when extension is already enabled - DO - $$ - BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_extension - WHERE extname = 'pg_net' - ) - THEN - GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - END IF; - END - $$; - -- Event trigger for pg_net - CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() - RETURNS event_trigger - LANGUAGE plpgsql - AS $$ - BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_event_trigger_ddl_commands() AS ev - JOIN pg_extension AS ext - ON ev.objid = ext.oid - WHERE ext.extname = 'pg_net' - ) - THEN - GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - END IF; - END; - $$; - COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; - DO - $$ - BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_event_trigger - WHERE evtname = 'issue_pg_net_access' - ) THEN - CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') - EXECUTE PROCEDURE extensions.grant_pg_net_access(); - END IF; - END - $$; - INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); - ALTER function supabase_functions.http_request() SECURITY DEFINER; - ALTER function supabase_functions.http_request() SET search_path = supabase_functions; - REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; - GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; -COMMIT; diff --git a/docker/supabase/volumes/functions/main/index.ts b/docker/supabase/volumes/functions/main/index.ts deleted file mode 100644 index 3eaaf72..0000000 --- a/docker/supabase/volumes/functions/main/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { serve } from 'https://deno.land/std@0.131.0/http/server.ts' -import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' - -const JWT_SECRET = Deno.env.get('JWT_SECRET') -const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' - -function getAuthToken(req: Request) { - const authHeader = req.headers.get('authorization') - if (!authHeader) { - throw new Error('Missing authorization header') - } - const [bearer, token] = authHeader.split(' ') - if (bearer !== 'Bearer') { - throw new Error("Auth header is not 'Bearer {token}'") - } - return token -} - -async function verifyJWT(jwt: string): Promise { - const encoder = new TextEncoder() - const secretKey = encoder.encode(JWT_SECRET) - try { - await jose.jwtVerify(jwt, secretKey) - } catch (_err) { - return false - } - return true -} - -serve(async (req: Request) => { - if (req.method !== 'OPTIONS' && VERIFY_JWT) { - try { - const token = getAuthToken(req) - const isValidJWT = await verifyJWT(token) - - if (!isValidJWT) { - return new Response(JSON.stringify({ msg: 'Invalid JWT' }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }) - } - } catch (e) { - return new Response(JSON.stringify({ msg: e.toString() }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }) - } - } - - const url = new URL(req.url) - const { pathname } = url - const path_parts = pathname.split('/') - const service_name = path_parts[1] - - if (!service_name || service_name === '') { - const error = { msg: 'missing function name in request' } - return new Response(JSON.stringify(error), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }) - } - - const servicePath = `/home/deno/functions/${service_name}` - - const memoryLimitMb = 150 - const workerTimeoutMs = 1 * 60 * 1000 - const noModuleCache = false - const importMapPath = null - const envVarsObj = Deno.env.toObject() - const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) - - try { - const worker = await EdgeRuntime.userWorkers.create({ - servicePath, - memoryLimitMb, - workerTimeoutMs, - noModuleCache, - importMapPath, - envVars, - }) - return await worker.fetch(req) - } catch (e) { - const error = { msg: e.toString() } - return new Response(JSON.stringify(error), { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }) - } -}) diff --git a/docs/plans/PLAN-base-service-redis-qdrant-2026-02-05.md b/docs/plans/PLAN-base-service-redis-qdrant-2026-02-05.md new file mode 100644 index 0000000..73c3e9e --- /dev/null +++ b/docs/plans/PLAN-base-service-redis-qdrant-2026-02-05.md @@ -0,0 +1,108 @@ +# Plan: Base Service for Redis and Qdrant + +**Date:** 2026-02-05 +**Author:** AI Assistant +**Status:** Draft + +## Overview + +Create a reusable base service module under `backend/src/services/base` that standardizes Redis and Qdrant client creation, lifecycle management, and error handling. Align the design with the DIVA-backend equivalent (once provided) and integrate configuration through existing `SOCIAL_REDIS__*` and `SOCIAL_QDRANT__*` settings. + +## Requirements + +### Functional +- [ ] Provide a base service abstraction that exposes Redis and Qdrant clients to other services. +- [ ] Use async client implementations compatible with FastAPI async execution. +- [ ] Support connection lifecycle hooks (initialize, health check, close). +- [ ] Centralize error handling and translate connection failures to consistent HTTP errors. +- [ ] Mirror DIVA-backend base service features and naming conventions where applicable. + +### Non-Functional +- [ ] Performance: reuse client instances; avoid per-request connection creation. +- [ ] Security: never log secrets (API keys/passwords); enforce TLS settings when enabled. +- [ ] Reliability: implement timeouts and retry policy where supported by client libraries. + +## Technical Approach + +Introduce a `services/base` package that provides a small, composable base class plus Redis/Qdrant client factories. Configuration will be sourced from `core/config/settings.py` using the existing `.env` keys. The base service will accept injected clients to keep testability high and avoid global state, while a module-level factory will handle creation and cleanup. + +### Key Decisions +| Decision | Rationale | +|----------|-----------| +| Use async Redis and Qdrant clients | Matches FastAPI async usage and avoids blocking the event loop. | +| Constructor injection with factories | Keeps services testable and avoids hidden global state. | +| Centralized error mapping in base service | Ensures consistent HTTP 503 responses and logging. | + +## Implementation Steps + +### Phase 1: DIVA-backend Parity Review (1-2 hours) +1. Locate DIVA-backend base service module (path or repo) and document its responsibilities, public API, and lifecycle behavior. +2. Produce a parity checklist to map DIVA behaviors to this repo (naming, error types, retry policy, health checks). + +### Phase 2: Configuration and Client Factories (3 hours) +1. Add `RedisSettings` and `QdrantSettings` sections to `backend/src/core/config/settings.py` using existing `SOCIAL_REDIS__*` and `SOCIAL_QDRANT__*` env keys. +2. Create `backend/src/services/base/redis_client.py` and `backend/src/services/base/qdrant_client.py` with async client factory functions and close helpers. +3. Add structured logging for client initialization, connection failures, and shutdown paths. + +### Phase 3: Base Service Class (3 hours) +1. Create `backend/src/services/base/service.py` with a `BaseService` that accepts optional Redis/Qdrant clients (dependency injection). +2. Add helper methods (e.g., `require_redis()`, `require_qdrant()`) that raise HTTP 503 on unavailable clients. +3. Define error translation utilities for Redis/Qdrant exceptions with consistent messages and logging. + +### Phase 4: Tests (TDD) and Minimal Integration (4 hours) +1. Unit tests for settings parsing and default values (RED/GREEN). +2. Unit tests for base service behavior: missing client errors, exception mapping, and logging context. +3. Integration tests using running Redis/Qdrant containers to verify client factories can connect and execute a simple command. +4. E2E test that exercises a minimal endpoint using the base service (e.g., `/health/infra`), or record an explicit exception if no API integration is allowed. + +## Files to Modify + +| File | Changes | +|------|---------| +| backend/src/core/config/settings.py | Add Redis/Qdrant settings models and defaults. | +| backend/src/app.py | (If needed) register startup/shutdown hooks for client lifecycle. | +| backend/src/v1/router.py | (If needed) add an infra health endpoint to support E2E. | + +## Files to Create + +| File | Purpose | +|------|---------| +| backend/src/services/base/__init__.py | Package export surface for base services. | +| backend/src/services/base/service.py | Base service class for Redis/Qdrant access. | +| backend/src/services/base/redis_client.py | Redis client factory and teardown helpers. | +| backend/src/services/base/qdrant_client.py | Qdrant client factory and teardown helpers. | +| backend/tests/unit/services/base/test_service.py | Unit tests for base service error handling. | +| backend/tests/unit/services/base/test_clients.py | Unit tests for client factory behavior. | +| backend/tests/integration/services/base/test_clients.py | Integration tests with Redis/Qdrant containers. | +| backend/tests/e2e/test_infra_health.py | E2E test for an endpoint using base service. | + +## Dependencies + +- [ ] `redis` (async client) for Redis connectivity. +- [ ] `qdrant-client` for Qdrant connectivity (async/GRPC as configured). +- [ ] No additional infra services required (Redis/Qdrant already in Docker compose). + +## Testing Strategy + +- **Unit Tests:** Base service behavior, missing client errors, exception translation, settings parsing. +- **Integration Tests:** Connect to Redis and Qdrant, run minimal ping/health operations. +- **E2E Tests:** Call a minimal endpoint that uses the base service to validate wiring and error handling. + +## Risks & Mitigations + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| DIVA-backend module not available | Medium | High | Add a parity checklist and update plan once module location is provided. | +| Client library mismatch (sync vs async) | Medium | Medium | Select async-supported libraries and verify compatibility in unit tests. | +| Lack of API integration for E2E | High | Medium | Add a minimal infra health endpoint or record a documented exception. | +| Connection config mismatches | Medium | Medium | Validate settings with integration tests and mirror `.env.example`. | + +## Estimated Effort + +| Phase | Effort | +|-------|--------| +| Phase 1 | 2 hours | +| Phase 2 | 3 hours | +| Phase 3 | 3 hours | +| Phase 4 | 4 hours | +| **Total** | **12 hours** | diff --git a/docs/plans/PLAN-env-config-refactor-2026-02-05.md b/docs/plans/PLAN-env-config-refactor-2026-02-05.md new file mode 100644 index 0000000..fef9cde --- /dev/null +++ b/docs/plans/PLAN-env-config-refactor-2026-02-05.md @@ -0,0 +1,143 @@ +# Plan: Env Config Refactor + +**Date:** 2026-02-05 +**Author:** AI Assistant +**Status:** Draft + +## Overview + +对 `.env` / `.env.example` 与 `backend/src/core/config/settings.py` 做一次一致性重构,消除同一含义的重复配置来源(例如 `DATABASE_URL` 与分段口令、host/port 与完整 URL)。目标是明确一组规范化环境变量,确保后端仅通过 Settings 读取,并兼顾 `infra/docker/docker-compose.yml` 的现有依赖。 + +## Requirements + +### Functional +- [ ] 提出“规范化环境变量”清单(canonical set),覆盖后端与 Supabase 本地栈的关键配置。 +- [ ] 定义 Settings 的读取与推导策略(优先级、默认值、派生字段)。 +- [ ] 给出 `.env` / `.env.example` 的迁移步骤与兼容策略。 +- [ ] 兼容 `infra/docker/docker-compose.yml` 使用的变量(保证 compose 不被破坏)。 + +### Non-Functional +- [ ] Performance: Settings 解析不增加明显启动耗时 +- [ ] Security: 不在仓库中暴露真实密钥;对后端使用的数据库 URL 与密钥来源保持单一可信源 + +## Technical Approach + +以“后端设置单一来源 + docker-compose 继续使用 Supabase 变量”为原则: +- 后端只接受 `SOCIAL_DATABASE_URL` 与必要的 Supabase 访问变量(`public_url/anon_key/service_role_key/jwt_secret`)。 +- Supabase stack 继续使用 `SOCIAL_SUPABASE__*` 变量,保持 compose 模板稳定。 +- 通过 Settings 做派生字段(例如 `supabase.url`)与兼容性读入(可选旧字段,设置弃用期)。 + +### Key Decisions +| Decision | Rationale | +|----------|-----------| +| 保留 `SOCIAL_DATABASE_URL` 作为后端唯一数据库连接来源 | 避免与分段 `POSTGRES_*` 产生冲突,清晰配置入口 | +| Supabase stack 变量继续使用 `SOCIAL_SUPABASE__*` | docker-compose 已广泛引用,改动成本高 | +| Settings 允许短期兼容旧字段 | 保障迁移期间部署安全,减少切换风险 | + +## Implementation Steps + +### Phase 1: Inventory & Mapping (2 hours) +1. 盘点 `.env` / `.env.example` 与 `settings.py` 的变量差异,标注重复与冲突字段。 +2. 输出 canonical env vars 列表与映射关系表(旧 -> 新)。 + +### Phase 2: Settings Refactor (3 hours) +1. 在 `settings.py` 中实现新的读取优先级与派生字段。 +2. 为旧字段加兼容读取与弃用注记(仅内存兼容,不继续写入)。 + +### Phase 3: Env Templates Update (2 hours) +1. 更新 `.env.example` 为 canonical 变量,并标注“后端使用/compose 使用”。 +2. 更新 `.env`(本地开发用)以匹配新模板。 + +### Phase 4: Validation & Docs (2 hours) +1. 本地启动 docker-compose,验证 Supabase stack 与后端连接正常。 +2. 写简要迁移说明(README 或 docs/ 中短节)。 + +## Files to Modify + +| File | Changes | +|------|---------| +| `.env.example` | 替换为 canonical 变量,移除重复字段 | +| `.env` | 与模板对齐,移除重复字段 | +| `backend/src/core/config/settings.py` | 调整 Settings 读取与派生策略 | +| `infra/docker/docker-compose.yml` | 仅在必要时新增兼容映射变量 | +| `README.md` 或 `docs/*` | 增加迁移说明 | + +## Files to Create + +| File | Purpose | +|------|---------| +| `docs/plans/PLAN-env-config-refactor-2026-02-05.md` | 规划文档 | + +## Dependencies + +- [ ] 无新增第三方依赖 + +## Proposed Canonical Env Vars + +### Backend (Settings) +- `SOCIAL_DATABASE_URL` (required) — 后端数据库连接(唯一来源) +- `SOCIAL_SUPABASE__PUBLIC_URL` — Supabase 公网/本地外部访问 URL +- `SOCIAL_SUPABASE__API_EXTERNAL_URL` — Supabase Auth 回调外部 URL +- `SOCIAL_SUPABASE__ANON_KEY` — 前端/匿名访问 key +- `SOCIAL_SUPABASE__SERVICE_ROLE_KEY` — 后端服务角色 key +- `SOCIAL_SUPABASE__JWT_SECRET` — JWT 验证密钥(后端验证用) + +### Supabase Stack (docker-compose) +- `SOCIAL_SUPABASE__POSTGRES_HOST` +- `SOCIAL_SUPABASE__POSTGRES_PORT` +- `SOCIAL_SUPABASE__POSTGRES_DB` +- `SOCIAL_SUPABASE__POSTGRES_PASSWORD` +- `SOCIAL_SUPABASE__KONG_HTTP_PORT` +- `SOCIAL_SUPABASE__KONG_HTTPS_PORT` +- `SOCIAL_SUPABASE__SITE_URL` +- `SOCIAL_SUPABASE__JWT_SECRET` +- `SOCIAL_SUPABASE__ANON_KEY` +- `SOCIAL_SUPABASE__SERVICE_ROLE_KEY` +- 其余 `SOCIAL_SUPABASE__*` 保持现状(Logflare、SMTP、Pooler 等) + +## Mapping Strategy + +1. **Backend DB** + - Only: `SOCIAL_DATABASE_URL` + - Deprecated: `SOCIAL_SUPABASE__POSTGRES_*`(后端不再拼接) + +2. **Supabase URL** + - Primary: `SOCIAL_SUPABASE__PUBLIC_URL` + - Fallback: `SOCIAL_SUPABASE__API_EXTERNAL_URL` + - Settings 中 `supabase.url` 由上述字段派生 + +3. **Docker Compose** + - 继续读 `SOCIAL_SUPABASE__POSTGRES_*` + - 不引入 `SOCIAL_DATABASE_URL` 到 compose,以免混淆职责 + +## Migration Steps + +1. 在 `settings.py` 中增加兼容逻辑:若 `SOCIAL_DATABASE_URL` 不存在,可临时从 `SOCIAL_SUPABASE__POSTGRES_*` 组装(同时记录弃用)。 +2. 更新 `.env.example`:只保留 canonical 变量并标注用途。 +3. 更新 `.env`:移除重复字段,确保本地后端使用 `SOCIAL_DATABASE_URL`。 +4. 校验 compose:`infra/docker/docker-compose.yml` 不依赖被移除字段。 +5. 发布说明:提示下游用户迁移并在下个版本移除兼容逻辑。 + +## Testing Strategy + +- **Unit Tests:** Settings 派生字段与优先级规则 +- **Integration Tests:** 后端连接 Supabase DB(使用 `SOCIAL_DATABASE_URL`) +- **E2E Tests:** 关键登录/读写流程(确认 JWT 与 Service Role 配置无误) + +## Risks & Mitigations + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| compose 变量被误删导致 Supabase 启动失败 | High | Medium | 迁移前后对照 `docker-compose.yml`,保留全部 `SOCIAL_SUPABASE__*` 依赖 | +| 后端数据库连接断开 | High | Medium | 兼容旧字段,先引入新变量再切换 | +| 开发环境 `.env` 未更新 | Medium | High | 更新模板并在 README 明确迁移步骤 | + +## Estimated Effort + +| Phase | Effort | +|-------|--------| +| Phase 1 | 2 hours | +| Phase 2 | 3 hours | +| Phase 3 | 2 hours | +| Phase 4 | 2 hours | +| **Total** | **9 hours** | diff --git a/docs/plans/PLAN-logging-manager-2026-01-29.md b/docs/plans/PLAN-logging-manager-2026-01-29.md index e8d7b6c..197a4de 100644 --- a/docs/plans/PLAN-logging-manager-2026-01-29.md +++ b/docs/plans/PLAN-logging-manager-2026-01-29.md @@ -74,21 +74,21 @@ | File | Changes | |------|---------| -| api/src/core/config/settings.py | 扩展日志相关配置模型 | +| backend/src/core/config/settings.py | 扩展日志相关配置模型 | ## Files to Create | File | Purpose | |------|---------| -| api/src/core/logging/__init__.py | 模块导出与初始化入口 | -| api/src/core/logging/config.py | dictConfig 构建与环境配置 | -| api/src/core/logging/formatters.py | JSON formatter 与字段规范 | -| api/src/core/logging/handlers.py | 文件、控制台、错误 handler | -| api/src/core/logging/filters.py | 等级过滤、敏感字段脱敏 | -| api/src/core/logging/context.py | contextvars 绑定与获取 | -| api/src/core/logging/middleware.py | FastAPI 请求中间件 | -| api/src/core/logging/celery.py | Celery 日志信号集成 | -| api/src/core/logging/examples.py | 使用示例(可选) | +| backend/src/core/logging/__init__.py | 模块导出与初始化入口 | +| backend/src/core/logging/config.py | dictConfig 构建与环境配置 | +| backend/src/core/logging/formatters.py | JSON formatter 与字段规范 | +| backend/src/core/logging/handlers.py | 文件、控制台、错误 handler | +| backend/src/core/logging/filters.py | 等级过滤、敏感字段脱敏 | +| backend/src/core/logging/context.py | contextvars 绑定与获取 | +| backend/src/core/logging/middleware.py | FastAPI 请求中间件 | +| backend/src/core/logging/celery.py | Celery 日志信号集成 | +| backend/src/core/logging/examples.py | 使用示例(可选) | ## Dependencies @@ -126,6 +126,35 @@ logger.info("user login", extra={"user_id": "u_123"}) - **Integration Tests:** FastAPI 中间件注入的 request_id 与错误分离写入 - **E2E Tests:** 关键流程触发错误,验证 error 日志输出与轮转 +## Test Database 约定 + +### Supabase 组件能力范围 + +- Supabase 的 Auth/Storage/Realtime 等组件是独立服务,默认指向同一个主数据库。 +- 单独创建一个“测试数据库”(Postgres database)并不会自动获得这些组件的能力,除非显式为这些服务配置新的数据库连接。 +- 因此,“测试数据库”默认只具备纯 Postgres 能力;Supabase 组件能力仍然作用在主数据库上。 + +### 对测试的影响 + +- **只走直连数据库的测试**(如通过 SQLAlchemy/psycopg 直连)不会受影响。 +- **依赖 Supabase 组件的测试**(例如通过 Auth/Storage/Realtime API)会仍然落到主数据库,可能导致: + - 测试数据污染主库 + - 并发测试互相干扰 +- 若需要 Supabase 组件也“指向测试数据库”,需要启动一套独立 Supabase 栈或重新配置各服务连接(通常不建议在同一栈内动态切换)。 + +### 环境变量与自动创建 + +- 建议为“测试数据库”提供独立环境变量(仅测试环境读取),例如: + - `SOCIAL_TEST_DATABASE__HOST` + - `SOCIAL_TEST_DATABASE__PORT` + - `SOCIAL_TEST_DATABASE__NAME` + - `SOCIAL_TEST_DATABASE__USER` + - `SOCIAL_TEST_DATABASE__PASSWORD` +- 若使用 Docker 启动 Postgres,建议在容器初始化阶段自动创建测试数据库(避免手动创建): + - 通过 `docker-entrypoint-initdb.d` 的 init SQL 脚本创建测试数据库与权限 + - 保证容器重建后自动恢复测试数据库 +- 若使用独立 Supabase 栈做测试,测试环境变量应指向该栈的数据库与服务端口。 + ## Risks & Mitigations | Risk | Impact | Likelihood | Mitigation | diff --git a/docs/plans/PLAN-supabase-compose-base-services-2026-02-05.md b/docs/plans/PLAN-supabase-compose-base-services-2026-02-05.md new file mode 100644 index 0000000..6957d5b --- /dev/null +++ b/docs/plans/PLAN-supabase-compose-base-services-2026-02-05.md @@ -0,0 +1,113 @@ +# Plan: Merge Supabase Compose and Base Services + +**Date:** 2026-02-05 +**Author:** AI Assistant +**Status:** Draft + +## Overview + +Integrate Supabase Docker services into the project's `infra/docker/docker-compose.yml` and align all environment variables with the project's `.env` conventions. Add reusable BaseRepository and BaseService abstractions (soft-delete filtering and auth/user validation) and refactor profile/auth services to use them, with full TDD coverage. + +## Requirements + +### Functional +- [ ] Merge Supabase Docker Compose services into `infra/docker/docker-compose.yml` using project `.env` variable names. +- [ ] Update `.env.example` to include all required Supabase compose variables. +- [ ] Implement BaseRepository with standard soft-delete filtering (excludes `deleted_at` rows by default). +- [ ] Implement BaseService with shared auth/user validation helpers. +- [ ] Refactor profile repository/service and auth service to use BaseRepository/BaseService. +- [ ] Add unit, integration, and E2E tests following TDD. + +### Non-Functional +- [ ] Performance: keep repository queries indexed and avoid extra round-trips. +- [ ] Security: validate user identity consistently; no secrets in repo; no bypass of auth checks. +- [ ] Compatibility: keep Supabase config compatible with existing `Settings` and `.env` prefixes. + +## Technical Approach + +Introduce small, reusable base classes in `backend/src/core` for repository and service concerns, then refactor profile and auth modules to leverage them. Merge the Supabase compose services from the official template into `infra/docker/docker-compose.yml`, mapping variables to `SOCIAL_SUPABASE__*` and related infra keys already used in `backend/src/core/config/settings.py`. + +### Key Decisions +| Decision | Rationale | +|----------|-----------| +| BaseRepository provides a `base_select()` or `apply_soft_delete_filter()` | Avoid duplicated `deleted_at` filters and enforce consistent behavior. | +| BaseService handles user validation helpers | Keeps auth checks consistent across services and reduces duplicated error handling. | +| Compose variables aligned to `SOCIAL_*` prefixes | Matches existing settings resolution and simplifies local/dev parity. | + +## Implementation Steps + +### Phase 1: Compose Merge and Env Alignment (3 hours) +1. Identify the Supabase Docker Compose template to merge (official Supabase Docker template) and list required services and env vars. +2. Merge Supabase services into `infra/docker/docker-compose.yml`, keeping existing Redis/Qdrant services intact and aligning ports/volumes. +3. Map Supabase compose env variables to project `.env` names (e.g., `SOCIAL_SUPABASE__*`, `SOCIAL_INFRA__*` where needed). +4. Update `.env.example` with all required Supabase-related variables, keeping comments updated for local vs. cloud usage. +5. Add/adjust docker compose healthchecks or depends_on as needed for startup ordering. + +### Phase 2: BaseRepository and BaseService (4 hours) +1. Add `backend/src/core/db/repository.py` (or `backend/src/core/repository/base.py`) with a BaseRepository that applies `SoftDeleteMixin` filters by default. +2. Add `backend/src/core/services/base.py` with BaseService helpers for current user validation (e.g., `require_user`, `require_user_id`). +3. Add unit tests for BaseRepository soft delete filtering and BaseService auth validation (TDD red/green). + +### Phase 3: Refactor Profile/Auth (4 hours) +1. Refactor `backend/src/v1/profile/repository.py` to inherit from BaseRepository and remove duplicated `deleted_at` logic. +2. Refactor `backend/src/v1/profile/service.py` to inherit from BaseService and use shared validation helpers where applicable. +3. Refactor `backend/src/v1/auth/service.py` to adopt BaseService helpers for user validation (where applicable) and keep gateway contract unchanged. +4. Update unit tests for profile and auth services to reflect base class usage and ensure behavior unchanged. + +### Phase 4: Integration/E2E Tests and Hardening (4 hours) +1. Add integration tests for repository soft delete behavior using SQLAlchemy session fixtures. +2. Add or update E2E tests for profile flow to ensure auth/user validation still enforced. +3. Run coverage check (80%+), fix gaps, and verify CI pre-commit tooling passes. + +## Files to Modify + +| File | Changes | +|------|---------| +| infra/docker/docker-compose.yml | Merge Supabase services; map env vars to `SOCIAL_*`. | +| .env.example | Add Supabase compose variables and update comments. | +| backend/src/v1/profile/repository.py | Inherit BaseRepository; simplify soft delete filtering. | +| backend/src/v1/profile/service.py | Inherit BaseService; use shared validation helpers. | +| backend/src/v1/auth/service.py | Use BaseService helpers where applicable. | +| backend/tests/unit/v1/profile/* | Update tests for BaseRepository/BaseService. | +| backend/tests/unit/v1/auth/* | Update tests for base service helpers (if needed). | +| backend/tests/integration/* | Add/adjust tests for soft delete filtering. | +| backend/tests/e2e/* | Update/extend critical auth/profile flow tests. | + +## Files to Create + +| File | Purpose | +|------|---------| +| backend/src/core/db/repository.py | BaseRepository with soft-delete filtering. | +| backend/src/core/services/base.py | BaseService with auth/user validation helpers. | +| backend/tests/unit/core/db/test_base_repository.py | Unit tests for soft delete filters. | +| backend/tests/unit/core/services/test_base_service.py | Unit tests for auth/user validation. | + +## Dependencies + +- [ ] Supabase official Docker Compose template (source of services/env vars). +- [ ] No new Python dependencies expected. + +## Testing Strategy + +- **Unit Tests:** BaseRepository soft-delete filter logic; BaseService user validation helpers; updated profile/auth service behavior. +- **Integration Tests:** SQLAlchemy queries exclude soft-deleted rows; profile endpoints still return expected responses. +- **E2E Tests:** Critical profile flow with authenticated user; verify unauthorized access remains blocked. + +## Risks & Mitigations + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| Missing or outdated Supabase compose template | Medium | Medium | Pin to official template version and document source in plan. | +| Env var mismatches break local auth or DB connections | High | Medium | Add validation checklist and update `.env.example` with exact mappings. | +| BaseRepository changes alter query behavior | Medium | Medium | Add unit/integration tests and verify no regressions. | +| Auth validation refactor introduces regressions | High | Low | TDD with unit + E2E tests; keep behavior parity. | + +## Estimated Effort + +| Phase | Effort | +|-------|--------| +| Phase 1 | 3 hours | +| Phase 2 | 4 hours | +| Phase 3 | 4 hours | +| Phase 4 | 4 hours | +| **Total** | **15 hours** | diff --git a/docs/plans/PLAN-test-db-isolation-2026-02-05.md b/docs/plans/PLAN-test-db-isolation-2026-02-05.md new file mode 100644 index 0000000..5ab73e2 --- /dev/null +++ b/docs/plans/PLAN-test-db-isolation-2026-02-05.md @@ -0,0 +1,148 @@ +# 测试数据隔离方案(Supabase + Python 后端) + +## 背景现状 + +- 后端在 `backend/src/core/config/settings.py` 使用 `SOCIAL_DATABASE__*` 生成 `database_url`。 +- 本地 Supabase 通过 `supabase-db` 容器提供 Postgres,宿主端口由 `SOCIAL_DATABASE__PORT` 控制(默认映射到容器 5432)。 +- 注意:`supabase-pooler` 的 5432 仅用于连接池;测试与迁移应直连 `supabase-db` 的宿主端口。 +- 单元数据库测试目前使用 SQLite 内存库(见 `tests/unit/database/*`),不影响开发库。 +- 真实 Postgres 的集成/E2E 当前未统一隔离策略;当开始接入真实 DB 时,需要按本文方案隔离与清理。 + +## 目标 + +- 测试过程不污染开发数据。 +- 测试可重复、可并行、可在本地与 CI 稳定运行。 +- 变更成本可控,优先在现有架构上落地。 + +## 结论(适配本项目) + +采用“事务回滚 + 独立测试数据库”的混合策略: + +- 默认测试使用事务回滚,快速、零污染(适用于单连接/单事务场景)。 +- 需要真实提交、并发或触发器行为的测试使用独立测试数据库。 + +## 方案设计 + +### A. 事务回滚(默认) + +适用:单元测试、绝大多数集成测试(当这些测试连接真实 Postgres 时)。 + +核心思路: + +- 每个测试在事务中运行。 +- 测试结束自动回滚。 + +优点: + +- 速度快,无需新增数据库。 +- 测试间完全隔离。 + +限制: + +- 无法验证真实 COMMIT 结果。 +- 并发、多连接事务隔离测试不准确。 + +### B. 独立测试数据库(E2E/并发) + +适用:E2E、并发、触发器、LISTEN/NOTIFY 等需要真实提交的场景。 + +核心思路: + +- 在现有 Supabase Postgres 实例中创建独立数据库。 +- 测试使用专用 `SOCIAL_DATABASE__NAME` 连接,端口/账号/密码来自 `SOCIAL_DATABASE__*`。 +- 测试前应用迁移,测试后清理。 + +优点: + +- 行为最接近真实环境。 +- 与开发数据完全隔离。 + +成本: + +- 需要迁移与清理策略。 + +## 与现有测试模块的衔接 + +- `tests/unit/database/*` 已使用 SQLite 内存库,无需改造。 +- 未来若 `tests/integration/*` 或 `tests/e2e/*` 连接真实 Postgres,应切换到本文的测试库策略。 +- 使用 `SOCIAL_DATABASE__NAME=postgres_test` 启动测试,以避免污染开发库。 + +## 实施步骤(与项目当前结构对齐) + +### 1) 创建独立测试数据库 + +在本地 Supabase 容器中创建测试库: + +```bash +docker exec -e PGPASSWORD="$SOCIAL_DATABASE__PASSWORD" supabase-db \ + psql -U "$SOCIAL_DATABASE__USER" -c "CREATE DATABASE postgres_test;" +``` + +说明: + +- 容器名为 `supabase-db`(已在 `infra/docker` 运行)。 +- 数据库名建议 `postgres_test`,与 `.env` 的 `SOCIAL_DATABASE__NAME=postgres` 区分。 + +### 2) 运行迁移到测试库 + +使用测试环境变量指向测试库后,应用 Alembic 迁移: + +```bash +SOCIAL_RUNTIME__ENVIRONMENT=test \ +SOCIAL_DATABASE__NAME=postgres_test \ +uv run alembic upgrade head +``` + +说明: + +- 执行位置:`/home/qzl/Code/social-app/backend`。 +- 仍使用当前 `.env` 中的 `SOCIAL_DATABASE__HOST` 与 `SOCIAL_DATABASE__PORT`。 + +### 3) 事务回滚测试(默认) + +测试执行时注入事务回滚机制: + +- 在测试会话层创建单连接事务。 +- 对每个测试用例使用 SAVEPOINT(或嵌套事务)。 +- 测试结束回滚到 SAVEPOINT。 + +这套策略可保持速度与隔离性,同时不需要额外数据库。 + +### 4) 独立测试数据库执行(E2E/并发) + +对于需要真实提交的测试,使用测试库运行: + +```bash +SOCIAL_RUNTIME__ENVIRONMENT=test \ +SOCIAL_DATABASE__NAME=postgres_test \ +uv run pytest tests/e2e +``` + +清理策略(二选一): + +- 小规模测试:TRUNCATE public schema 的业务表(不影响 `auth` 等系统 schema)。 +- 大规模测试:`DROP DATABASE postgres_test;` 后重建并迁移。 + +### 5) 本地/CI 统一策略 + +- 本地默认:事务回滚。 +- CI:独立测试库(保证完全隔离、无隐式依赖)。 + +## 风险与规避 + +- 不要在清理时操作 `auth`、`storage` 等 Supabase 系统 schema。 +- E2E 使用独立数据库,避免与开发数据交叉。 +- 迁移必须由 Alembic 统一维护,禁止手动改库。 + +## 落地检查清单 + +- [ ] 已创建 `postgres_test` 数据库。 +- [ ] 测试库迁移已应用。 +- [ ] 事务回滚测试已接入(默认路径)。 +- [ ] E2E 使用测试库运行。 +- [ ] 清理策略执行脚本可复用。 + +## 备注 + +本方案基于当前项目的 Supabase 本地 Docker 结构与后端配置方式(`SOCIAL_DATABASE__*`)。 +无需变更 Supabase 组件,优先在测试层完成隔离与清理。 diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml new file mode 100644 index 0000000..3bd60c2 --- /dev/null +++ b/infra/docker/docker-compose.yml @@ -0,0 +1,407 @@ +name: social-app-local + +services: + redis: + image: redis:7-alpine + container_name: social-local-redis + restart: unless-stopped + ports: + - "${SOCIAL_REDIS__PORT:-6379}:6379" + volumes: + - redis_data:/data + environment: + - REDIS_PASSWORD=${SOCIAL_REDIS__PASSWORD:-} + command: > + sh -c 'if [ -n "$$REDIS_PASSWORD" ]; then redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"; else redis-server --appendonly yes; fi' + healthcheck: + test: ["CMD", "sh", "-c", "if [ -n \"$$REDIS_PASSWORD\" ]; then redis-cli -a \"$$REDIS_PASSWORD\" ping; else redis-cli ping; fi"] + interval: 5s + timeout: 3s + retries: 5 + + qdrant: + image: qdrant/qdrant:latest + container_name: social-local-qdrant + restart: unless-stopped + ports: + - "${SOCIAL_QDRANT__PORT:-6333}:6333" + - "${SOCIAL_QDRANT__GRPC_PORT:-6334}:6334" + volumes: + - qdrant_data:/qdrant/storage + environment: + - QDRANT__SERVICE__API_KEY=${SOCIAL_QDRANT__API_KEY:-} + + studio: + container_name: supabase-studio + image: supabase/studio:2025.12.17-sha-43f4f7f + restart: unless-stopped + healthcheck: + test: + ["CMD", "node", "-e", "fetch('http://studio:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})"] + timeout: 10s + interval: 5s + retries: 3 + depends_on: + analytics: + condition: service_healthy + environment: + HOSTNAME: "::" + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PORT: 5432 + POSTGRES_HOST: db + POSTGRES_DB: postgres + POSTGRES_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} + PG_META_CRYPTO_KEY: ${SOCIAL_SUPABASE__PG_META_CRYPTO_KEY} + DEFAULT_ORGANIZATION_NAME: ${SOCIAL_SUPABASE__STUDIO_DEFAULT_ORGANIZATION:-Social App} + DEFAULT_PROJECT_NAME: ${SOCIAL_SUPABASE__STUDIO_DEFAULT_PROJECT:-local} + OPENAI_API_KEY: ${SOCIAL_SUPABASE__OPENAI_API_KEY:-} + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SOCIAL_SUPABASE__PUBLIC_SCHEME}://${SOCIAL_SUPABASE__PUBLIC_HOST}:${SOCIAL_SUPABASE__KONG_HTTP_PORT} + SUPABASE_ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY} + SUPABASE_SERVICE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PUBLIC_ACCESS_TOKEN} + LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PRIVATE_ACCESS_TOKEN} + LOGFLARE_URL: http://analytics:4000 + NEXT_PUBLIC_ENABLE_LOGS: "true" + NEXT_ANALYTICS_BACKEND_PROVIDER: postgres + + kong: + container_name: supabase-kong + image: kong:2.8.1 + restart: unless-stopped + ports: + - "127.0.0.1:${SOCIAL_SUPABASE__KONG_HTTP_PORT:-8000}:8000/tcp" + - "127.0.0.1:${SOCIAL_SUPABASE__KONG_HTTPS_PORT:-8443}:8443/tcp" + volumes: + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z + depends_on: + analytics: + condition: service_healthy + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml + KONG_DNS_ORDER: LAST,A,CNAME + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + KONG_PROXY_LISTEN: 0.0.0.0:8000, 0.0.0.0:8443 ssl + SUPABASE_ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY} + SUPABASE_SERVICE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY} + DASHBOARD_USERNAME: ${SOCIAL_SUPABASE__DASHBOARD_USERNAME} + DASHBOARD_PASSWORD: ${SOCIAL_SUPABASE__DASHBOARD_PASSWORD} + entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' + + auth: + container_name: supabase-auth + image: supabase/gotrue:v2.184.0 + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${SOCIAL_SUPABASE__PUBLIC_SCHEME}://${SOCIAL_SUPABASE__PUBLIC_HOST}:${SOCIAL_SUPABASE__KONG_HTTP_PORT} + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/postgres + GOTRUE_SITE_URL: ${SOCIAL_SUPABASE__SITE_URL} + GOTRUE_URI_ALLOW_LIST: ${SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS:-} + GOTRUE_DISABLE_SIGNUP: ${SOCIAL_SUPABASE__DISABLE_SIGNUP:-false} + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${SOCIAL_SUPABASE__JWT_EXPIRY:-3600} + GOTRUE_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${SOCIAL_SUPABASE__ENABLE_EMAIL_SIGNUP:-true} + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${SOCIAL_SUPABASE__ENABLE_ANONYMOUS_USERS:-false} + GOTRUE_MAILER_AUTOCONFIRM: ${SOCIAL_SUPABASE__ENABLE_EMAIL_AUTOCONFIRM:-true} + GOTRUE_SMTP_ADMIN_EMAIL: ${SOCIAL_SUPABASE__SMTP_ADMIN_EMAIL:-} + GOTRUE_SMTP_HOST: ${SOCIAL_SUPABASE__SMTP_HOST:-} + GOTRUE_SMTP_PORT: ${SOCIAL_SUPABASE__SMTP_PORT:-} + GOTRUE_SMTP_USER: ${SOCIAL_SUPABASE__SMTP_USER:-} + GOTRUE_SMTP_PASS: ${SOCIAL_SUPABASE__SMTP_PASS:-} + GOTRUE_SMTP_SENDER_NAME: ${SOCIAL_SUPABASE__SMTP_SENDER_NAME:-} + GOTRUE_MAILER_URLPATHS_INVITE: ${SOCIAL_SUPABASE__MAILER_URLPATHS_INVITE:-/auth/v1/verify} + GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${SOCIAL_SUPABASE__MAILER_URLPATHS_CONFIRMATION:-/auth/v1/verify} + GOTRUE_MAILER_URLPATHS_RECOVERY: ${SOCIAL_SUPABASE__MAILER_URLPATHS_RECOVERY:-/auth/v1/recover} + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${SOCIAL_SUPABASE__MAILER_URLPATHS_EMAIL_CHANGE:-/auth/v1/verify} + GOTRUE_EXTERNAL_PHONE_ENABLED: ${SOCIAL_SUPABASE__ENABLE_PHONE_SIGNUP:-false} + GOTRUE_SMS_AUTOCONFIRM: ${SOCIAL_SUPABASE__ENABLE_PHONE_AUTOCONFIRM:-false} + + rest: + container_name: supabase-rest + image: postgrest/postgrest:v14.1 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${SOCIAL_DATABASE__PASSWORD}@db:5432/postgres + PGRST_DB_SCHEMAS: ${SOCIAL_SUPABASE__PGRST_DB_SCHEMAS:-public} + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: ${SOCIAL_SUPABASE__JWT_EXPIRY:-3600} + command: ["postgrest"] + + realtime: + container_name: realtime-dev.supabase-realtime + image: supabase/realtime:v2.68.0 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${SOCIAL_SUPABASE__ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health"] + timeout: 5s + interval: 30s + retries: 3 + start_period: 10s + environment: + PORT: 4000 + DB_HOST: db + DB_PORT: 5432 + DB_USER: supabase_admin + DB_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} + DB_NAME: postgres + DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + DB_ENC_KEY: supabaserealtime + API_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + SECRET_KEY_BASE: ${SOCIAL_SUPABASE__SECRET_KEY_BASE} + ERL_AFLAGS: -proto_dist inet_tcp + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + SEED_SELF_HOST: "true" + RUN_JANITOR: "true" + + storage: + container_name: supabase-storage + image: supabase/storage-api:v1.33.0 + restart: unless-stopped + volumes: + - ./volumes/storage:/var/lib/storage:z + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://storage:5000/status"] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + db: + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + environment: + ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY} + SERVICE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/postgres + REQUEST_ALLOW_X_FORWARDED_PATH: "true" + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + REGION: stub + GLOBAL_S3_BUCKET: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + + imgproxy: + container_name: supabase-imgproxy + image: darthsim/imgproxy:v3.30.1 + restart: unless-stopped + volumes: + - ./volumes/storage:/var/lib/storage:z + healthcheck: + test: ["CMD", "imgproxy", "health"] + timeout: 5s + interval: 5s + retries: 3 + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: ${SOCIAL_SUPABASE__IMGPROXY_ENABLE_WEBP_DETECTION:-true} + IMGPROXY_MAX_SRC_RESOLUTION: 16.8 + + meta: + container_name: supabase-meta + image: supabase/postgres-meta:v0.95.1 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: db + PG_META_DB_PORT: 5432 + PG_META_DB_NAME: postgres + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} + CRYPTO_KEY: ${SOCIAL_SUPABASE__PG_META_CRYPTO_KEY} + + functions: + container_name: supabase-edge-functions + image: supabase/edge-runtime:v1.69.28 + restart: unless-stopped + volumes: + - ./volumes/functions:/home/deno/functions:Z + depends_on: + analytics: + condition: service_healthy + environment: + JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + SUPABASE_URL: http://kong:8000 + SUPABASE_ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY} + SUPABASE_DB_URL: postgresql://postgres:${SOCIAL_DATABASE__PASSWORD}@db:5432/postgres + VERIFY_JWT: "${SOCIAL_SUPABASE__FUNCTIONS_VERIFY_JWT:-false}" + command: ["start", "--main-service", "/home/deno/functions/main"] + + analytics: + container_name: supabase-analytics + image: supabase/logflare:1.27.0 + restart: unless-stopped + ports: + - "127.0.0.1:${SOCIAL_SUPABASE__ANALYTICS_PORT:-4000}:4000" + healthcheck: + test: ["CMD", "curl", "http://localhost:4000/health"] + timeout: 5s + interval: 5s + retries: 10 + depends_on: + db: + condition: service_healthy + environment: + LOGFLARE_NODE_HOST: 127.0.0.1 + DB_USERNAME: supabase_admin + DB_DATABASE: _supabase + DB_HOSTNAME: db + DB_PORT: 5432 + DB_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} + DB_SCHEMA: _analytics + LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PUBLIC_ACCESS_TOKEN} + LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PRIVATE_ACCESS_TOKEN} + LOGFLARE_SINGLE_TENANT: true + LOGFLARE_SUPABASE_MODE: true + POSTGRES_BACKEND_URL: postgresql://supabase_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/_supabase + POSTGRES_BACKEND_SCHEMA: _analytics + LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true + + db: + container_name: supabase-db + image: supabase/postgres:15.8.1.085 + restart: unless-stopped + ports: + - "127.0.0.1:${SOCIAL_DATABASE__PORT:-5432}:5432" + volumes: + - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z + - ./volumes/db/data:/var/lib/postgresql/data:Z + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z + - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z + - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z + - db-config:/etc/postgresql-custom + healthcheck: + test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"] + interval: 5s + timeout: 5s + retries: 10 + depends_on: + vector: + condition: service_healthy + environment: + POSTGRES_HOST: /var/run/postgresql + PGPORT: 5432 + POSTGRES_PORT: 5432 + PGPASSWORD: ${SOCIAL_DATABASE__PASSWORD} + POSTGRES_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} + PGDATABASE: postgres + POSTGRES_DB: postgres + JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + JWT_EXP: ${SOCIAL_SUPABASE__JWT_EXPIRY:-3600} + command: + ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf", "-c", "log_min_messages=fatal"] + + vector: + container_name: supabase-vector + image: timberio/vector:0.28.1-alpine + restart: unless-stopped + volumes: + - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z + - ${SOCIAL_SUPABASE__DOCKER_SOCKET_LOCATION:-/var/run/docker.sock}:/var/run/docker.sock:ro,z + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://vector:9001/health"] + timeout: 5s + interval: 5s + retries: 3 + environment: + LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PUBLIC_ACCESS_TOKEN} + command: ["--config", "/etc/vector/vector.yml"] + security_opt: + - "label=disable" + + supavisor: + container_name: supabase-pooler + image: supabase/supavisor:2.7.4 + restart: unless-stopped + ports: + - "127.0.0.1:5432:5432" + - "127.0.0.1:${SOCIAL_SUPABASE__POOLER_PROXY_PORT_TRANSACTION:-6543}:6543" + volumes: + - ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z + healthcheck: + test: ["CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "http://127.0.0.1:4000/api/health"] + interval: 10s + timeout: 5s + retries: 5 + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + environment: + PORT: 4000 + POSTGRES_PORT: 5432 + POSTGRES_DB: postgres + POSTGRES_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} + DATABASE_URL: ecto://supabase_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/_supabase + CLUSTER_POSTGRES: true + SECRET_KEY_BASE: ${SOCIAL_SUPABASE__SECRET_KEY_BASE} + VAULT_ENC_KEY: ${SOCIAL_SUPABASE__VAULT_ENC_KEY} + API_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + METRICS_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} + REGION: local + ERL_AFLAGS: -proto_dist inet_tcp + POOLER_TENANT_ID: ${SOCIAL_SUPABASE__POOLER_TENANT_ID:-local} + POOLER_DEFAULT_POOL_SIZE: ${SOCIAL_SUPABASE__POOLER_DEFAULT_POOL_SIZE:-20} + POOLER_MAX_CLIENT_CONN: ${SOCIAL_SUPABASE__POOLER_MAX_CLIENT_CONN:-100} + POOLER_POOL_MODE: transaction + DB_POOL_SIZE: ${SOCIAL_SUPABASE__POOLER_DB_POOL_SIZE:-5} + command: + ["/bin/sh", "-c", "/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server"] + +volumes: + redis_data: + qdrant_data: + db-config: diff --git a/infra/docker/volumes/api/kong.yml b/infra/docker/volumes/api/kong.yml new file mode 100644 index 0000000..d2ef6cf --- /dev/null +++ b/infra/docker/volumes/api/kong.yml @@ -0,0 +1,238 @@ +_format_version: '2.1' +_transform: true +consumers: +- username: DASHBOARD +- username: anon +- username: service_role +keyauth_credentials: +- consumer: anon + key: $SUPABASE_ANON_KEY +- consumer: service_role + key: $SUPABASE_SERVICE_KEY +acls: +- consumer: anon + group: anon +- consumer: service_role + group: admin +basicauth_credentials: +- consumer: DASHBOARD + username: $DASHBOARD_USERNAME + password: $DASHBOARD_PASSWORD +services: +- name: auth-v1-open + url: http://auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors +- name: auth-v1-open-callback + url: http://auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors +- name: auth-v1-open-authorize + url: http://auth:9999/authorize + routes: + - name: auth-v1-open-authorize + strip_path: true + paths: + - /auth/v1/authorize + plugins: + - name: cors +- name: auth-v1 + _comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*' + url: http://auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon +- name: rest-v1 + _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' + url: http://rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon +- name: graphql-v1 + _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql' + url: http://rest:3000/rpc/graphql + routes: + - name: graphql-v1-all + strip_path: true + paths: + - /graphql/v1 + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: request-transformer + config: + add: + headers: + - Content-Profile:graphql_public + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon +- name: realtime-v1-ws + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/socket + protocol: ws + routes: + - name: realtime-v1-ws + strip_path: true + paths: + - /realtime/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon +- name: realtime-v1-rest + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/api + protocol: http + routes: + - name: realtime-v1-rest + strip_path: true + paths: + - /realtime/v1/api + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon +- name: storage-v1 + _comment: 'Storage: /storage/v1/* -> http://storage:5000/*' + url: http://storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors +- name: functions-v1 + _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' + url: http://functions:9000/ + routes: + - name: functions-v1-all + strip_path: true + paths: + - /functions/v1/ + plugins: + - name: cors +- name: analytics-v1 + _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*' + url: http://analytics:4000/ + routes: + - name: analytics-v1-all + strip_path: true + paths: + - /analytics/v1/ +- name: meta + _comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*' + url: http://meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin +- name: mcp-blocker + _comment: 'Block direct access to /api/mcp' + url: http://studio:3000/api/mcp + routes: + - name: mcp-blocker-route + strip_path: true + paths: + - /api/mcp + plugins: + - name: request-termination + config: + status_code: 403 + message: "Access is forbidden." +- name: mcp + _comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)' + url: http://studio:3000/api/mcp + routes: + - name: mcp + strip_path: true + paths: + - /mcp + plugins: + - name: cors + - name: ip-restriction + config: + allow: + - 127.0.0.1 + - ::1 + - 172.19.0.1 + deny: [] +- name: dashboard + _comment: 'Studio: /* -> http://studio:3000/*' + url: http://studio:3000/ + routes: + - name: dashboard-all + strip_path: true + paths: + - / + plugins: + - name: cors + - name: basic-auth + config: + hide_credentials: true diff --git a/docker/supabase/volumes/db/_supabase.sql b/infra/docker/volumes/db/_supabase.sql similarity index 98% rename from docker/supabase/volumes/db/_supabase.sql rename to infra/docker/volumes/db/_supabase.sql index 6236ae1..8882968 100644 --- a/docker/supabase/volumes/db/_supabase.sql +++ b/infra/docker/volumes/db/_supabase.sql @@ -1,3 +1,2 @@ \set pguser `echo "$POSTGRES_USER"` - CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/docker/supabase/volumes/db/jwt.sql b/infra/docker/volumes/db/jwt.sql similarity index 99% rename from docker/supabase/volumes/db/jwt.sql rename to infra/docker/volumes/db/jwt.sql index cfd3b16..93a8041 100644 --- a/docker/supabase/volumes/db/jwt.sql +++ b/infra/docker/volumes/db/jwt.sql @@ -1,5 +1,4 @@ \set jwt_secret `echo "$JWT_SECRET"` \set jwt_exp `echo "$JWT_EXP"` - ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; diff --git a/docker/supabase/volumes/db/logs.sql b/infra/docker/volumes/db/logs.sql similarity index 99% rename from docker/supabase/volumes/db/logs.sql rename to infra/docker/volumes/db/logs.sql index 255c0f4..794b086 100644 --- a/docker/supabase/volumes/db/logs.sql +++ b/infra/docker/volumes/db/logs.sql @@ -1,5 +1,4 @@ \set pguser `echo "$POSTGRES_USER"` - \c _supabase create schema if not exists _analytics; alter schema _analytics owner to :pguser; diff --git a/docker/supabase/volumes/db/pooler.sql b/infra/docker/volumes/db/pooler.sql similarity index 99% rename from docker/supabase/volumes/db/pooler.sql rename to infra/docker/volumes/db/pooler.sql index 162c5b9..516d986 100644 --- a/docker/supabase/volumes/db/pooler.sql +++ b/infra/docker/volumes/db/pooler.sql @@ -1,5 +1,4 @@ \set pguser `echo "$POSTGRES_USER"` - \c _supabase create schema if not exists _supavisor; alter schema _supavisor owner to :pguser; diff --git a/docker/supabase/volumes/db/realtime.sql b/infra/docker/volumes/db/realtime.sql similarity index 99% rename from docker/supabase/volumes/db/realtime.sql rename to infra/docker/volumes/db/realtime.sql index 4d4b9ff..231cded 100644 --- a/docker/supabase/volumes/db/realtime.sql +++ b/infra/docker/volumes/db/realtime.sql @@ -1,4 +1,3 @@ \set pguser `echo "$POSTGRES_USER"` - create schema if not exists _realtime; alter schema _realtime owner to :pguser; diff --git a/docker/supabase/volumes/db/roles.sql b/infra/docker/volumes/db/roles.sql similarity index 99% rename from docker/supabase/volumes/db/roles.sql rename to infra/docker/volumes/db/roles.sql index 8f7161a..d0641fa 100644 --- a/docker/supabase/volumes/db/roles.sql +++ b/infra/docker/volumes/db/roles.sql @@ -1,6 +1,5 @@ -- NOTE: change to your own passwords for production environments \set pgpass `echo "$POSTGRES_PASSWORD"` - ALTER USER authenticator WITH PASSWORD :'pgpass'; ALTER USER pgbouncer WITH PASSWORD :'pgpass'; ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; diff --git a/infra/docker/volumes/db/webhooks.sql b/infra/docker/volumes/db/webhooks.sql new file mode 100644 index 0000000..3c9b93c --- /dev/null +++ b/infra/docker/volumes/db/webhooks.sql @@ -0,0 +1,191 @@ +BEGIN; +CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; +CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; +GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; +CREATE TABLE supabase_functions.migrations ( + version text PRIMARY KEY, + inserted_at timestamptz NOT NULL DEFAULT NOW() +); +INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); +CREATE TABLE supabase_functions.hooks ( + id bigserial PRIMARY KEY, + hook_table_id integer NOT NULL, + hook_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + request_id bigint +); +CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); +CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); +COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; +CREATE FUNCTION supabase_functions.http_request() +RETURNS trigger +LANGUAGE plpgsql +AS $function$ +DECLARE + request_id bigint; + payload jsonb; + url text := TG_ARGV[0]::text; + method text := TG_ARGV[1]::text; + headers jsonb DEFAULT '{}'::jsonb; + params jsonb DEFAULT '{}'::jsonb; + timeout_ms integer DEFAULT 1000; +BEGIN + IF url IS NULL OR url = 'null' THEN + RAISE EXCEPTION 'url argument is missing'; + END IF; + IF method IS NULL OR method = 'null' THEN + RAISE EXCEPTION 'method argument is missing'; + END IF; + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN + headers = '{"Content-Type": "application/json"}'::jsonb; + ELSE + headers = TG_ARGV[2]::jsonb; + END IF; + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN + params = '{}'::jsonb; + ELSE + params = TG_ARGV[3]::jsonb; + END IF; + IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN + timeout_ms = 1000; + ELSE + timeout_ms = TG_ARGV[4]::integer; + END IF; + CASE + WHEN method = 'GET' THEN + SELECT http_get INTO request_id FROM net.http_get( + url, + params, + headers, + timeout_ms + ); + WHEN method = 'POST' THEN + payload = jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ); + SELECT http_post INTO request_id FROM net.http_post( + url, + payload, + params, + headers, + timeout_ms + ); + ELSE + RAISE EXCEPTION 'method argument % is invalid', method; + END CASE; + INSERT INTO supabase_functions.hooks + (hook_table_id, hook_name, request_id) + VALUES + (TG_RELID, TG_NAME, request_id); + RETURN NEW; +END +$function$; +DO +$$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_functions_admin' + ) + THEN + CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; + END IF; +END +$$; +GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; +ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; +ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; +ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; +ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; +GRANT supabase_functions_admin TO postgres; +DO +$$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_pg_net_admin' + ) + THEN + REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; + DROP OWNED BY supabase_pg_net_admin; + DROP ROLE supabase_pg_net_admin; + END IF; +END +$$; +DO +$$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_extension + WHERE extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; +END +$$; +CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() +RETURNS event_trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; +END; +$$; +COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; +DO +$$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_event_trigger + WHERE evtname = 'issue_pg_net_access' + ) THEN + CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') + EXECUTE PROCEDURE extensions.grant_pg_net_access(); + END IF; +END +$$; +INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); +ALTER function supabase_functions.http_request() SECURITY DEFINER; +ALTER function supabase_functions.http_request() SET search_path = supabase_functions; +REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; +COMMIT; diff --git a/infra/docker/volumes/functions/main/index.ts b/infra/docker/volumes/functions/main/index.ts new file mode 100644 index 0000000..496b68e --- /dev/null +++ b/infra/docker/volumes/functions/main/index.ts @@ -0,0 +1,5 @@ +Deno.serve(() => new Response("Supabase Edge Functions ready", { + headers: { + "content-type": "text/plain", + }, +})) diff --git a/docker/supabase/volumes/logs/vector.yml b/infra/docker/volumes/logs/vector.yml similarity index 60% rename from docker/supabase/volumes/logs/vector.yml rename to infra/docker/volumes/logs/vector.yml index 1dee9e0..da61014 100644 --- a/docker/supabase/volumes/logs/vector.yml +++ b/infra/docker/volumes/logs/vector.yml @@ -1,13 +1,11 @@ api: enabled: true address: 0.0.0.0:9001 - sources: docker_host: type: docker_logs exclude_containers: - supabase-vector - transforms: project_logs: type: remap @@ -44,14 +42,14 @@ transforms: source: |- req, err = parse_nginx_log(.event_message, "combined") if err == null { - .timestamp = req.timestamp - .metadata.request.headers.referer = req.referer - .metadata.request.headers.user_agent = req.agent - .metadata.request.headers.cf_connecting_ip = req.client - .metadata.request.method = req.method - .metadata.request.path = req.path - .metadata.request.protocol = req.protocol - .metadata.response.status_code = req.status + .timestamp = req.timestamp + .metadata.request.headers.referer = req.referer + .metadata.request.headers.user_agent = req.agent + .metadata.request.headers.cf_connecting_ip = req.client + .metadata.request.method = req.method + .metadata.request.path = req.path + .metadata.request.protocol = req.protocol + .metadata.response.status_code = req.status } if err != null { abort @@ -65,16 +63,16 @@ transforms: .metadata.response.status_code = 200 parsed, err = parse_nginx_log(.event_message, "error") if err == null { - .timestamp = parsed.timestamp - .severity = parsed.severity - .metadata.request.host = parsed.host - .metadata.request.headers.cf_connecting_ip = parsed.client - url, err = split(parsed.request, " ") - if err == null { - .metadata.request.method = url[0] - .metadata.request.path = url[1] - .metadata.request.protocol = url[2] - } + .timestamp = parsed.timestamp + .severity = parsed.severity + .metadata.request.host = parsed.host + .metadata.request.headers.cf_connecting_ip = parsed.client + url, err = split(parsed.request, " ") + if err == null { + .metadata.request.method = url[0] + .metadata.request.path = url[1] + .metadata.request.protocol = url[2] + } } if err != null { abort @@ -86,8 +84,8 @@ transforms: source: |- parsed, err = parse_json(.event_message) if err == null { - .metadata.timestamp = parsed.time - .metadata = merge!(.metadata, parsed) + .metadata.timestamp = parsed.time + .metadata = merge!(.metadata, parsed) } rest_logs: type: remap @@ -96,26 +94,21 @@ transforms: source: |- parsed, err = parse_regex(.event_message, r'^(?P