fix(backend): secure production startup and migrations
This commit is contained in:
@@ -98,7 +98,9 @@ Vite 默认地址为 `http://localhost:5173`。登录页的服务器地址默认
|
|||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
后端从 `backend/etc/agent.<mode>.yaml` 读取配置。`SENLIN_APP_MODE` 未设置时使用 `backend/etc/agent.dev.yaml`;例如设置 `$env:SENLIN_APP_MODE='prod'` 时读取 `backend/etc/agent.prod.yaml`,该生产文件需由部署方安全提供。
|
后端从 `backend/etc/agent.<mode>.yaml` 读取配置。`SENLIN_APP_MODE` 未设置时使用 `backend/etc/agent.dev.yaml`;例如设置 `$env:SENLIN_APP_MODE='prod'` 时读取 `backend/etc/agent.prod.yaml`。可复制 `backend/etc/agent.prod.example.yaml` 作为生产配置起点,但必须替换其中的占位值。
|
||||||
|
|
||||||
|
所选 mode 必须与 YAML 的 `env` 一致(`prod` 对应 `production`,`dev` 对应 `development`),不一致时服务拒绝启动。生产模式下,`auth_secret` 与 `ai_key_encryption_secret` 都必须是至少 32 bytes、估算熵至少 128 bits 的独立随机密钥;重复字符、重复短模式、开发占位值和常见口令会被拒绝。可以在 PowerShell 中分别执行两次 `[Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))` 生成两个不同密钥。
|
||||||
|
|
||||||
| 字段 | 用途 |
|
| 字段 | 用途 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
11
backend/etc/agent.prod.example.yaml
Normal file
11
backend/etc/agent.prod.example.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 复制为 agent.prod.yaml 后使用;change-me 值会被生产校验拒绝。
|
||||||
|
env: production
|
||||||
|
port: "9150"
|
||||||
|
dsn: "postgres://change-me"
|
||||||
|
storage_dir: "/var/lib/senlinai/files"
|
||||||
|
max_upload_bytes: 33554432
|
||||||
|
auth_secret: "change-me-with-an-independent-random-secret"
|
||||||
|
system_ai_key: ""
|
||||||
|
ai_key_encryption_secret: "change-me-with-a-different-random-secret"
|
||||||
|
allowed_origins:
|
||||||
|
- "https://workbench.example.com"
|
||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -43,6 +44,17 @@ func LoadFromDir(configDir string) (Config, error) {
|
|||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
selectedEnvironment, err := canonicalEnvironment(mode)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
configuredEnvironment, err := canonicalEnvironment(cfg.Env)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if selectedEnvironment != configuredEnvironment {
|
||||||
|
return Config{}, fmt.Errorf("SENLIN_APP_MODE %s does not match config env %s", mode, cfg.Env)
|
||||||
|
}
|
||||||
if cfg.MaxUploadBytes <= 0 {
|
if cfg.MaxUploadBytes <= 0 {
|
||||||
cfg.MaxUploadBytes = 32 << 20
|
cfg.MaxUploadBytes = 32 << 20
|
||||||
}
|
}
|
||||||
@@ -59,28 +71,57 @@ func LoadFromDir(configDir string) (Config, error) {
|
|||||||
if !hasAllowedOrigin {
|
if !hasAllowedOrigin {
|
||||||
return Config{}, fmt.Errorf("allowed_origins must include at least one origin")
|
return Config{}, fmt.Errorf("allowed_origins must include at least one origin")
|
||||||
}
|
}
|
||||||
if err := validateSecret(cfg.Env, "auth_secret", cfg.AuthSecret); err != nil {
|
production := selectedEnvironment == "production"
|
||||||
|
if err := validateSecret(production, "auth_secret", cfg.AuthSecret); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
if err := validateSecret(cfg.Env, "ai_key_encryption_secret", cfg.AIKeyEncryptionSecret); err != nil {
|
if err := validateSecret(production, "ai_key_encryption_secret", cfg.AIKeyEncryptionSecret); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSecret(environment, field, value string) error {
|
func canonicalEnvironment(value string) (string, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "dev", "development":
|
||||||
|
return "development", nil
|
||||||
|
case "prod", "production":
|
||||||
|
return "production", nil
|
||||||
|
case "test":
|
||||||
|
return "test", nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported environment %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSecret(production bool, field, value string) error {
|
||||||
secret := strings.TrimSpace(value)
|
secret := strings.TrimSpace(value)
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
return fmt.Errorf("%s must not be empty", field)
|
return fmt.Errorf("%s must not be empty", field)
|
||||||
}
|
}
|
||||||
if strings.EqualFold(strings.TrimSpace(environment), "production") || strings.EqualFold(strings.TrimSpace(environment), "prod") {
|
if production {
|
||||||
if len(secret) < 32 || isCommonSecret(secret) {
|
if len([]byte(secret)) < 32 || estimatedEntropyBits(secret) < 128 || isCommonSecret(secret) {
|
||||||
return fmt.Errorf("%s must be at least 32 characters and must not use a development sentinel in production", field)
|
return fmt.Errorf("%s must contain at least 32 bytes and an estimated 128 bits of entropy, without development sentinels, in production", field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func estimatedEntropyBits(value string) float64 {
|
||||||
|
data := []byte(value)
|
||||||
|
counts := make(map[byte]int, len(data))
|
||||||
|
for _, item := range data {
|
||||||
|
counts[item]++
|
||||||
|
}
|
||||||
|
length := float64(len(data))
|
||||||
|
entropyPerByte := 0.0
|
||||||
|
for _, count := range counts {
|
||||||
|
probability := float64(count) / length
|
||||||
|
entropyPerByte -= probability * math.Log2(probability)
|
||||||
|
}
|
||||||
|
return entropyPerByte * length
|
||||||
|
}
|
||||||
|
|
||||||
func isCommonSecret(value string) bool {
|
func isCommonSecret(value string) bool {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
for _, marker := range []string{"change-me", "changeme", "development", "dev-secret", "local-secret", "test-secret", "placeholder"} {
|
for _, marker := range []string{"change-me", "changeme", "development", "dev-secret", "local-secret", "test-secret", "placeholder"} {
|
||||||
|
|||||||
@@ -74,6 +74,28 @@ func TestLoadFromDirUsesSENLINAppMode(t *testing.T) {
|
|||||||
require.Equal(t, []string{"https://workbench.example.com"}, cfg.AllowedOrigins)
|
require.Equal(t, []string{"https://workbench.example.com"}, cfg.AllowedOrigins)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadFromDirRejectsProductionModeWithDevelopmentEnvironment(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
writeConfig(t, configDir, "agent.prod.yaml", "development", "80", "postgres://prod", "/data/files", "production-auth-signing-key-2026-safe", "", "production-ai-encryption-key-2026-safe")
|
||||||
|
t.Setenv("SENLIN_APP_MODE", "prod")
|
||||||
|
|
||||||
|
_, err := LoadFromDir(configDir)
|
||||||
|
|
||||||
|
require.ErrorContains(t, err, "SENLIN_APP_MODE prod")
|
||||||
|
require.ErrorContains(t, err, "env development")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFromDirRejectsModeEnvironmentMismatchOutsideProduction(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
writeConfig(t, configDir, "agent.dev.yaml", "production", "9150", "postgres://dev", "./files", "production-auth-signing-key-2026-safe", "", "production-ai-encryption-key-2026-safe")
|
||||||
|
t.Setenv("SENLIN_APP_MODE", "dev")
|
||||||
|
|
||||||
|
_, err := LoadFromDir(configDir)
|
||||||
|
|
||||||
|
require.ErrorContains(t, err, "SENLIN_APP_MODE dev")
|
||||||
|
require.ErrorContains(t, err, "env production")
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadFromDirRejectsMissingStorageDir(t *testing.T) {
|
func TestLoadFromDirRejectsMissingStorageDir(t *testing.T) {
|
||||||
configDir := t.TempDir()
|
configDir := t.TempDir()
|
||||||
writeConfig(t, configDir, "agent.dev.yaml", "development", "9150", "postgres://agent", "", "dev-auth", "", "dev-ai")
|
writeConfig(t, configDir, "agent.dev.yaml", "development", "9150", "postgres://agent", "", "dev-auth", "", "dev-ai")
|
||||||
@@ -111,9 +133,12 @@ func TestLoadFromDirRejectsUnsafeProductionSecrets(t *testing.T) {
|
|||||||
{name: "short auth", authSecret: "short", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
|
{name: "short auth", authSecret: "short", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
|
||||||
{name: "development auth sentinel", authSecret: "development-auth-secret-change-me", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
|
{name: "development auth sentinel", authSecret: "development-auth-secret-change-me", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
|
||||||
{name: "dev auth sentinel", authSecret: "dev-secret-dev-secret-dev-secret-000", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
|
{name: "dev auth sentinel", authSecret: "dev-secret-dev-secret-dev-secret-000", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
|
||||||
|
{name: "repeated auth character", authSecret: strings.Repeat("x", 32), encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
|
||||||
|
{name: "repeated auth pattern", authSecret: strings.Repeat("abcd", 8), encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
|
||||||
{name: "empty encryption", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "", expectedFieldName: "ai_key_encryption_secret"},
|
{name: "empty encryption", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "", expectedFieldName: "ai_key_encryption_secret"},
|
||||||
{name: "short encryption", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "short", expectedFieldName: "ai_key_encryption_secret"},
|
{name: "short encryption", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "short", expectedFieldName: "ai_key_encryption_secret"},
|
||||||
{name: "common encryption sentinel", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "change-me-change-me-change-me-change-me", expectedFieldName: "ai_key_encryption_secret"},
|
{name: "common encryption sentinel", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "change-me-change-me-change-me-change-me", expectedFieldName: "ai_key_encryption_secret"},
|
||||||
|
{name: "repeated encryption character", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: strings.Repeat("9", 64), expectedFieldName: "ai_key_encryption_secret"},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -5,23 +5,19 @@ package ai
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
|
"senlinai-agent/backend/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPostgresReserveRateLimitIsAtomicAcrossConcurrentConnections(t *testing.T) {
|
func TestPostgresReserveRateLimitIsAtomicAcrossConcurrentConnections(t *testing.T) {
|
||||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
database := testutil.OpenIsolatedPostgres(t, &gorm.Config{TranslateError: true, Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database")
|
|
||||||
database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true, Logger: logger.Default.LogMode(logger.Silent)})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, models.AutoMigrate(database))
|
require.NoError(t, models.AutoMigrate(database))
|
||||||
models.DBService = database
|
models.DBService = database
|
||||||
suffix := fmt.Sprint(time.Now().UnixNano())
|
suffix := fmt.Sprint(time.Now().UnixNano())
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ package search
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
|
"senlinai-agent/backend/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPostgresSearchMatchesChinesePartialKeywords(t *testing.T) {
|
func TestPostgresSearchMatchesChinesePartialKeywords(t *testing.T) {
|
||||||
@@ -111,13 +110,7 @@ func TestPostgresSearchUsesStableFairLimitAndRuneBoundedSnippets(t *testing.T) {
|
|||||||
|
|
||||||
func newPostgresSearchTestDB(t *testing.T) *gorm.DB {
|
func newPostgresSearchTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
database := testutil.OpenIsolatedPostgres(t, &gorm.Config{TranslateError: true})
|
||||||
require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database")
|
require.NoError(t, models.AutoMigrate(database))
|
||||||
database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
return database
|
||||||
require.NoError(t, err)
|
|
||||||
transaction := database.Begin()
|
|
||||||
require.NoError(t, transaction.Error)
|
|
||||||
t.Cleanup(func() { transaction.Rollback() })
|
|
||||||
require.NoError(t, models.AutoMigrate(transaction))
|
|
||||||
return transaction
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,18 @@ package tasks
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
|
"senlinai-agent/backend/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPostgresMovePreventsOldProjectShareFromBeingInsertedConcurrently(t *testing.T) {
|
func TestPostgresMovePreventsOldProjectShareFromBeingInsertedConcurrently(t *testing.T) {
|
||||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
database := testutil.OpenIsolatedPostgres(t, &gorm.Config{TranslateError: true})
|
||||||
require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database")
|
|
||||||
database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, models.AutoMigrate(database))
|
require.NoError(t, models.AutoMigrate(database))
|
||||||
suffix := fmt.Sprint(time.Now().UnixNano())
|
suffix := fmt.Sprint(time.Now().UnixNano())
|
||||||
owner := models.SenlinAgentUser{Email: "lock-" + suffix + "@example.com", DisplayName: "Lock Owner", PasswordHash: "hash"}
|
owner := models.SenlinAgentUser{Email: "lock-" + suffix + "@example.com", DisplayName: "Lock Owner", PasswordHash: "hash"}
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ func TestAutoMigrateUpgradesLegacyProjectsAndTagsWithoutLosingAssociations(t *te
|
|||||||
require.Error(t, database.Exec(`INSERT INTO senlin_agent_tags (project_id, name) VALUES (1, 'UI')`).Error)
|
require.Error(t, database.Exec(`INSERT INTO senlin_agent_tags (project_id, name) VALUES (1, 'UI')`).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMigrationAdvisoryLockIsRequiredOnlyForPostgres(t *testing.T) {
|
||||||
|
require.True(t, migrationAdvisoryLockRequired("postgres"))
|
||||||
|
require.False(t, migrationAdvisoryLockRequired("sqlite"))
|
||||||
|
require.False(t, migrationAdvisoryLockRequired("mysql"))
|
||||||
|
}
|
||||||
|
|
||||||
func projectIdentifiers(projects []SenlinAgentProject) []string {
|
func projectIdentifiers(projects []SenlinAgentProject) []string {
|
||||||
result := make([]string, 0, len(projects))
|
result := make([]string, 0, len(projects))
|
||||||
for _, project := range projects {
|
for _, project := range projects {
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
var DBService *gorm.DB
|
var DBService *gorm.DB
|
||||||
|
|
||||||
|
// migrationAdvisoryLockKey scopes the PostgreSQL session lock to SenlinAI schema changes.
|
||||||
|
const migrationAdvisoryLockKey int64 = 0x53454e4c494e4149
|
||||||
|
|
||||||
func New(dsn string) error {
|
func New(dsn string) error {
|
||||||
// 启用 Gorm 跨驱动错误翻译,业务层无需解析 Postgres 错误文本或 SQLSTATE。
|
// 启用 Gorm 跨驱动错误翻译,业务层无需解析 Postgres 错误文本或 SQLSTATE。
|
||||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
||||||
@@ -23,6 +28,29 @@ func New(dsn string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func AutoMigrate(database *gorm.DB) error {
|
func AutoMigrate(database *gorm.DB) error {
|
||||||
|
if !migrationAdvisoryLockRequired(database.Dialector.Name()) {
|
||||||
|
return autoMigrateUnlocked(database)
|
||||||
|
}
|
||||||
|
// Connection pins every migration statement to one PostgreSQL session so the
|
||||||
|
// advisory lock covers both Gorm's schema work and the versioned data repairs.
|
||||||
|
return database.Connection(func(connection *gorm.DB) (resultErr error) {
|
||||||
|
if err := connection.Exec("SELECT pg_advisory_lock(?)", migrationAdvisoryLockKey).Error; err != nil {
|
||||||
|
return fmt.Errorf("acquire schema migration advisory lock: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := connection.Exec("SELECT pg_advisory_unlock(?)", migrationAdvisoryLockKey).Error; resultErr == nil && err != nil {
|
||||||
|
resultErr = fmt.Errorf("release schema migration advisory lock: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return autoMigrateUnlocked(connection)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrationAdvisoryLockRequired(dialect string) bool {
|
||||||
|
return dialect == "postgres"
|
||||||
|
}
|
||||||
|
|
||||||
|
func autoMigrateUnlocked(database *gorm.DB) error {
|
||||||
if err := database.AutoMigrate(
|
if err := database.AutoMigrate(
|
||||||
&SenlinAgentSchemaMigration{},
|
&SenlinAgentSchemaMigration{},
|
||||||
&SenlinAgentUser{},
|
&SenlinAgentUser{},
|
||||||
|
|||||||
@@ -3,20 +3,15 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"senlinai-agent/backend/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPostgresPing(t *testing.T) {
|
func TestPostgresPing(t *testing.T) {
|
||||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
database := testutil.OpenIsolatedPostgres(t, &gorm.Config{TranslateError: true})
|
||||||
require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database")
|
|
||||||
|
|
||||||
database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
|
||||||
require.NoError(t, err)
|
|
||||||
sqlDB, err := database.DB()
|
sqlDB, err := database.DB()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, sqlDB.Ping())
|
require.NoError(t, sqlDB.Ping())
|
||||||
|
|||||||
@@ -5,17 +5,16 @@ package seed
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
|
"senlinai-agent/backend/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type demoSeedRunContextKey struct{}
|
type demoSeedRunContextKey struct{}
|
||||||
@@ -28,13 +27,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestPostgresDemoSeedSerializesConcurrentRunsForSameOwner(t *testing.T) {
|
func TestPostgresDemoSeedSerializesConcurrentRunsForSameOwner(t *testing.T) {
|
||||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
database := testutil.OpenIsolatedPostgres(t, &gorm.Config{
|
||||||
require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database")
|
|
||||||
database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
|
||||||
TranslateError: true,
|
TranslateError: true,
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, models.AutoMigrate(database))
|
require.NoError(t, models.AutoMigrate(database))
|
||||||
|
|
||||||
suffix := fmt.Sprint(time.Now().UnixNano())
|
suffix := fmt.Sprint(time.Now().UnixNano())
|
||||||
|
|||||||
68
backend/internal/testutil/postgres_integration.go
Normal file
68
backend/internal/testutil/postgres_integration.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// Package testutil contains opt-in helpers for isolated integration databases.
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenIsolatedPostgres creates a random schema and binds every pooled test
|
||||||
|
// connection to it through search_path. It never reads the production DSN.
|
||||||
|
func OpenIsolatedPostgres(t testing.TB, config *gorm.Config) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
|
||||||
|
require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database")
|
||||||
|
|
||||||
|
schema := randomSchemaName(t)
|
||||||
|
admin, err := gorm.Open(postgres.Open(dsn), config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
adminSQL, err := admin.DB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, admin.Exec(fmt.Sprintf(`CREATE SCHEMA "%s"`, schema)).Error)
|
||||||
|
|
||||||
|
schemaDSN, err := postgresSchemaDSN(dsn, schema)
|
||||||
|
require.NoError(t, err)
|
||||||
|
database, err := gorm.Open(postgres.Open(schemaDSN), config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
isolationSQL, err := database.DB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = isolationSQL.Close()
|
||||||
|
_ = admin.Exec(fmt.Sprintf(`DROP SCHEMA IF EXISTS "%s" CASCADE`, schema)).Error
|
||||||
|
_ = adminSQL.Close()
|
||||||
|
})
|
||||||
|
return database
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomSchemaName(t testing.TB) string {
|
||||||
|
t.Helper()
|
||||||
|
value := make([]byte, 12)
|
||||||
|
_, err := rand.Read(value)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return "senlin_test_" + hex.EncodeToString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func postgresSchemaDSN(dsn, schema string) (string, error) {
|
||||||
|
if strings.Contains(dsn, "://") {
|
||||||
|
parsed, err := url.Parse(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
query := parsed.Query()
|
||||||
|
query.Set("search_path", schema)
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(dsn) + " search_path=" + schema, nil
|
||||||
|
}
|
||||||
20
backend/internal/testutil/postgres_integration_test.go
Normal file
20
backend/internal/testutil/postgres_integration_test.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostgresSchemaDSNSupportsURLAndKeywordFormats(t *testing.T) {
|
||||||
|
urlDSN, err := postgresSchemaDSN("postgres://user:pass@localhost:5432/db?sslmode=disable", "senlin_test_abc")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, urlDSN, "sslmode=disable")
|
||||||
|
require.Contains(t, urlDSN, "search_path=senlin_test_abc")
|
||||||
|
|
||||||
|
keywordDSN, err := postgresSchemaDSN("host=localhost dbname=db sslmode=disable", "senlin_test_abc")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "host=localhost dbname=db sslmode=disable search_path=senlin_test_abc", keywordDSN)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user