1077 lines
29 KiB
Go
1077 lines
29 KiB
Go
package storage_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
"gorm.io/gorm"
|
|
|
|
"gpt-load/internal/platform/config"
|
|
"gpt-load/internal/storage"
|
|
"gpt-load/internal/storage/models"
|
|
)
|
|
|
|
func TestCredentialStatusAcceptsOnlyDurableOperatorStates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openMigratedDatabase(t)
|
|
group := models.Group{
|
|
Name: "credential-status-parent", ChannelID: "openai_compatible",
|
|
Params: models.JSON(`{"base_url":"https://credential-status.example.com"}`), Models: models.JSON(`[]`),
|
|
}
|
|
if err := db.Create(&group).Error; err != nil {
|
|
t.Fatalf("create parent group: %v", err)
|
|
}
|
|
for index, status := range []models.CredentialStatus{
|
|
models.CredentialStatusActive,
|
|
models.CredentialStatusDisabled,
|
|
} {
|
|
credential := models.Credential{
|
|
GroupID: group.ID, Data: "encrypted-data",
|
|
Fingerprint: "allowed-credential-status-" + string(rune('a'+index)),
|
|
Status: status,
|
|
}
|
|
if err := db.Create(&credential).Error; err != nil {
|
|
t.Fatalf("create credential with status %q: %v", status, err)
|
|
}
|
|
}
|
|
invalid := models.Credential{
|
|
GroupID: group.ID, Data: "encrypted-data", Fingerprint: "invalid-credential-status",
|
|
Status: models.CredentialStatus("blacklisted"),
|
|
}
|
|
if err := db.Create(&invalid).Error; err == nil {
|
|
t.Fatal("create credential with runtime-only blacklisted status error = nil, want constraint error")
|
|
}
|
|
}
|
|
|
|
func TestGroupNormalizesMissingChannelParamsToEmptyObject(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openMigratedDatabase(t)
|
|
group := models.Group{
|
|
Name: "normalized-channel-params", ChannelID: "staged-channel",
|
|
Models: models.JSON(`[]`),
|
|
}
|
|
if err := db.Create(&group).Error; err != nil {
|
|
t.Fatalf("create group without explicit channel params: %v", err)
|
|
}
|
|
var params string
|
|
if err := db.Table("groups").Select("params").Where("id = ?", group.ID).Scan(¶ms).Error; err != nil {
|
|
t.Fatalf("load normalized channel params: %v", err)
|
|
}
|
|
if params != `{}` {
|
|
t.Fatalf("normalized channel params = %q, want {}", params)
|
|
}
|
|
}
|
|
|
|
func TestAccessKeyStatusAcceptsOnlyDurableOperatorStates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openMigratedDatabase(t)
|
|
for index, status := range []string{"active", "disabled"} {
|
|
key := models.AccessKey{
|
|
Name: "allowed-" + string(rune('a'+index)),
|
|
KeyValue: "ciphertext",
|
|
KeyHash: "allowed-status-" + string(rune('a'+index)),
|
|
KeySuffix: "7f2a",
|
|
Status: status,
|
|
Filters: models.JSON(`{}`),
|
|
}
|
|
if err := db.Create(&key).Error; err != nil {
|
|
t.Fatalf("create access key with status %q: %v", status, err)
|
|
}
|
|
}
|
|
|
|
invalid := models.AccessKey{
|
|
Name: "invalid",
|
|
KeyValue: "ciphertext",
|
|
KeyHash: "invalid-status",
|
|
KeySuffix: "7f2a",
|
|
Status: "blacklisted",
|
|
Filters: models.JSON(`{}`),
|
|
}
|
|
if err := db.Create(&invalid).Error; err == nil {
|
|
t.Fatal("blacklisted status error = nil, want CHECK constraint error")
|
|
}
|
|
}
|
|
|
|
func TestAutoMigrateCreatesReviewedIndexesAndPrimaryKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openMigratedDatabase(t)
|
|
type pragmaColumn struct {
|
|
Name string
|
|
NotNull int `gorm:"column:notnull"`
|
|
}
|
|
type pragmaIndex struct {
|
|
Name string
|
|
}
|
|
|
|
for _, table := range []string{"request_logs", "jobs", "system_settings"} {
|
|
var columns []pragmaColumn
|
|
if err := db.Raw("PRAGMA table_info('" + table + "')").Scan(&columns).Error; err != nil {
|
|
t.Fatalf("inspect %s columns: %v", table, err)
|
|
}
|
|
|
|
var found bool
|
|
for _, column := range columns {
|
|
keyName := "id"
|
|
if table == "system_settings" {
|
|
keyName = "key"
|
|
}
|
|
if column.Name == keyName {
|
|
found = true
|
|
if column.NotNull != 1 {
|
|
t.Errorf("%s.%s notnull = %d, want 1", table, keyName, column.NotNull)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("%s does not contain primary key column", table)
|
|
}
|
|
}
|
|
|
|
if db.Migrator().HasTable("upstream_keys") {
|
|
t.Fatal("retired upstream_keys table exists")
|
|
}
|
|
for _, table := range []string{"credentials", "access_keys"} {
|
|
var indexes []pragmaIndex
|
|
if err := db.Raw("PRAGMA index_list('" + table + "')").Scan(&indexes).Error; err != nil {
|
|
t.Fatalf("inspect %s indexes: %v", table, err)
|
|
}
|
|
for _, index := range indexes {
|
|
if index.Name == "idx_"+table+"_status" {
|
|
t.Errorf("%s has ordinary status index %q", table, index.Name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenRejectsInvalidDSN(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
dsn string
|
|
}{
|
|
{name: "empty", dsn: ""},
|
|
{name: "unsupported scheme", dsn: "redis://localhost/gpt-load"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if _, err := storage.Open(tt.dsn); err == nil {
|
|
t.Fatalf("Open(%q) error = nil, want a validation error", tt.dsn)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenCreatesSQLiteDatabase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dsn := filepath.Join(t.TempDir(), "nested", "gpt-load.db")
|
|
db, err := storage.OpenWithSource(dsn, config.DatabaseSourceManaged)
|
|
if err != nil {
|
|
t.Fatalf("OpenWithSource(%q, managed) error = %v", dsn, err)
|
|
}
|
|
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("db.DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
|
|
if err := sqlDB.Ping(); err != nil {
|
|
t.Fatalf("Ping() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenWithSourceExternalRejectsMissingParentWithoutCreation(t *testing.T) {
|
|
root := t.TempDir()
|
|
parent := filepath.Join(root, "operator-must-create")
|
|
dsn := filepath.Join(parent, "external.db")
|
|
|
|
db, err := storage.OpenWithSource(dsn, config.DatabaseSourceExternal)
|
|
if db != nil {
|
|
sqlDB, sqlErr := db.DB()
|
|
if sqlErr == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
}
|
|
if err == nil {
|
|
t.Fatal("OpenWithSource(external) error = nil, want missing-parent rejection")
|
|
}
|
|
if _, statErr := os.Stat(parent); !os.IsNotExist(statErr) {
|
|
t.Fatalf("external parent stat error = %v, want not exist", statErr)
|
|
}
|
|
}
|
|
|
|
func TestOpenRejectsLegacyFileBeforeRuntimePragmas(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "legacy.db")
|
|
db, err := storage.Open(path)
|
|
if err != nil {
|
|
t.Fatalf("create database: %v", err)
|
|
}
|
|
if err := db.Exec("CREATE TABLE legacy_data (id integer PRIMARY KEY)").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Remove(path + "-wal"); err != nil && !os.IsNotExist(err) {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Remove(path + "-shm"); err != nil && !os.IsNotExist(err) {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := storage.Open(path); err == nil || !strings.Contains(err.Error(), "without schema_migrations") {
|
|
t.Fatalf("Open(legacy database) error = %v, want legacy database rejection", err)
|
|
}
|
|
for _, suffix := range []string{"-wal", "-shm"} {
|
|
if _, err := os.Stat(path + suffix); !os.IsNotExist(err) {
|
|
t.Fatalf("legacy preflight created %s", path+suffix)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenReopensMigratedRelativeDatabase(t *testing.T) {
|
|
workingDir := t.TempDir()
|
|
previousWorkingDir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(workingDir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := os.Chdir(previousWorkingDir); err != nil {
|
|
t.Errorf("restore working directory: %v", err)
|
|
}
|
|
})
|
|
if err := os.Mkdir("data", 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
const dsn = "data/gpt-load.db"
|
|
first, err := storage.Open(dsn)
|
|
if err != nil {
|
|
t.Fatalf("first Open(relative DSN) error = %v", err)
|
|
}
|
|
if err := storage.AutoMigrate(first); err != nil {
|
|
t.Fatalf("AutoMigrate() error = %v", err)
|
|
}
|
|
firstSQL, err := first.DB()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := firstSQL.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
second, err := storage.Open(dsn)
|
|
if err != nil {
|
|
t.Fatalf("second Open(relative DSN) error = %v", err)
|
|
}
|
|
secondSQL, err := second.DB()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := secondSQL.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestOpenWithSourceExternalHintDoesNotExposeDSN(t *testing.T) {
|
|
const sensitiveFilename = "distinctive-operator-secret.db"
|
|
dsn := filepath.Join(t.TempDir(), sensitiveFilename)
|
|
|
|
var logs bytes.Buffer
|
|
standard := logrus.StandardLogger()
|
|
previousOutput := standard.Out
|
|
standard.SetOutput(&logs)
|
|
t.Cleanup(func() {
|
|
standard.SetOutput(previousOutput)
|
|
})
|
|
|
|
db, err := storage.OpenWithSource(dsn, config.DatabaseSourceExternal)
|
|
if err != nil {
|
|
t.Fatalf("OpenWithSource(external) error = %v", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
|
|
output := logs.String()
|
|
if !strings.Contains(output, "database_source=external") {
|
|
t.Fatalf("external storage hint = %q, want database_source=external", output)
|
|
}
|
|
for _, forbidden := range []string{dsn, sensitiveFilename} {
|
|
if strings.Contains(output, forbidden) {
|
|
t.Fatalf("external storage hint exposed %q: %s", forbidden, output)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenWithSourceFileModeMemoryURIStaysInMemory(t *testing.T) {
|
|
databasePath := filepath.Join(t.TempDir(), "named-memory.db")
|
|
dsn := "file:" + filepath.ToSlash(databasePath) + "?mode=memory&cache=shared"
|
|
|
|
db, err := storage.OpenWithSource(dsn, config.DatabaseSourceManaged)
|
|
if err != nil {
|
|
t.Fatalf("OpenWithSource(file mode=memory) error = %v", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
assertSQLiteMemoryDatabase(t, db)
|
|
for _, path := range []string{
|
|
databasePath,
|
|
databasePath + "-wal",
|
|
databasePath + "-shm",
|
|
} {
|
|
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
|
t.Fatalf("memory URI path stat error = %v, want not exist", statErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenWithSourceColonMemoryQueryStaysInMemory(t *testing.T) {
|
|
db, err := storage.OpenWithSource(
|
|
":memory:?cache=shared",
|
|
config.DatabaseSourceExternal,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("OpenWithSource(:memory:?cache=shared) error = %v", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
assertSQLiteMemoryDatabase(t, db)
|
|
}
|
|
|
|
func assertSQLiteMemoryDatabase(t *testing.T, db *gorm.DB) {
|
|
t.Helper()
|
|
|
|
if err := db.Exec(
|
|
"CREATE TABLE memory_probe (value TEXT NOT NULL)",
|
|
).Error; err != nil {
|
|
t.Fatalf("create memory probe table: %v", err)
|
|
}
|
|
if err := db.Exec(
|
|
"INSERT INTO memory_probe(value) VALUES ('probe')",
|
|
).Error; err != nil {
|
|
t.Fatalf("write memory probe: %v", err)
|
|
}
|
|
|
|
var databases []struct {
|
|
Name string
|
|
File string
|
|
}
|
|
if err := db.Raw("PRAGMA database_list").Scan(&databases).Error; err != nil {
|
|
t.Fatalf("read database list: %v", err)
|
|
}
|
|
for _, database := range databases {
|
|
if database.Name == "main" {
|
|
if database.File != "" {
|
|
t.Fatalf("main database file = %q, want in-memory database", database.File)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("PRAGMA database_list did not contain main database")
|
|
}
|
|
|
|
func TestOpenOverridesSQLiteRuntimeOptions(t *testing.T) {
|
|
dsn := filepath.Join(t.TempDir(), "runtime.db") +
|
|
"?_txlock=deferred&_pragma=foreign_keys(0)" +
|
|
"&_pragma=busy_timeout(1)&_pragma=journal_mode(DELETE)"
|
|
db, err := storage.Open(dsn)
|
|
if err != nil {
|
|
t.Fatalf("Open() error = %v", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
|
|
var journalMode string
|
|
if err := db.Raw("PRAGMA journal_mode").Scan(&journalMode).Error; err != nil {
|
|
t.Fatalf("journal_mode: %v", err)
|
|
}
|
|
var foreignKeys, busyTimeout int
|
|
if err := db.Raw("PRAGMA foreign_keys").Scan(&foreignKeys).Error; err != nil {
|
|
t.Fatalf("foreign_keys: %v", err)
|
|
}
|
|
if err := db.Raw("PRAGMA busy_timeout").Scan(&busyTimeout).Error; err != nil {
|
|
t.Fatalf("busy_timeout: %v", err)
|
|
}
|
|
if !strings.EqualFold(journalMode, "wal") || foreignKeys != 1 || busyTimeout != 5000 {
|
|
t.Fatalf("runtime = journal:%q foreign_keys:%d busy_timeout:%d", journalMode, foreignKeys, busyTimeout)
|
|
}
|
|
if got := sqlDB.Stats().MaxOpenConnections; got != 1 {
|
|
t.Fatalf("MaxOpenConnections = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenDoesNotForceWALForMemoryDatabase(t *testing.T) {
|
|
db, err := storage.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Open() error = %v", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
var mode string
|
|
if err := db.Raw("PRAGMA journal_mode").Scan(&mode).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.EqualFold(mode, "wal") || sqlDB.Stats().MaxOpenConnections != 1 {
|
|
t.Fatalf("memory mode/pool = %q/%d", mode, sqlDB.Stats().MaxOpenConnections)
|
|
}
|
|
}
|
|
|
|
func TestOpenUsesImmediateTransactions(t *testing.T) {
|
|
dsn := filepath.Join(t.TempDir(), "immediate.db")
|
|
appDB, err := storage.Open(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
appSQL, err := appDB.DB()
|
|
if err != nil {
|
|
t.Fatalf("app DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := appSQL.Close(); err != nil {
|
|
t.Errorf("close app database: %v", err)
|
|
}
|
|
})
|
|
if err := storage.AutoMigrate(appDB); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := appDB.Exec("PRAGMA busy_timeout = 1").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
blockerDB, err := storage.Open(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
blockerSQL, err := blockerDB.DB()
|
|
if err != nil {
|
|
t.Fatalf("blocker DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := blockerSQL.Close(); err != nil {
|
|
t.Errorf("close blocker database: %v", err)
|
|
}
|
|
})
|
|
blockerTx := blockerDB.Begin()
|
|
if blockerTx.Error != nil {
|
|
t.Fatal(blockerTx.Error)
|
|
}
|
|
blockerTxActive := true
|
|
t.Cleanup(func() {
|
|
if !blockerTxActive {
|
|
return
|
|
}
|
|
if err := blockerTx.Rollback().Error; err != nil {
|
|
t.Errorf("rollback blocker transaction during cleanup: %v", err)
|
|
}
|
|
})
|
|
if err := blockerTx.Exec("UPDATE schema_migrations SET id = id").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
callbackEntered := false
|
|
err = appDB.Transaction(func(*gorm.DB) error {
|
|
callbackEntered = true
|
|
return nil
|
|
})
|
|
if err == nil || callbackEntered {
|
|
t.Fatalf("Transaction() error/callback = %v/%t, want lock before callback", err, callbackEntered)
|
|
}
|
|
if err := blockerTx.Rollback().Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
blockerTxActive = false
|
|
callbackEntered = false
|
|
if err := appDB.Transaction(func(*gorm.DB) error {
|
|
callbackEntered = true
|
|
return nil
|
|
}); err != nil || !callbackEntered {
|
|
t.Fatalf("Transaction() after release = %v/%t", err, callbackEntered)
|
|
}
|
|
}
|
|
|
|
func TestOpenConfiguresParameterizedSQLLogging(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, err := storage.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Open(:memory:) error = %v", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("db.DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
|
|
filter, ok := db.Logger.(gorm.ParamsFilter)
|
|
if !ok {
|
|
t.Fatalf("database logger %T does not implement gorm.ParamsFilter", db.Logger)
|
|
}
|
|
const query = "INSERT INTO secrets(value) VALUES (?)"
|
|
const secret = "known-plaintext-secret"
|
|
filteredQuery, params := filter.ParamsFilter(context.Background(), query, secret)
|
|
if filteredQuery != query {
|
|
t.Fatalf("filtered query = %q, want %q", filteredQuery, query)
|
|
}
|
|
if len(params) != 0 {
|
|
t.Fatalf("parameterized SQL logger retained %d parameter(s), want 0", len(params))
|
|
}
|
|
}
|
|
|
|
func TestAutoMigrateCreatesUsageJournalAndMigrationLedger(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openMigratedDatabase(t)
|
|
|
|
wantTables := []string{
|
|
"groups",
|
|
"credentials",
|
|
"access_keys",
|
|
"request_logs",
|
|
"usage_aggregation_journal",
|
|
"usage_stats",
|
|
"model_prices",
|
|
"system_settings",
|
|
"jobs",
|
|
"control_operations",
|
|
"schema_migrations",
|
|
}
|
|
for _, table := range wantTables {
|
|
if !db.Migrator().HasTable(table) {
|
|
t.Errorf("AutoMigrate() did not create table %q", table)
|
|
}
|
|
}
|
|
|
|
var migrationIDs []string
|
|
if err := db.Table("schema_migrations").Order("id ASC").Pluck("id", &migrationIDs).Error; err != nil {
|
|
t.Fatalf("read schema_migrations: %v", err)
|
|
}
|
|
wantMigrationIDs := []string{
|
|
"0001_final_v2",
|
|
"0002_request_log_execution_observation",
|
|
"0003_request_log_conversion_trace",
|
|
"0004_remove_request_log_conversion_trace",
|
|
}
|
|
if !reflect.DeepEqual(migrationIDs, wantMigrationIDs) {
|
|
t.Fatalf("schema_migrations IDs = %v, want %v", migrationIDs, wantMigrationIDs)
|
|
}
|
|
|
|
if err := storage.AutoMigrate(db); err != nil {
|
|
t.Fatalf("second AutoMigrate() error = %v", err)
|
|
}
|
|
var count int64
|
|
if err := db.Table("schema_migrations").Count(&count).Error; err != nil {
|
|
t.Fatalf("count schema_migrations: %v", err)
|
|
}
|
|
if count != int64(len(wantMigrationIDs)) {
|
|
t.Fatalf("schema_migrations row count after a second migration = %d, want %d", count, len(wantMigrationIDs))
|
|
}
|
|
}
|
|
|
|
func TestAutoMigrateRejectsRetiredV2MigrationLedger(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, err := storage.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Open(:memory:) error = %v", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("db.DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
|
|
if err := db.Exec(`CREATE TABLE schema_migrations (
|
|
id varchar(255) PRIMARY KEY NOT NULL
|
|
)`).Error; err != nil {
|
|
t.Fatalf("create legacy migration ledger: %v", err)
|
|
}
|
|
if err := db.Exec("INSERT INTO schema_migrations(id) VALUES (?)", "0001_initial_v2").Error; err != nil {
|
|
t.Fatalf("seed legacy migration ledger: %v", err)
|
|
}
|
|
|
|
err = storage.AutoMigrate(db)
|
|
if err == nil || !strings.Contains(err.Error(), "unknown or non-contiguous migration") {
|
|
t.Fatalf("AutoMigrate() error = %v, want retired v2 ledger rejection", err)
|
|
}
|
|
if db.Migrator().HasTable("groups") {
|
|
t.Fatal("AutoMigrate() modified a retired v2 database")
|
|
}
|
|
}
|
|
|
|
func TestAutoMigrateRejectsEmptyLedgerBesideExistingApplicationTables(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, err := storage.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Open(:memory:) error = %v", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("db.DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
|
|
if err := db.Exec(`CREATE TABLE schema_migrations (
|
|
id varchar(255) PRIMARY KEY NOT NULL
|
|
)`).Error; err != nil {
|
|
t.Fatalf("create empty migration ledger: %v", err)
|
|
}
|
|
if err := db.Exec("CREATE TABLE legacy_data (id INTEGER PRIMARY KEY)").Error; err != nil {
|
|
t.Fatalf("create legacy table: %v", err)
|
|
}
|
|
|
|
err = storage.AutoMigrate(db)
|
|
if err == nil || !strings.Contains(err.Error(), "empty migration ledger beside existing tables") {
|
|
t.Fatalf("AutoMigrate() error = %v, want existing table rejection", err)
|
|
}
|
|
if db.Migrator().HasTable("groups") {
|
|
t.Fatal("AutoMigrate() modified a database containing pre-existing tables")
|
|
}
|
|
}
|
|
|
|
func TestAutoMigrateCreatesRequestLogFieldsAndCompositeIndexes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dsn := filepath.Join(t.TempDir(), "fresh-request-log-v1.db")
|
|
db, err := storage.Open(dsn)
|
|
if err != nil {
|
|
t.Fatalf("Open(%q) error = %v", dsn, err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("db.DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
if err := storage.AutoMigrate(db); err != nil {
|
|
t.Fatalf("AutoMigrate() error = %v", err)
|
|
}
|
|
|
|
type columnInfo struct {
|
|
Name string
|
|
}
|
|
var columns []columnInfo
|
|
if err := db.Raw("PRAGMA table_info('request_logs')").Scan(&columns).Error; err != nil {
|
|
t.Fatalf("inspect request_logs columns: %v", err)
|
|
}
|
|
columnNames := make(map[string]struct{}, len(columns))
|
|
for _, column := range columns {
|
|
columnNames[column.Name] = struct{}{}
|
|
}
|
|
for _, name := range []string{
|
|
"error_code",
|
|
"error_summary",
|
|
"reasoning_mode",
|
|
"reasoning_effort",
|
|
"reasoning_budget_tokens",
|
|
"upstream_reported_model",
|
|
"model_consistency",
|
|
} {
|
|
if _, ok := columnNames[name]; !ok {
|
|
t.Errorf("request_logs column %q is missing", name)
|
|
}
|
|
}
|
|
|
|
type indexInfo struct {
|
|
Name string
|
|
}
|
|
var indexes []indexInfo
|
|
if err := db.Raw("PRAGMA index_list('request_logs')").Scan(&indexes).Error; err != nil {
|
|
t.Fatalf("inspect request_logs indexes: %v", err)
|
|
}
|
|
indexNames := make(map[string]struct{}, len(indexes))
|
|
for _, index := range indexes {
|
|
indexNames[index.Name] = struct{}{}
|
|
}
|
|
|
|
wantIndexes := map[string][]struct {
|
|
name string
|
|
desc int
|
|
}{
|
|
"idx_request_logs_completed_id": {
|
|
{name: "completed_at_ms", desc: 1},
|
|
{name: "id", desc: 1},
|
|
},
|
|
"idx_request_logs_access_completed_id": {
|
|
{name: "access_key_id"},
|
|
{name: "completed_at_ms", desc: 1},
|
|
{name: "id", desc: 1},
|
|
},
|
|
"idx_request_logs_status_completed_id": {
|
|
{name: "status"},
|
|
{name: "completed_at_ms", desc: 1},
|
|
{name: "id", desc: 1},
|
|
},
|
|
"idx_request_logs_model_completed_id": {
|
|
{name: "client_model"},
|
|
{name: "completed_at_ms", desc: 1},
|
|
{name: "id", desc: 1},
|
|
},
|
|
"idx_request_logs_upstream_model_completed_id": {
|
|
{name: "upstream_model"},
|
|
{name: "completed_at_ms", desc: 1},
|
|
{name: "id", desc: 1},
|
|
},
|
|
}
|
|
type indexedColumn struct {
|
|
Sequence int `gorm:"column:seqno"`
|
|
Name string `gorm:"column:name"`
|
|
Desc int `gorm:"column:desc"`
|
|
Key int `gorm:"column:key"`
|
|
}
|
|
for indexName, wantColumns := range wantIndexes {
|
|
if _, ok := indexNames[indexName]; !ok {
|
|
t.Errorf("request_logs index %q is missing", indexName)
|
|
continue
|
|
}
|
|
var indexedColumns []indexedColumn
|
|
if err := db.Raw("PRAGMA index_xinfo('" + indexName + "')").Scan(&indexedColumns).Error; err != nil {
|
|
t.Fatalf("inspect %s columns: %v", indexName, err)
|
|
}
|
|
gotColumns := make([]indexedColumn, 0, len(indexedColumns))
|
|
for _, column := range indexedColumns {
|
|
if column.Key == 1 {
|
|
gotColumns = append(gotColumns, column)
|
|
}
|
|
}
|
|
if len(gotColumns) != len(wantColumns) {
|
|
t.Errorf("%s columns = %+v, want %d key columns", indexName, gotColumns, len(wantColumns))
|
|
continue
|
|
}
|
|
for position, want := range wantColumns {
|
|
got := gotColumns[position]
|
|
if got.Sequence != position || got.Name != want.name || got.Desc != want.desc {
|
|
t.Errorf("%s column %d = seq:%d name:%q desc:%d, want seq:%d name:%q desc:%d",
|
|
indexName, position, got.Sequence, got.Name, got.Desc, position, want.name, want.desc)
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
func TestAutoMigrateOmitsGroupSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openMigratedDatabase(t)
|
|
type columnInfo struct {
|
|
Name string
|
|
}
|
|
var columns []columnInfo
|
|
if err := db.Raw("PRAGMA table_info('groups')").Scan(&columns).Error; err != nil {
|
|
t.Fatalf("inspect groups columns: %v", err)
|
|
}
|
|
for _, column := range columns {
|
|
if column.Name == "signature" {
|
|
t.Fatal("fresh groups schema still contains signature")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAutoMigrateAllowsDuplicateChannelTargets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openMigratedDatabase(t)
|
|
first := models.Group{
|
|
Name: "group-one",
|
|
ChannelID: "openai_compatible",
|
|
Params: models.JSON(`{"base_url":"https://same.example.com/v1"}`),
|
|
Models: models.JSON(`[]`),
|
|
Overrides: models.JSON(`{}`),
|
|
Enabled: true,
|
|
}
|
|
second := first
|
|
second.Name = "group-two"
|
|
if err := db.Create(&first).Error; err != nil {
|
|
t.Fatalf("create first group: %v", err)
|
|
}
|
|
if err := db.Create(&second).Error; err != nil {
|
|
t.Fatalf("create group with duplicate channel target: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAutoMigrateRejectsNonEmptyDatabaseWithoutMigrationLedger(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, err := storage.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Open(:memory:) error = %v", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("db.DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
|
|
if err := db.Exec("CREATE TABLE legacy_data (id INTEGER PRIMARY KEY)").Error; err != nil {
|
|
t.Fatalf("create legacy table: %v", err)
|
|
}
|
|
|
|
err = storage.AutoMigrate(db)
|
|
if err == nil {
|
|
t.Fatal("AutoMigrate() error = nil, want rejection for an unversioned non-empty database")
|
|
}
|
|
if !strings.Contains(err.Error(), "non-empty database without schema_migrations") {
|
|
t.Fatalf("AutoMigrate() error = %q, want unversioned non-empty database error", err)
|
|
}
|
|
if db.Migrator().HasTable("groups") {
|
|
t.Fatal("AutoMigrate() created groups in an unversioned non-empty database")
|
|
}
|
|
if !db.Migrator().HasTable("legacy_data") {
|
|
t.Fatal("AutoMigrate() removed the pre-existing legacy table")
|
|
}
|
|
}
|
|
|
|
func TestAutoMigrateCreatesCriticalUniqueConstraints(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openMigratedDatabase(t)
|
|
|
|
t.Run("group name", func(t *testing.T) {
|
|
first := models.Group{
|
|
Name: "group-one",
|
|
ChannelID: "openai_compatible",
|
|
Params: models.JSON(`{"base_url":"https://one.example.com"}`),
|
|
Models: models.JSON(`[]`),
|
|
Overrides: models.JSON(`{}`),
|
|
}
|
|
second := first
|
|
second.ID = 0
|
|
second.Params = models.JSON(`{"base_url":"https://two.example.com"}`)
|
|
|
|
assertDuplicateRejected(t, db.Create(&first).Error, db.Create(&second).Error)
|
|
})
|
|
|
|
t.Run("credential group and fingerprint", func(t *testing.T) {
|
|
group := models.Group{
|
|
Name: "credential-parent",
|
|
ChannelID: "openai_compatible",
|
|
Params: models.JSON(`{"base_url":"https://credentials.example.com"}`),
|
|
Models: models.JSON(`[]`),
|
|
}
|
|
if err := db.Create(&group).Error; err != nil {
|
|
t.Fatalf("create credential parent group: %v", err)
|
|
}
|
|
first := models.Credential{
|
|
GroupID: group.ID, Data: "encrypted-one", Fingerprint: "same-fingerprint",
|
|
}
|
|
second := first
|
|
second.ID = 0
|
|
second.Data = "encrypted-two"
|
|
assertDuplicateRejected(t, db.Create(&first).Error, db.Create(&second).Error)
|
|
})
|
|
|
|
t.Run("model price channel and model", func(t *testing.T) {
|
|
first := models.ModelPrice{ChannelID: "openai", ModelID: "same-model"}
|
|
duplicate := first
|
|
otherChannel := first
|
|
otherChannel.ChannelID = "anthropic"
|
|
if err := db.Create(&first).Error; err != nil {
|
|
t.Fatalf("create model price: %v", err)
|
|
}
|
|
if err := db.Create(&otherChannel).Error; err != nil {
|
|
t.Fatalf("create same model for another channel: %v", err)
|
|
}
|
|
if err := db.Create(&duplicate).Error; err == nil {
|
|
t.Fatal("create duplicate channel/model price error = nil, want unique constraint error")
|
|
}
|
|
})
|
|
|
|
t.Run("access key hash", func(t *testing.T) {
|
|
first := models.AccessKey{
|
|
Name: "access-one",
|
|
KeyValue: "ciphertext-one",
|
|
KeyHash: "same-access-key-hash",
|
|
KeySuffix: "7f2a",
|
|
Filters: models.JSON(`{}`),
|
|
}
|
|
second := first
|
|
second.ID = 0
|
|
second.Name = "access-two"
|
|
second.KeyValue = "ciphertext-two"
|
|
|
|
assertDuplicateRejected(t, db.Create(&first).Error, db.Create(&second).Error)
|
|
})
|
|
|
|
t.Run("usage bucket access group and model", func(t *testing.T) {
|
|
bucket := time.Date(2026, time.July, 15, 12, 0, 0, 0, time.UTC)
|
|
first := models.UsageStat{
|
|
BucketStartMS: bucket.UnixMilli(),
|
|
AccessKeyID: 101,
|
|
GroupID: 202,
|
|
Model: "model-a",
|
|
}
|
|
second := first
|
|
second.ID = 0
|
|
|
|
assertDuplicateRejected(t, db.Create(&first).Error, db.Create(&second).Error)
|
|
})
|
|
}
|
|
|
|
func TestAutoMigrateCreatesCredentialForeignKeyWithCascade(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openMigratedDatabase(t)
|
|
type foreignKey struct {
|
|
Table string
|
|
From string
|
|
To string
|
|
OnDelete string `gorm:"column:on_delete"`
|
|
}
|
|
var foreignKeys []foreignKey
|
|
if err := db.Raw("PRAGMA foreign_key_list('credentials')").Scan(&foreignKeys).Error; err != nil {
|
|
t.Fatalf("inspect credentials foreign keys: %v", err)
|
|
}
|
|
var groupForeignKey *foreignKey
|
|
for index := range foreignKeys {
|
|
candidate := &foreignKeys[index]
|
|
if candidate.Table == "groups" && candidate.From == "group_id" && candidate.To == "id" {
|
|
groupForeignKey = candidate
|
|
break
|
|
}
|
|
}
|
|
if groupForeignKey == nil || groupForeignKey.OnDelete != "CASCADE" {
|
|
t.Fatalf("credentials foreign keys = %+v, want cascading group_id -> groups.id", foreignKeys)
|
|
}
|
|
|
|
group := models.Group{
|
|
Name: "credential-cascade-parent",
|
|
ChannelID: "openai_compatible",
|
|
Params: models.JSON(`{"base_url":"https://credential-cascade.example.com"}`),
|
|
Models: models.JSON(`[]`),
|
|
}
|
|
if err := db.Create(&group).Error; err != nil {
|
|
t.Fatalf("create credential parent group: %v", err)
|
|
}
|
|
credential := models.Credential{
|
|
GroupID: group.ID, Data: "encrypted-data", Fingerprint: "cascade-fingerprint",
|
|
}
|
|
if err := db.Create(&credential).Error; err != nil {
|
|
t.Fatalf("create credential: %v", err)
|
|
}
|
|
if err := db.Delete(&group).Error; err != nil {
|
|
t.Fatalf("delete credential parent group: %v", err)
|
|
}
|
|
var count int64
|
|
if err := db.Model(&models.Credential{}).Where("id = ?", credential.ID).Count(&count).Error; err != nil {
|
|
t.Fatalf("count credential after deleting group: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("credential count after deleting group = %d, want 0", count)
|
|
}
|
|
}
|
|
|
|
func openMigratedDatabase(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
|
|
db, err := storage.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Open(:memory:) error = %v", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("db.DB() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
|
|
if err := storage.AutoMigrate(db); err != nil {
|
|
t.Fatalf("AutoMigrate() error = %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func assertDuplicateRejected(t *testing.T, firstErr, duplicateErr error) {
|
|
t.Helper()
|
|
|
|
if firstErr != nil {
|
|
t.Fatalf("create initial record: %v", firstErr)
|
|
}
|
|
if duplicateErr == nil {
|
|
t.Fatal("create duplicate record error = nil, want unique constraint error")
|
|
}
|
|
}
|