test(03-04): cover framework database, routing and JWT boundaries
- Shared pgx/GORM pool, ICU locale fail, and isolated plugin migrations - Seven-stage middleware order, missing-guard boot failure, typed 404s - Adversarial JWT matrix including alg:none, empty secret, and no leak Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
162
lagoon/connection_test.go
Normal file
162
lagoon/connection_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestUseRejectsNilSQL(t *testing.T) {
|
||||
_, err := Use(t.Context(), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "sql db is nil") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLocaleRejectsNilSQL(t *testing.T) {
|
||||
err := CheckLocale(t.Context(), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "sql db is nil") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRejectsNilHandles(t *testing.T) {
|
||||
app := backpack.New(nil)
|
||||
if err := Publish(nil, nil, nil); err == nil {
|
||||
t.Fatal("want nil app error")
|
||||
}
|
||||
if err := Publish(app, nil, &gorm.DB{}); err == nil || !strings.Contains(err.Error(), "nil") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDSNEmptyWithoutConfig(t *testing.T) {
|
||||
if DSN(nil) != "" {
|
||||
t.Fatal("nil config must yield empty DSN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFromAppMissingConfig(t *testing.T) {
|
||||
_, _, err := OpenFromApp(t.Context(), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "app config is missing") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
_, _, err = OpenFromApp(t.Context(), backpack.New(nil))
|
||||
if err == nil || !strings.Contains(err.Error(), "app config is missing") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedSQLPoolUsedByGORMAndClosed(t *testing.T) {
|
||||
_, dsn := dedicatedDB(t, "lagoon_shared")
|
||||
sqlDB, gdb, err := Open(t.Context(), dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := gdb.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw != sqlDB {
|
||||
t.Fatal("GORM is not using the shared *sql.DB")
|
||||
}
|
||||
|
||||
app := backpack.New(nil)
|
||||
if err := Publish(app, sqlDB, gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotSQL, ok := app.Lookup[*sql.DB]()
|
||||
if !ok || gotSQL != sqlDB {
|
||||
t.Fatal("published *sql.DB is not the shared pool")
|
||||
}
|
||||
gotGORM, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || gotGORM != gdb {
|
||||
t.Fatal("published *gorm.DB is missing")
|
||||
}
|
||||
|
||||
viaUse, err := Use(t.Context(), sqlDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useRaw, err := viaUse.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if useRaw != sqlDB {
|
||||
t.Fatal("Use must open GORM on the caller pool, not a second connection")
|
||||
}
|
||||
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sqlDB.PingContext(t.Context()); err == nil {
|
||||
t.Fatal("closed pool must reject Ping")
|
||||
}
|
||||
if _, err := Use(t.Context(), sqlDB); err == nil {
|
||||
t.Fatal("Use must fail after the shared pool is closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongICULocaleFailsOpen(t *testing.T) {
|
||||
admin := lagoonDB(t)
|
||||
ctx := t.Context()
|
||||
if _, err := admin.ExecContext(ctx, `CREATE DATABASE lagoon_locale_fail TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER libc LOCALE 'C'`); err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("create libc database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = admin.ExecContext(ctx, `DROP DATABASE IF EXISTS lagoon_locale_fail WITH (FORCE)`)
|
||||
})
|
||||
failDSN, err := dsnWithDB(lagoonDSN, "lagoon_locale_fail")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = Open(ctx, failDSN)
|
||||
if err == nil {
|
||||
t.Fatal("wrong locale must fail boot")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"ICU", "pl-PL", "CREATE DATABASE", "LOCALE_PROVIDER icu"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("missing %q in %s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFromAppReadsDSN(t *testing.T) {
|
||||
_, dsn := dedicatedDB(t, "lagoon_from_app")
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: lagoon-test\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{
|
||||
Dir: dir,
|
||||
Environ: []string{
|
||||
"SUMMER_ENV=development",
|
||||
"SUMMER_DATABASE__DSN=" + dsn,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := DSN(cfg); got != dsn {
|
||||
t.Fatalf("DSN = %q want %q", got, dsn)
|
||||
}
|
||||
sqlDB, gdb, err := OpenFromApp(t.Context(), backpack.New(cfg))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
raw, err := gdb.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw != sqlDB {
|
||||
t.Fatal("OpenFromApp must share the same *sql.DB with GORM")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/bonfire"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -123,3 +125,139 @@ func TestRollbackMissingPluginIsNamedError(t *testing.T) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type migPlugin struct {
|
||||
id string
|
||||
migrations []*gormigrate.Migration
|
||||
}
|
||||
|
||||
func (p migPlugin) ID() string { return p.id }
|
||||
func (p migPlugin) Requires() []string { return nil }
|
||||
func (p migPlugin) Register(*backpack.App) error { return nil }
|
||||
func (p migPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p migPlugin) Migrations() []*gormigrate.Migration { return p.migrations }
|
||||
|
||||
func TestTwoPluginMigrationSetsIsolated(t *testing.T) {
|
||||
db, _ := dedicatedDB(t, "lagoon_mig_iso")
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
alpha := migPlugin{
|
||||
id: "demo.alpha",
|
||||
migrations: []*gormigrate.Migration{{
|
||||
ID: "202609170001_create_alpha",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`CREATE TABLE lagoon_alpha (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL)`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS lagoon_alpha`).Error
|
||||
},
|
||||
}},
|
||||
}
|
||||
beta := migPlugin{
|
||||
id: "demo.beta",
|
||||
migrations: []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202609170001_create_beta",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`CREATE TABLE lagoon_beta (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL)`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS lagoon_beta`).Error
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "202609170002_seed_beta",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`INSERT INTO lagoon_beta (name) VALUES ('seed')`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DELETE FROM lagoon_beta WHERE name = 'seed'`).Error
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
plugins := []party.Plugin{alpha, beta}
|
||||
|
||||
if err := Migrate(gdb, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(gdb, plugins); err != nil {
|
||||
t.Fatalf("idempotent migrate: %v", err)
|
||||
}
|
||||
|
||||
status, err := Status(gdb, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(status) != 2 {
|
||||
t.Fatalf("status rows = %+v", status)
|
||||
}
|
||||
if status[0].Plugin != "demo.alpha" || status[0].Table != "summer_migrations_demo_alpha" || strings.Join(status[0].IDs, ",") != "202609170001_create_alpha" {
|
||||
t.Fatalf("alpha status = %+v", status[0])
|
||||
}
|
||||
if status[1].Plugin != "demo.beta" || status[1].Table != "summer_migrations_demo_beta" || strings.Join(status[1].IDs, ",") != "202609170001_create_beta,202609170002_seed_beta" {
|
||||
t.Fatalf("beta status = %+v", status[1])
|
||||
}
|
||||
|
||||
if !gdb.Migrator().HasTable("lagoon_alpha") || !gdb.Migrator().HasTable("lagoon_beta") {
|
||||
t.Fatal("both plugin tables must exist after migrate")
|
||||
}
|
||||
var seedName string
|
||||
if err := gdb.Raw(`SELECT name FROM lagoon_beta`).Scan(&seedName).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seedName != "seed" {
|
||||
t.Fatalf("seed = %q", seedName)
|
||||
}
|
||||
|
||||
if err := RollbackLast(gdb, plugins, "demo.beta"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int64
|
||||
if err := gdb.Table("lagoon_beta").Count(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("beta seed rollback left %d rows", n)
|
||||
}
|
||||
if !gdb.Migrator().HasTable("lagoon_alpha") || !gdb.Migrator().HasTable("lagoon_beta") {
|
||||
t.Fatal("schema rollback must not run on the first beta RollbackLast")
|
||||
}
|
||||
|
||||
status, err = Status(gdb, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(status[0].IDs, ",") != "202609170001_create_alpha" {
|
||||
t.Fatalf("alpha history changed: %+v", status[0])
|
||||
}
|
||||
if strings.Join(status[1].IDs, ",") != "202609170001_create_beta" {
|
||||
t.Fatalf("beta history = %+v", status[1])
|
||||
}
|
||||
|
||||
if err := RollbackLast(gdb, plugins, "demo.beta"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gdb.Migrator().HasTable("lagoon_beta") {
|
||||
t.Fatal("second beta rollback must drop lagoon_beta")
|
||||
}
|
||||
if !gdb.Migrator().HasTable("lagoon_alpha") {
|
||||
t.Fatal("alpha table must survive beta rollback")
|
||||
}
|
||||
|
||||
status, err = Status(gdb, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(status[0].IDs) != 1 {
|
||||
t.Fatalf("alpha history after beta down = %+v", status[0])
|
||||
}
|
||||
if len(status[1].IDs) != 0 {
|
||||
t.Fatalf("beta history should be empty, got %+v", status[1])
|
||||
}
|
||||
}
|
||||
|
||||
var _ pact.HasMigrations = migPlugin{}
|
||||
|
||||
@@ -36,4 +36,7 @@ func TestOrderClauseAllowList(t *testing.T) {
|
||||
if _, err := OrderBy(nil, "items.title", "asc", allowed); err == nil {
|
||||
t.Fatal("want nil db error")
|
||||
}
|
||||
if _, err := orderClause("items.title", "asc", nil); err == nil {
|
||||
t.Fatal("empty allow-list must reject")
|
||||
}
|
||||
}
|
||||
|
||||
139
lagoon/postgres_test.go
Normal file
139
lagoon/postgres_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
)
|
||||
|
||||
var (
|
||||
lagoonPG *postgres.PostgresContainer
|
||||
lagoonSQL *sql.DB
|
||||
lagoonDSN string
|
||||
lagoonPGErr error
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
code := 1
|
||||
if !testShort() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
lagoonPGErr = startLagoonPostgres(ctx)
|
||||
cancel()
|
||||
if lagoonPGErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "lagoon: testcontainers postgres: %v\n", lagoonPGErr)
|
||||
stopLagoonPostgres()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
code = m.Run()
|
||||
stopLagoonPostgres()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func testShort() bool {
|
||||
for _, a := range os.Args {
|
||||
if a == "-test.short" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func startLagoonPostgres(ctx context.Context) error {
|
||||
ctr, err := postgres.Run(ctx,
|
||||
"postgres:16-alpine",
|
||||
postgres.WithDatabase("lagoon"),
|
||||
postgres.WithUsername("lagoon"),
|
||||
postgres.WithPassword("lagoon"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
testcontainers.WithEnv(map[string]string{
|
||||
"POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8",
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lagoonPG = ctr
|
||||
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return err
|
||||
}
|
||||
lagoonSQL = db
|
||||
lagoonDSN = dsn
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopLagoonPostgres() {
|
||||
if lagoonSQL != nil {
|
||||
_ = lagoonSQL.Close()
|
||||
}
|
||||
if lagoonPG != nil {
|
||||
_ = testcontainers.TerminateContainer(lagoonPG)
|
||||
}
|
||||
}
|
||||
|
||||
func lagoonDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("requires testcontainers postgres")
|
||||
}
|
||||
if lagoonPGErr != nil {
|
||||
t.Fatalf("postgres unavailable: %v", lagoonPGErr)
|
||||
}
|
||||
if lagoonSQL == nil {
|
||||
t.Fatal("postgres unavailable: container was not started")
|
||||
}
|
||||
return lagoonSQL
|
||||
}
|
||||
|
||||
func dedicatedDB(t *testing.T, name string) (*sql.DB, string) {
|
||||
t.Helper()
|
||||
admin := lagoonDB(t)
|
||||
ctx := t.Context()
|
||||
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||
if _, err := admin.ExecContext(ctx, `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'`); err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("create %s: %v", name, err)
|
||||
}
|
||||
dsn, err := dsnWithDB(lagoonDSN, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Close()
|
||||
_, _ = admin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`)
|
||||
})
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db, dsn
|
||||
}
|
||||
|
||||
func dsnWithDB(dsn, name string) (string, error) {
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u.Path = "/" + name
|
||||
return u.String(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user