From 92255a46ed039f605231ad71eed243436c4a1fbc Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 17 Sep 2026 20:29:46 +0200 Subject: [PATCH] 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 --- bouncer/jwt_test.go | 108 +++++++++++++++ examples/hello/hello_test.go | 7 +- go.mod | 56 +++++++- go.sum | 132 ++++++++++++++++++- lagoon/connection_test.go | 162 +++++++++++++++++++++++ lagoon/migrations_test.go | 138 +++++++++++++++++++ lagoon/order_test.go | 3 + lagoon/postgres_test.go | 139 ++++++++++++++++++++ surf/middleware_test.go | 248 +++++++++++++++++++++++++++++++++++ surf/params_test.go | 7 + 10 files changed, 989 insertions(+), 11 deletions(-) create mode 100644 lagoon/connection_test.go create mode 100644 lagoon/postgres_test.go create mode 100644 surf/middleware_test.go diff --git a/bouncer/jwt_test.go b/bouncer/jwt_test.go index 39e5476..16c2e0a 100644 --- a/bouncer/jwt_test.go +++ b/bouncer/jwt_test.go @@ -2,9 +2,11 @@ package bouncer import ( "context" + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -131,4 +133,110 @@ func TestMiddlewareStatusBodies(t *testing.T) { t.Fatalf("status=%d hit=%s", rec.Code, rec.Header().Get("X-Hit")) } }) + t.Run("malformed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer not-a-jwt") + assert401(t, req, msgMalformed) + }) + t.Run("alg-none", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+noneToken(t, jwt.MapClaims{ + "sub": "1", + "exp": time.Now().Add(time.Hour).Unix(), + })) + assert401(t, req, msgBadSignature) + }) + t.Run("absent-exp", func(t *testing.T) { + tok := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{"sub": "1"}, []byte(secret)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+tok) + assert401(t, req, msgBadSignature) + }) +} + +func TestVerifyRejectsAlgNoneEmptySecretAndAbsentExp(t *testing.T) { + valid := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "1", + "exp": time.Now().Add(time.Hour).Unix(), + }, []byte(secret)) + + none := noneToken(t, jwt.MapClaims{ + "sub": "1", + "exp": time.Now().Add(time.Hour).Unix(), + }) + if _, err := Verify(none, secret); err == nil || err.Error() != msgBadSignature { + t.Fatalf("alg none: %v", err) + } + + if _, err := Verify(valid, ""); err == nil || !strings.Contains(err.Error(), "jwt secret is empty") { + t.Fatalf("empty secret: %v", err) + } + if _, err := Verify(valid, " "); err == nil || !strings.Contains(err.Error(), "jwt secret is empty") { + t.Fatalf("blank secret: %v", err) + } + + noExp := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{"sub": "1"}, []byte(secret)) + if sub, err := Verify(noExp, secret); err == nil || sub != "" { + t.Fatalf("absent exp must fail, got %q %v", sub, err) + } +} + +func TestVerifyAndMiddlewareOmitTokenAndSecret(t *testing.T) { + const leakSecret = "unique-hs256-secret-value-9f3a" + tok := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "1", + "exp": time.Now().Add(time.Hour).Unix(), + }, []byte(leakSecret)) + + assertClean := func(t *testing.T, msg string) { + t.Helper() + if strings.Contains(msg, leakSecret) || strings.Contains(msg, tok) { + t.Fatalf("leaked secret or token: %s", msg) + } + } + + if _, err := Verify("not-a-jwt", leakSecret); err == nil { + t.Fatal("want malformed") + } else { + assertClean(t, err.Error()) + } + if _, err := Verify(tok, "other-"+leakSecret); err == nil { + t.Fatal("want bad signature") + } else { + assertClean(t, err.Error()) + } + + h := Middleware(leakSecret, memUsers{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler must not run") + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d", rec.Code) + } + assertClean(t, rec.Body.String()) +} + +func TestContextUserRoundTrip(t *testing.T) { + if _, ok := User(t.Context()); ok { + t.Fatal("empty context must have no user") + } + p := &Principal{ID: 7, MustChangePassword: true} + got, ok := User(WithUser(t.Context(), p)) + if !ok || got != p || got.ID != 7 || !got.MustChangePassword { + t.Fatalf("got %+v ok=%t", got, ok) + } +} + +func noneToken(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + payload, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + body := base64.RawURLEncoding.EncodeToString(payload) + return header + "." + body + "." } diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index bac8255..675eace 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -153,8 +153,11 @@ func TestHelpListsServe(t *testing.T) { if err := run([]string{"--help"}, &buf); err != nil { t.Fatal(err) } - if !strings.Contains(buf.String(), "serve") { - t.Fatalf("help missing serve:\n%s", buf.String()) + help := buf.String() + for _, name := range []string{"serve", "migrate", "migrate:status", "migrate:rollback"} { + if !strings.Contains(help, name) { + t.Fatalf("help missing %s:\n%s", name, help) + } } } diff --git a/go.mod b/go.mod index d2fc88e..5c7b1f4 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,6 @@ module git.golem15.com/golem15/summercms go 1.27.0 - toolchain go1.27.0 require ( @@ -16,26 +15,77 @@ require ( github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.6 github.com/spf13/cobra v1.10.2 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 golang.org/x/term v0.46.0 gorm.io/driver/postgres v1.6.3 gorm.io/gorm v1.31.2 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sync v0.17.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/text v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 8ba3db4..688d1ef 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,63 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg= github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -26,6 +72,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU= @@ -38,40 +86,108 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -84,3 +200,7 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/lagoon/connection_test.go b/lagoon/connection_test.go new file mode 100644 index 0000000..04f705e --- /dev/null +++ b/lagoon/connection_test.go @@ -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") + } +} diff --git a/lagoon/migrations_test.go b/lagoon/migrations_test.go index e423a50..1acf977 100644 --- a/lagoon/migrations_test.go +++ b/lagoon/migrations_test.go @@ -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{} diff --git a/lagoon/order_test.go b/lagoon/order_test.go index 422acbc..152a202 100644 --- a/lagoon/order_test.go +++ b/lagoon/order_test.go @@ -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") + } } diff --git a/lagoon/postgres_test.go b/lagoon/postgres_test.go new file mode 100644 index 0000000..2cfbd3e --- /dev/null +++ b/lagoon/postgres_test.go @@ -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 +} diff --git a/surf/middleware_test.go b/surf/middleware_test.go new file mode 100644 index 0000000..0c807b8 --- /dev/null +++ b/surf/middleware_test.go @@ -0,0 +1,248 @@ +package surf + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/party" + "git.golem15.com/golem15/summercms/towel" +) + +type assemblePlugin struct { + id string + mw map[string]pact.Middleware + use []string + path string + hit *bool +} + +func (p assemblePlugin) ID() string { return p.id } +func (p assemblePlugin) Requires() []string { return nil } +func (p assemblePlugin) Register(*backpack.App) error { return nil } +func (p assemblePlugin) Boot(*backpack.App) error { return nil } +func (p assemblePlugin) Middlewares() map[string]pact.Middleware { return p.mw } +func (p assemblePlugin) Routes(r pact.Router) error { + path := p.path + if path == "" { + path = "/items" + } + r.Group("/api", Use(p.use...), func(g pact.Router) { + g.Get(path, func(w http.ResponseWriter, r *http.Request) { + if p.hit != nil { + *p.hit = true + } + w.WriteHeader(http.StatusOK) + }) + }) + return nil +} + +func TestAssembleMissingMiddlewareFailsBoot(t *testing.T) { + p := assemblePlugin{id: "golem15.demo", use: []string{"jwt.auth"}} + _, err := Assemble(backpack.New(nil), []party.Plugin{p}) + if err == nil || !strings.Contains(err.Error(), "golem15.demo") || !strings.Contains(err.Error(), "jwt.auth") { + t.Fatalf("want plugin and middleware in error, got %v", err) + } +} + +func TestUnauthenticatedNamedGuardDoesNotReachHandler(t *testing.T) { + hit := false + p := assemblePlugin{ + id: "golem15.demo", + use: []string{"jwt.auth"}, + hit: &hit, + mw: map[string]pact.Middleware{ + "jwt.auth": func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":true,"message":"Token not provided"}`)) + }) + }, + }, + } + h, err := Assemble(backpack.New(nil), []party.Plugin{p}) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/items", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d", rec.Code) + } + if hit { + t.Fatal("unauthenticated request reached the handler") + } +} + +func TestPipelineOrderRecoverCORSLocaleAuthPasswordOrgRateHandler(t *testing.T) { + var order []string + record := func(name string) pact.Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + order = append(order, name) + next.ServeHTTP(w, r) + }) + } + } + + r := New([]string{"http://localhost:3000"}) + if err := r.RegisterMiddleware("golem15.user", "jwt.auth", record("jwt.auth")); err != nil { + t.Fatal(err) + } + if err := r.RegisterMiddleware("golem15.fonoteka", "inv.must-change-password", record("inv.must-change-password")); err != nil { + t.Fatal(err) + } + r.BindPlugin("golem15.demo") + r.Group("/api", Use("jwt.auth", "inv.must-change-password"), func(g pact.Router) { + g.Get("/items", func(w http.ResponseWriter, req *http.Request) { + loc, ok := towel.Locale(req.Context()) + if !ok || loc != "pl" { + t.Errorf("locale = %q ok=%t, want pl from Accept-Language", loc, ok) + } + if _, ok := towel.Organization(req.Context()); !ok { + t.Error("org slot must run before the handler") + } + order = append(order, "handler") + w.WriteHeader(http.StatusNoContent) + }) + }) + h, err := r.compile() + if err != nil { + t.Fatal(err) + } + + t.Run("preflight-before-auth", func(t *testing.T) { + order = nil + req := httptest.NewRequest(http.MethodOptions, "/api/items", nil) + req.Header.Set("Origin", "http://localhost:3000") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d", rec.Code) + } + if rec.Header().Get("Access-Control-Allow-Origin") != "http://localhost:3000" { + t.Fatalf("ACA origin = %q", rec.Header().Get("Access-Control-Allow-Origin")) + } + if len(order) != 0 { + t.Fatalf("named stages ran on preflight: %v", order) + } + }) + + t.Run("get-order", func(t *testing.T) { + order = nil + req := httptest.NewRequest(http.MethodGet, "/api/items", nil) + req.Header.Set("Accept-Language", "pl") + req.Header.Set("Origin", "http://localhost:3000") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d", rec.Code) + } + want := []string{"jwt.auth", "inv.must-change-password", "handler"} + if strings.Join(order, ",") != strings.Join(want, ",") { + t.Fatalf("order = %v want %v", order, want) + } + }) + + t.Run("panic-still-opaque", func(t *testing.T) { + panicRouter := New(nil) + panicRouter.Get("/boom", func(http.ResponseWriter, *http.Request) { + panic("stack-trace-secret") + }) + ph, err := panicRouter.compile() + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + ph.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/boom", nil)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", rec.Code) + } + body := rec.Body.String() + if strings.Contains(body, "stack-trace-secret") { + t.Fatalf("leaked panic: %s", body) + } + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["error"] != true || payload["message"] != "Internal server error" { + t.Fatalf("payload = %v", payload) + } + }) +} + +func TestGroupAndPerRouteMiddlewareCompose(t *testing.T) { + var order []string + record := func(name string) pact.Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + order = append(order, name) + next.ServeHTTP(w, r) + }) + } + } + r := New(nil) + if err := r.RegisterMiddleware("golem15.user", "jwt.auth", record("jwt.auth")); err != nil { + t.Fatal(err) + } + if err := r.RegisterMiddleware("golem15.demo", "audit", record("audit")); err != nil { + t.Fatal(err) + } + r.BindPlugin("golem15.demo") + r.Group("/api", Use("jwt.auth"), func(g pact.Router) { + g.Get("/plain", func(w http.ResponseWriter, r *http.Request) { + order = append(order, "plain") + w.WriteHeader(http.StatusNoContent) + }) + g.Get("/audited", func(w http.ResponseWriter, r *http.Request) { + order = append(order, "audited") + w.WriteHeader(http.StatusNoContent) + }, "audit") + }) + h, err := r.compile() + if err != nil { + t.Fatal(err) + } + + order = nil + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/plain", nil)) + if rec.Code != http.StatusNoContent || strings.Join(order, ",") != "jwt.auth,plain" { + t.Fatalf("plain = %d %v", rec.Code, order) + } + + order = nil + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/audited", nil)) + if rec.Code != http.StatusNoContent || strings.Join(order, ",") != "jwt.auth,audit,audited" { + t.Fatalf("audited = %d %v", rec.Code, order) + } +} + +func TestServeMuxRejectsWrongMethod(t *testing.T) { + r := New(nil) + r.Get("/only-get", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h, err := r.compile() + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/only-get", nil)) + if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound { + t.Fatalf("POST status = %d", rec.Code) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/only-get", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET status = %d", rec.Code) + } +} diff --git a/surf/params_test.go b/surf/params_test.go index ea5962d..4cb2a7a 100644 --- a/surf/params_test.go +++ b/surf/params_test.go @@ -21,6 +21,13 @@ func TestIntParamMalformedIsFalse(t *testing.T) { if _, ok := IntParam(req, "id"); ok { t.Fatal("non-positive id must be false") } + req.SetPathValue("id", "") + if _, ok := IntParam(req, "id"); ok { + t.Fatal("missing id must be false") + } + if _, ok := IntParam(nil, "id"); ok { + t.Fatal("nil request must be false") + } } func TestRegexCompilesAtRegistration(t *testing.T) {