diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml index a94ad76..259874d 100644 --- a/.woodpecker/test.yaml +++ b/.woodpecker/test.yaml @@ -19,6 +19,6 @@ steps: commands: - until bash -c 'echo > /dev/tcp/postgres/5432' 2>/dev/null; do sleep 1; done - go vet ./... - - go test -race -p 1 ./... + - go test -race ./... depends_on: [] diff --git a/README.md b/README.md index 1b336c4..af43617 100644 --- a/README.md +++ b/README.md @@ -88,17 +88,23 @@ must serve the confirm/control pages over HTTPS. ## Tests ```sh -go test ./... # unit tests (crypto round-trip, seal/open) — no DB needed +go test ./... ``` -The end-to-end confirm round-trip (browser + modem + repeater simulated in-process) is gated on a -**dedicated** test database, since it truncates all tables. The database name **must end in -`_test`** — the test refuses to run otherwise, so it can never wipe your dev data: +That's it — **just make sure Docker is running.** DB-backed tests (store queries and the +end-to-end confirm/console round-trips) spin up a throwaway `postgres:17` container automatically +via [testcontainers](https://golang.testcontainers.org/). The harness migrates a single template +database once, then clones a fresh database per test (`CREATE DATABASE … TEMPLATE …`), so every +test gets pristine, isolated state and tears it down when it finishes. No env vars, no manual +database setup, nothing to wipe. + +To run against an **existing** Postgres instead of a container (this is how CI reuses its service +container), set `MESHTENDER_TEST_DATABASE_URL` to a DSN on that server. The connecting role needs +`CREATEDB`, and the harness only ever creates/drops its own `mt_tmpl_*` / `mt_test_*` databases: ```sh -docker exec psql -U meshtender -c 'CREATE DATABASE meshtender_test OWNER meshtender;' -MESHTENDER_TEST_DATABASE_URL="postgres://meshtender:meshtender@localhost:5432/meshtender_test?sslmode=disable" \ - go test ./internal/web/ -run TestConfirmRoundTrip -v +MESHTENDER_TEST_DATABASE_URL="postgres://meshtender:meshtender@localhost:5432/postgres?sslmode=disable" \ + go test ./internal/core/ -run TestConfirmRoundTrip -v ``` ## Layout diff --git a/go.mod b/go.mod index 3dd2b1c..744fdbc 100644 --- a/go.mod +++ b/go.mod @@ -11,12 +11,32 @@ require ( github.com/jackc/pgx/v5 v5.10.0 github.com/meshcore-go/meshcore-go v1.0.8 github.com/pressly/goose/v3 v3.27.1 + github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 golang.org/x/crypto v0.53.0 ) require ( + dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.2.0 // 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.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // 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.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.2.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect @@ -25,13 +45,42 @@ require ( 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/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mfridman/interpolate v0.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.54.2 // indirect + github.com/moby/moby/client v0.4.1 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.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/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.5 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/testcontainers/testcontainers-go v0.43.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/x448/float16 v0.8.4 // 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.68.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7545484..898ce1a 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,62 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +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/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de h1:wNJVpr0ag/BL2nRGBIESdLe1qoljXIolF/qPi1gleRA= github.com/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de/go.mod h1:hwveArYcjyOK66EViVgVU5Iqj7zyEsWjKXMQhDJrTLI= github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= +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/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +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 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= @@ -24,6 +65,9 @@ github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= 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.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= @@ -41,34 +85,79 @@ github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QII github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 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/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +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-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +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-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +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/meshcore-go/meshcore-go v1.0.8 h1:NPmvE6U6upFBVjyoA9jTj6X8MknSnslv767FeZ/KSXI= github.com/meshcore-go/meshcore-go v1.0.8/go.mod h1:EnLNJNCjGLXoX+RHQXhAqu6LJkIVTkw9Jxpxre6HLJc= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +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.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +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.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= 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/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -76,11 +165,35 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A= +github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 h1:ShNOFYAF4lKHvdIG258hi69bSxC88uXnxJkJvNs/IVs= +github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0/go.mod h1:vdq5/RqmGfWeefzyfcVI/pID1rzmc1TDvqXa15bPJks= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +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.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -103,8 +216,11 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -117,6 +233,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -130,13 +248,17 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= @@ -145,3 +267,5 @@ modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go index 64d9ebd..a0255a2 100644 --- a/internal/auth/service_test.go +++ b/internal/auth/service_test.go @@ -3,6 +3,7 @@ package auth import "testing" func TestSafeLocalPath(t *testing.T) { + t.Parallel() cases := []struct { path string want bool diff --git a/internal/core/confirm_integration_test.go b/internal/core/confirm_integration_test.go index 2c703d1..6216e30 100644 --- a/internal/core/confirm_integration_test.go +++ b/internal/core/confirm_integration_test.go @@ -9,7 +9,6 @@ import ( "net/http/cookiejar" "net/http/httptest" "net/url" - "os" "strings" "testing" "time" @@ -29,30 +28,8 @@ import ( // (MeshCore crypto). It is gated on MESHTENDER_TEST_DATABASE_URL so a plain // `go test` never truncates a real database. func TestConfirmRoundTrip(t *testing.T) { - dsn := os.Getenv("MESHTENDER_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("set MESHTENDER_TEST_DATABASE_URL to run this integration test") - } - // Guardrail: this test TRUNCATEs every table, so it must never point at a - // real/dev database. Require a database name ending in "_test". - if u, err := url.Parse(dsn); err != nil || !strings.HasSuffix(strings.TrimPrefix(u.Path, "/"), "_test") { - t.Fatalf("refusing to run: MESHTENDER_TEST_DATABASE_URL must use a database named *_test (got %q) — it truncates all tables", dsn) - } - ctx := context.Background() - - st, err := store.New(ctx, dsn) - if err != nil { - t.Fatalf("store: %v", err) - } - defer st.Close() - if err := st.Migrate(ctx); err != nil { - t.Fatalf("migrate: %v", err) - } - // Isolate: wipe all state. - if _, err := st.Pool().Exec(ctx, - `TRUNCATE users, repeaters, repeater_shares, webauthn_credentials, server_identity, sessions RESTART IDENTITY CASCADE`); err != nil { - t.Fatalf("truncate: %v", err) - } + t.Parallel() + st, ctx := coreStore(t) var masterKey [32]byte _, _ = rand.Read(masterKey[:]) diff --git a/internal/core/confirm_location_test.go b/internal/core/confirm_location_test.go index a95eb08..89e33fb 100644 --- a/internal/core/confirm_location_test.go +++ b/internal/core/confirm_location_test.go @@ -8,8 +8,6 @@ import ( "net/http" "net/http/cookiejar" "net/http/httptest" - "net/url" - "os" "strings" "testing" "time" @@ -25,6 +23,7 @@ import ( ) func TestParseLocationFloat(t *testing.T) { + t.Parallel() cases := map[string]struct { want float64 ok bool @@ -46,25 +45,8 @@ func TestParseLocationFloat(t *testing.T) { // TestConfirmFetchesLocation drives the confirm flow with store_location set and // verifies the repeater's lat/lon are fetched (get lat / get lon) and stored. func TestConfirmFetchesLocation(t *testing.T) { - dsn := os.Getenv("MESHTENDER_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("set MESHTENDER_TEST_DATABASE_URL to run this integration test") - } - if u, err := url.Parse(dsn); err != nil || !strings.HasSuffix(strings.TrimPrefix(u.Path, "/"), "_test") { - t.Fatalf("refusing to run: test DB name must end in _test (got %q)", dsn) - } - ctx := context.Background() - st, err := store.New(ctx, dsn) - if err != nil { - t.Fatalf("store: %v", err) - } - defer st.Close() - if err := st.Migrate(ctx); err != nil { - t.Fatalf("migrate: %v", err) - } - if _, err := st.Pool().Exec(ctx, `TRUNCATE users, repeaters, organizations, server_identity, sessions RESTART IDENTITY CASCADE`); err != nil { - t.Fatalf("truncate: %v", err) - } + t.Parallel() + st, ctx := coreStore(t) var masterKey [32]byte _, _ = rand.Read(masterKey[:]) diff --git a/internal/core/confirm_retry_test.go b/internal/core/confirm_retry_test.go index 1b29fd5..408d0c6 100644 --- a/internal/core/confirm_retry_test.go +++ b/internal/core/confirm_retry_test.go @@ -8,8 +8,6 @@ import ( "net/http" "net/http/cookiejar" "net/http/httptest" - "net/url" - "os" "strings" "testing" "time" @@ -27,31 +25,8 @@ import ( // TestConfirmLoginRetry drops the first login (simulating a lost packet) and // verifies the confirm flow retries with a fresh timestamp and succeeds. func TestConfirmLoginRetry(t *testing.T) { - dsn := os.Getenv("MESHTENDER_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("set MESHTENDER_TEST_DATABASE_URL to run this integration test") - } - if u, err := url.Parse(dsn); err != nil || !strings.HasSuffix(strings.TrimPrefix(u.Path, "/"), "_test") { - t.Fatalf("refusing to run: test DB name must end in _test (got %q)", dsn) - } - - // Shorten the per-attempt reply wait for the test. - orig := perTryReply - perTryReply = 300 * time.Millisecond - defer func() { perTryReply = orig }() - - ctx := context.Background() - st, err := store.New(ctx, dsn) - if err != nil { - t.Fatalf("store: %v", err) - } - defer st.Close() - if err := st.Migrate(ctx); err != nil { - t.Fatalf("migrate: %v", err) - } - if _, err := st.Pool().Exec(ctx, `TRUNCATE users, repeaters, organizations, server_identity, sessions RESTART IDENTITY CASCADE`); err != nil { - t.Fatalf("truncate: %v", err) - } + t.Parallel() + st, ctx := coreStore(t) var masterKey [32]byte _, _ = rand.Read(masterKey[:]) diff --git a/internal/core/console_integration_test.go b/internal/core/console_integration_test.go index e1f8986..bf8b99e 100644 --- a/internal/core/console_integration_test.go +++ b/internal/core/console_integration_test.go @@ -8,8 +8,6 @@ import ( "net/http" "net/http/cookiejar" "net/http/httptest" - "net/url" - "os" "strings" "testing" "time" @@ -28,30 +26,8 @@ import ( // browser (WebSocket), the KISS modem (framing), and the repeater (decrypts the // command, replies). Gated on MESHTENDER_TEST_DATABASE_URL (db name ends _test). func TestConsoleRoundTrip(t *testing.T) { - dsn := os.Getenv("MESHTENDER_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("set MESHTENDER_TEST_DATABASE_URL to run this integration test") - } - if u, err := url.Parse(dsn); err != nil || !strings.HasSuffix(strings.TrimPrefix(u.Path, "/"), "_test") { - t.Fatalf("refusing to run: test DB name must end in _test (got %q)", dsn) - } - orig := perTryReply - perTryReply = 300 * time.Millisecond - defer func() { perTryReply = orig }() - ctx := context.Background() - - st, err := store.New(ctx, dsn) - if err != nil { - t.Fatalf("store: %v", err) - } - defer st.Close() - if err := st.Migrate(ctx); err != nil { - t.Fatalf("migrate: %v", err) - } - if _, err := st.Pool().Exec(ctx, - `TRUNCATE users, repeaters, repeater_shares, repeater_invites, share_commands, command_log, webauthn_credentials, server_identity, sessions RESTART IDENTITY CASCADE`); err != nil { - t.Fatalf("truncate: %v", err) - } + t.Parallel() + st, ctx := coreStore(t) var masterKey [32]byte _, _ = rand.Read(masterKey[:]) diff --git a/internal/core/console_resolve_test.go b/internal/core/console_resolve_test.go index f3f29d9..ee363d8 100644 --- a/internal/core/console_resolve_test.go +++ b/internal/core/console_resolve_test.go @@ -1,10 +1,7 @@ package core import ( - "context" "fmt" - "net/url" - "os" "regexp" "strings" "testing" @@ -46,6 +43,7 @@ func resolveTestCatalog() []*store.Command { } func TestResolveCommand(t *testing.T) { + t.Parallel() cat := resolveTestCatalog() cases := []struct { typed, want string // want == "" means must be denied (nil) @@ -120,6 +118,7 @@ func TestResolveCommand(t *testing.T) { } func TestValidCommandText(t *testing.T) { + t.Parallel() ok := []string{"ver", "set tx 20", "set name My Repeater", "setperm abc 3"} for _, s := range ok { if !validCommandText(s) { @@ -148,6 +147,7 @@ func TestValidCommandText(t *testing.T) { // the end — this catches the Go list drifting from the DB). Requires the *_test // database. The DB also enforces feature<>'' and operation IN (...) via CHECK. func TestCommandFeatureCoverage(t *testing.T) { + t.Parallel() cat := loadRealCatalog(t) validOp := map[string]bool{"read": true, "write": true, "delete": true, "action": true} for _, c := range cat { @@ -172,22 +172,7 @@ func TestCommandFeatureCoverage(t *testing.T) { // skipping the test when the DB isn't configured. func loadRealCatalog(t *testing.T) []*store.Command { t.Helper() - dsn := os.Getenv("MESHTENDER_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("set MESHTENDER_TEST_DATABASE_URL to run this integration test") - } - if u, err := url.Parse(dsn); err != nil || !strings.HasSuffix(strings.TrimPrefix(u.Path, "/"), "_test") { - t.Fatalf("refusing to run: test DB name must end in _test (got %q)", dsn) - } - ctx := context.Background() - st, err := store.New(ctx, dsn) - if err != nil { - t.Fatalf("store: %v", err) - } - t.Cleanup(st.Close) - if err := st.Migrate(ctx); err != nil { - t.Fatalf("migrate: %v", err) - } + st, ctx := coreStore(t) cat, err := st.ListCommands(ctx) if err != nil { t.Fatalf("list commands: %v", err) @@ -200,6 +185,7 @@ func loadRealCatalog(t *testing.T) []*store.Command { // Requires the *_test database (it runs migrations). func TestResolveCommandRealCatalog(t *testing.T) { + t.Parallel() cat := loadRealCatalog(t) argGroup := regexp.MustCompile(`<[^>]*>`) seen := map[string]string{} // (token|arity) -> key diff --git a/internal/core/dbtest_test.go b/internal/core/dbtest_test.go new file mode 100644 index 0000000..233444a --- /dev/null +++ b/internal/core/dbtest_test.go @@ -0,0 +1,47 @@ +package core + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jleight/meshtender/internal/store" + "github.com/jleight/meshtender/internal/testdb" +) + +// coreStore returns a Store backed by a fresh, throwaway database cloned from +// the migrated template (see internal/testdb). Each call is fully isolated — +// command_catalog seeded, everything else empty — so the integration tests need +// no truncation and don't share state. +func coreStore(t *testing.T) (*store.Store, context.Context) { + t.Helper() + ctx := context.Background() + st, err := store.New(ctx, testdb.Fresh(t, coreMigrate)) + if err != nil { + t.Fatalf("store: %v", err) + } + t.Cleanup(st.Close) + return st, ctx +} + +// coreMigrate applies the schema to the template database, releasing its +// connection before the template is cloned. +func coreMigrate(dsn string) error { + ctx := context.Background() + st, err := store.New(ctx, dsn) + if err != nil { + return err + } + defer st.Close() + return st.Migrate(ctx) +} + +// TestMain wires process-level setup/teardown for the testdb template/container. +// It also shortens the packet-reply wait once, before any test runs: the +// integration tests run in parallel and only ever read perTryReply, so setting +// it here (rather than mutating it per-test) keeps it race-free under -race. +func TestMain(m *testing.M) { + perTryReply = 300 * time.Millisecond + os.Exit(testdb.RunMain(m)) +} diff --git a/internal/core/handoff_test.go b/internal/core/handoff_test.go index 77bc5ac..f47281c 100644 --- a/internal/core/handoff_test.go +++ b/internal/core/handoff_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "os" "strings" "testing" @@ -30,26 +29,7 @@ type hostEnv struct{ auth, app, root, www string } // against the test database, returning the store and the per-surface host:port. func splitServer(t *testing.T) (*store.Store, context.Context, *httptest.Server, hostEnv) { t.Helper() - dsn := os.Getenv("MESHTENDER_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("set MESHTENDER_TEST_DATABASE_URL to run this integration test") - } - if u, err := url.Parse(dsn); err != nil || !strings.HasSuffix(strings.TrimPrefix(u.Path, "/"), "_test") { - t.Fatalf("refusing to run: test DB name must end in _test (got %q)", dsn) - } - ctx := context.Background() - st, err := store.New(ctx, dsn) - if err != nil { - t.Fatalf("store: %v", err) - } - t.Cleanup(st.Close) - if err := st.Migrate(ctx); err != nil { - t.Fatalf("migrate: %v", err) - } - if _, err := st.Pool().Exec(ctx, - `TRUNCATE users, repeaters, server_identity, sessions, auth_codes RESTART IDENTITY CASCADE`); err != nil { - t.Fatalf("truncate: %v", err) - } + st, ctx := coreStore(t) var masterKey [32]byte _, _ = rand.Read(masterKey[:]) @@ -158,6 +138,7 @@ func cookieByName(resp *http.Response, name string) *http.Cookie { // protected page redirects to the auth host's /login, carrying next and a state // nonce that matches the host-only state cookie just set. func TestRequireUserBouncesToAuthHost(t *testing.T) { + t.Parallel() _, _, ts, h := splitServer(t) resp := do(t, ts, h.app, "/repeaters") @@ -182,6 +163,7 @@ func TestRequireUserBouncesToAuthHost(t *testing.T) { // TestAppHostLoginRedirectsToAuth: the credential UI does not live on the app // host; /login there bounces to the auth host. func TestAppHostLoginRedirectsToAuth(t *testing.T) { + t.Parallel() _, _, ts, h := splitServer(t) resp := do(t, ts, h.app, "/login") defer resp.Body.Close() @@ -195,6 +177,7 @@ func TestAppHostLoginRedirectsToAuth(t *testing.T) { // TestAuthHostServesLogin: the auth host renders the sign-in page in place. func TestAuthHostServesLogin(t *testing.T) { + t.Parallel() _, _, ts, h := splitServer(t) resp := do(t, ts, h.auth, "/login") defer resp.Body.Close() @@ -207,6 +190,7 @@ func TestAuthHostServesLogin(t *testing.T) { // serves public marketing + org discovery, www redirects to root, and the app // host's / and /orgs require auth (bounce to the auth host). func TestHostRouting(t *testing.T) { + t.Parallel() _, _, ts, h := splitServer(t) t.Run("root serves landing", func(t *testing.T) { @@ -256,6 +240,7 @@ func TestHostRouting(t *testing.T) { // /logout chains to the auth host, and the auth host's /logout destroys the SSO // session so it can't silently re-authenticate. func TestSingleLogout(t *testing.T) { + t.Parallel() st, ctx, ts, h := splitServer(t) // An app session (via the handoff) logs out by bouncing to the auth host. @@ -303,6 +288,7 @@ func TestSingleLogout(t *testing.T) { // SSO session, and an SSO-less visit returns LOCALLY to /account after login // (rather than handing off to the app). func TestAccountOnAuthHost(t *testing.T) { + t.Parallel() _, _, ts, h := splitServer(t) t.Run("app host no longer serves /account", func(t *testing.T) { @@ -354,6 +340,7 @@ func TestAccountOnAuthHost(t *testing.T) { // state establishes an app-host session; tampered state or an unknown code is // rejected back to sign-in without a session. func TestSessionCallback(t *testing.T) { + t.Parallel() st, ctx, ts, h := splitServer(t) appHost := h.app u, err := st.CreateUser(ctx, "callbackuser", "") diff --git a/internal/core/org_domains_test.go b/internal/core/org_domains_test.go index 02f3f85..6669625 100644 --- a/internal/core/org_domains_test.go +++ b/internal/core/org_domains_test.go @@ -7,6 +7,7 @@ import ( ) func TestNormalizeHostname(t *testing.T) { + t.Parallel() cases := map[string]string{ "mesh.example.org": "mesh.example.org", " Mesh.Example.ORG ": "mesh.example.org", @@ -28,6 +29,7 @@ func TestNormalizeHostname(t *testing.T) { } func TestTxtRecordsHaveToken(t *testing.T) { + t.Parallel() tok := "secret-token" if !txtRecordsHaveToken([]string{"other", " secret-token "}, tok) { t.Error("should match token with surrounding whitespace among other records") @@ -41,6 +43,7 @@ func TestTxtRecordsHaveToken(t *testing.T) { } func TestHostWithoutPort(t *testing.T) { + t.Parallel() cases := map[string]string{ "example.org:8080": "example.org", "example.org": "example.org", diff --git a/internal/core/render_test.go b/internal/core/render_test.go index 4206a75..e95bbad 100644 --- a/internal/core/render_test.go +++ b/internal/core/render_test.go @@ -14,6 +14,7 @@ import ( // or template-resolution error. This guards the startup-time template // composition (each page redefines content/title/header onto the base set). func TestBuildPagesComposeAndExecute(t *testing.T) { + t.Parallel() rn, err := web.NewRenderer(&config.Config{}, templatesFS) if err != nil { t.Fatalf("NewRenderer: %v", err) diff --git a/internal/identity/seal_test.go b/internal/identity/seal_test.go index 7f972e0..f4a8e6b 100644 --- a/internal/identity/seal_test.go +++ b/internal/identity/seal_test.go @@ -7,6 +7,7 @@ import ( ) func TestSealOpenRoundTrip(t *testing.T) { + t.Parallel() var key [32]byte if _, err := rand.Read(key[:]); err != nil { t.Fatal(err) @@ -31,6 +32,7 @@ func TestSealOpenRoundTrip(t *testing.T) { } func TestOpenWrongKeyFails(t *testing.T) { + t.Parallel() var key, wrong [32]byte _, _ = rand.Read(key[:]) _, _ = rand.Read(wrong[:]) @@ -45,6 +47,7 @@ func TestOpenWrongKeyFails(t *testing.T) { } func TestOpenTooShort(t *testing.T) { + t.Parallel() var key [32]byte _, _ = rand.Read(key[:]) if _, err := open(key, []byte{0x01, 0x02}); err == nil { diff --git a/internal/marketing/orgs_cursor_test.go b/internal/marketing/orgs_cursor_test.go index 2749032..5712214 100644 --- a/internal/marketing/orgs_cursor_test.go +++ b/internal/marketing/orgs_cursor_test.go @@ -3,6 +3,7 @@ package marketing import "testing" func TestOrgCursorRoundTrip(t *testing.T) { + t.Parallel() cases := []orgCursor{ {Sort: "name", Name: "Acme Mesh", ID: 1}, {Sort: "members", Count: 0, ID: 0}, @@ -20,6 +21,7 @@ func TestOrgCursorRoundTrip(t *testing.T) { } func TestDecodeOrgCursorMalformed(t *testing.T) { + t.Parallel() // A missing or garbage cursor must decode to ok=false, never an error, so a // tampered URL just resets paging rather than 500ing. for _, tok := range []string{"", "not-base64!!", "Zm9v" /* "foo", not JSON */} { diff --git a/internal/mesh/command_test.go b/internal/mesh/command_test.go index c47a26c..4dd529a 100644 --- a/internal/mesh/command_test.go +++ b/internal/mesh/command_test.go @@ -9,6 +9,7 @@ import ( ) func TestBuildCommandPacketDecodableByRepeater(t *testing.T) { + t.Parallel() server := mustIdentity(t) repeater := mustIdentity(t) now := time.Unix(1_700_001_000, 0) @@ -55,6 +56,7 @@ func TestBuildCommandPacketDecodableByRepeater(t *testing.T) { } func TestDecodeCommandReply(t *testing.T) { + t.Parallel() server := mustIdentity(t) repeater := mustIdentity(t) now := time.Unix(1_700_001_500, 0) diff --git a/internal/mesh/exchange_test.go b/internal/mesh/exchange_test.go index 4af75a0..cdb5190 100644 --- a/internal/mesh/exchange_test.go +++ b/internal/mesh/exchange_test.go @@ -38,6 +38,7 @@ func (f *fakeModem) SendData(_ []byte) error { } func TestExchangerRetriesThenSucceeds(t *testing.T) { + t.Parallel() server := mustIdentity(t) repeater := mustIdentity(t) fm := &fakeModem{repeater: repeater, server: server.Identity, replyText: "> 42.0", dropN: 2} @@ -61,6 +62,7 @@ func TestExchangerRetriesThenSucceeds(t *testing.T) { } func TestExchangerExhaustsRetries(t *testing.T) { + t.Parallel() server := mustIdentity(t) repeater := mustIdentity(t) fm := &fakeModem{repeater: repeater, server: server.Identity, replyText: "x", dropN: 100} @@ -90,6 +92,7 @@ func (f *routeFakeModem) SendData(raw []byte) error { // TestExchangerUsesDirectAfterLogin verifies that once Login learns a path, the // next Command is sent via direct routing carrying that exact path. func TestExchangerUsesDirectAfterLogin(t *testing.T) { + t.Parallel() server := mustIdentity(t) repeater := mustIdentity(t) shared, _ := repeater.SharedSecret(server.Identity) @@ -177,6 +180,7 @@ func (f *seqModem) SendData(_ []byte) error { // reply it rejects and keeps waiting for one it accepts — the guard that keeps // a straggling "get lat" duplicate from being misread as the longitude. func TestCommandAcceptRejectsStaleReply(t *testing.T) { + t.Parallel() server := mustIdentity(t) repeater := mustIdentity(t) // First two sends echo the stale latitude; the third gives the longitude. @@ -199,6 +203,7 @@ func TestCommandAcceptRejectsStaleReply(t *testing.T) { } func TestExchangerCancelled(t *testing.T) { + t.Parallel() server := mustIdentity(t) repeater := mustIdentity(t) fm := &fakeModem{repeater: repeater, server: server.Identity, dropN: 100} diff --git a/internal/mesh/mesh_test.go b/internal/mesh/mesh_test.go index 099a367..bba8cbf 100644 --- a/internal/mesh/mesh_test.go +++ b/internal/mesh/mesh_test.go @@ -66,6 +66,7 @@ func buildRepeaterResponse(t *testing.T, repeater meshcore.LocalIdentity, server } func TestLoginRequestDecodableByRepeater(t *testing.T) { + t.Parallel() server := mustIdentity(t) repeater := mustIdentity(t) now := time.Unix(1_700_000_000, 0) @@ -169,6 +170,7 @@ func buildRepeaterPathReply(t *testing.T, repeater meshcore.LocalIdentity, serve } func TestLoginPathReplyRoundTrip(t *testing.T) { + t.Parallel() server := mustIdentity(t) repeater := mustIdentity(t) now := time.Unix(1_700_000_900, 0).UTC() @@ -192,6 +194,7 @@ func TestLoginPathReplyRoundTrip(t *testing.T) { // from a live repeater (admin granted via `setperm 3`). It guards the // timestamp-anchored field offsets against regressions. func TestParseRealPathReply(t *testing.T) { + t.Parallel() // raw = [path_len=00][tag=01][ts:4 LE = 0x6a34b5e3][code=00][rsv=00] // [admin=01][perms=03][random:4][fw=02][pad=00] plain, err := hex.DecodeString("0001e3b5346a000001030b9213f80200") @@ -221,6 +224,7 @@ func TestParseRealPathReply(t *testing.T) { } func TestLoginResponseRoundTrip(t *testing.T) { + t.Parallel() server := mustIdentity(t) repeater := mustIdentity(t) now := time.Unix(1_700_000_500, 0).UTC() @@ -246,6 +250,7 @@ func TestLoginResponseRoundTrip(t *testing.T) { } func TestDecodeLoginResponseWrongRepeater(t *testing.T) { + t.Parallel() server := mustIdentity(t) repeater := mustIdentity(t) other := mustIdentity(t) @@ -261,6 +266,7 @@ func TestDecodeLoginResponseWrongRepeater(t *testing.T) { } func TestDecodeLoginResponseNotResponse(t *testing.T) { + t.Parallel() server := mustIdentity(t) repeater := mustIdentity(t) diff --git a/internal/mesh/seqclock_test.go b/internal/mesh/seqclock_test.go index 4c12c06..f3b5308 100644 --- a/internal/mesh/seqclock_test.go +++ b/internal/mesh/seqclock_test.go @@ -3,6 +3,7 @@ package mesh import "testing" func TestSeqClockStrictlyIncreasing(t *testing.T) { + t.Parallel() c := &SeqClock{} prev := int64(0) for i := 0; i < 100; i++ { // tight loop → same wall-clock second diff --git a/internal/store/auth_codes_test.go b/internal/store/auth_codes_test.go index 2429458..b3e9a40 100644 --- a/internal/store/auth_codes_test.go +++ b/internal/store/auth_codes_test.go @@ -3,6 +3,7 @@ package store import "testing" func TestAuthCodes(t *testing.T) { + t.Parallel() st, ctx := orgTestStore(t) u, err := st.CreateUser(ctx, "codeuser", "") if err != nil { diff --git a/internal/store/command_log_paging_test.go b/internal/store/command_log_paging_test.go index aebe969..c619241 100644 --- a/internal/store/command_log_paging_test.go +++ b/internal/store/command_log_paging_test.go @@ -10,6 +10,7 @@ import ( // checks every session with commands appears once, newest-first, across pages — // and that a session with no commands is omitted. func TestListCommandLogSessionsPage(t *testing.T) { + t.Parallel() st, ctx := orgTestStore(t) owner, err := st.CreateUser(ctx, "logowner", "") diff --git a/internal/store/ids_test.go b/internal/store/ids_test.go index ba25e8c..034ddcd 100644 --- a/internal/store/ids_test.go +++ b/internal/store/ids_test.go @@ -7,6 +7,7 @@ import ( ) func TestRepeaterPublicID(t *testing.T) { + t.Parallel() st, ctx := orgTestStore(t) owner, _ := st.CreateUser(ctx, "pidowner", "") @@ -39,6 +40,7 @@ func TestRepeaterPublicID(t *testing.T) { } func TestOrgSlugGeneration(t *testing.T) { + t.Parallel() st, ctx := orgTestStore(t) creator, _ := st.CreateUser(ctx, "slugcreator", "") @@ -76,6 +78,7 @@ func TestOrgSlugGeneration(t *testing.T) { } func TestValidOrgSlug(t *testing.T) { + t.Parallel() cases := map[string]bool{ "buffalo-mesh": true, "abc": true, @@ -97,6 +100,7 @@ func TestValidOrgSlug(t *testing.T) { } func TestOrgDomains(t *testing.T) { + t.Parallel() st, ctx := orgTestStore(t) creator, _ := st.CreateUser(ctx, "domowner", "") org, err := st.CreateOrg(ctx, "Domain Org", creator.ID) diff --git a/internal/store/main_test.go b/internal/store/main_test.go new file mode 100644 index 0000000..a4bf1e4 --- /dev/null +++ b/internal/store/main_test.go @@ -0,0 +1,14 @@ +package store + +import ( + "os" + "testing" + + "github.com/jleight/meshtender/internal/testdb" +) + +// TestMain gives the package's DB-backed tests process-level setup/teardown of +// the testdb template and (when used) its container. +func TestMain(m *testing.M) { + os.Exit(testdb.RunMain(m)) +} diff --git a/internal/store/orgs_paging_test.go b/internal/store/orgs_paging_test.go index 4346073..6072a7b 100644 --- a/internal/store/orgs_paging_test.go +++ b/internal/store/orgs_paging_test.go @@ -8,6 +8,7 @@ import ( // TestListPublicOrgsPageByName walks the name-sorted directory and checks every // org appears exactly once, in (name, id) order, across pages. func TestListPublicOrgsPageByName(t *testing.T) { + t.Parallel() st, ctx := orgTestStore(t) owner, err := st.CreateUser(ctx, "pager", "") @@ -68,6 +69,7 @@ func TestListPublicOrgsPageByName(t *testing.T) { // TestListPublicOrgsPageByMembers checks the default ordering puts orgs with the // most members first, and that the count-keyset seek pages without dupes. func TestListPublicOrgsPageByMembers(t *testing.T) { + t.Parallel() st, ctx := orgTestStore(t) // Give each org a distinct member count: org i ends up with i+1 members @@ -113,6 +115,7 @@ func TestListPublicOrgsPageByMembers(t *testing.T) { // TestListPublicOrgsPageSearch checks the search term matches name, description, // and region case-insensitively, and that wildcards are treated literally. func TestListPublicOrgsPageSearch(t *testing.T) { + t.Parallel() st, ctx := orgTestStore(t) owner, err := st.CreateUser(ctx, "searcher", "") diff --git a/internal/store/orgs_resolution_test.go b/internal/store/orgs_resolution_test.go index 68ea1e3..8c95903 100644 --- a/internal/store/orgs_resolution_test.go +++ b/internal/store/orgs_resolution_test.go @@ -3,41 +3,42 @@ package store import ( "context" "errors" - "net/url" - "os" "strings" "testing" + + "github.com/jleight/meshtender/internal/testdb" ) -// orgTestStore opens the test DB (gated, *_test only) and wipes mutable state, -// preserving the migration-seeded command_catalog. +// orgTestStore returns a Store backed by a fresh, throwaway database cloned from +// the migrated template (see internal/testdb). Each call gets pristine state — +// command_catalog seeded, everything else empty — so tests need no truncation +// and can run in parallel. func orgTestStore(t *testing.T) (*Store, context.Context) { t.Helper() - dsn := os.Getenv("MESHTENDER_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("set MESHTENDER_TEST_DATABASE_URL to run org resolution tests") - } - if u, err := url.Parse(dsn); err != nil || !strings.HasSuffix(strings.TrimPrefix(u.Path, "/"), "_test") { - t.Fatalf("refusing to run: test DB name must end in _test (got %q)", dsn) - } ctx := context.Background() - st, err := New(ctx, dsn) + st, err := New(ctx, testdb.Fresh(t, migrateTemplate)) if err != nil { t.Fatalf("store: %v", err) } t.Cleanup(st.Close) - if err := st.Migrate(ctx); err != nil { - t.Fatalf("migrate: %v", err) - } - // Wipe state but keep command_catalog (seeded by migration). - if _, err := st.pool.Exec(ctx, - `TRUNCATE users, repeaters, organizations RESTART IDENTITY CASCADE`); err != nil { - t.Fatalf("truncate: %v", err) - } return st, ctx } +// migrateTemplate applies the schema to the template database. It opens its own +// store and closes it before returning, so no connection lingers on the +// template when it's cloned. +func migrateTemplate(dsn string) error { + ctx := context.Background() + st, err := New(ctx, dsn) + if err != nil { + return err + } + defer st.Close() + return st.Migrate(ctx) +} + func TestOrgCommandResolution(t *testing.T) { + t.Parallel() st, ctx := orgTestStore(t) cmdID := func(key string) int64 { @@ -138,6 +139,7 @@ func TestOrgCommandResolution(t *testing.T) { } func TestOrgRepeaterAccess(t *testing.T) { + t.Parallel() st, ctx := orgTestStore(t) owner, _ := st.CreateUser(ctx, "owner2", "") member, _ := st.CreateUser(ctx, "member2", "") diff --git a/internal/store/username_test.go b/internal/store/username_test.go index 793bfe7..0b39bae 100644 --- a/internal/store/username_test.go +++ b/internal/store/username_test.go @@ -11,6 +11,7 @@ func selfChange(uid int64) UsernameChangeContext { } func TestSetUsername(t *testing.T) { + t.Parallel() st, ctx := orgTestStore(t) mk := func(name string) int64 { diff --git a/internal/store/users_paging_test.go b/internal/store/users_paging_test.go index 5b5601f..525e55c 100644 --- a/internal/store/users_paging_test.go +++ b/internal/store/users_paging_test.go @@ -8,6 +8,7 @@ import ( // TestListUsersPage walks the keyset-paginated admin user list and checks every // user appears exactly once, in username order, across pages. func TestListUsersPage(t *testing.T) { + t.Parallel() st, ctx := orgTestStore(t) // reuses the gated *_test store + truncation total := UsersPageSize*2 + 3 diff --git a/internal/testdb/testdb.go b/internal/testdb/testdb.go new file mode 100644 index 0000000..c02bb0f --- /dev/null +++ b/internal/testdb/testdb.go @@ -0,0 +1,221 @@ +// Package testdb gives each test its own throwaway Postgres database. +// +// The model (borrowed from a pattern common in .NET test suites): once per test +// process we obtain a Postgres server, build a single migrated "template" +// database, and then hand every test a fresh database cloned from that template +// via CREATE DATABASE ... TEMPLATE. Cloning copies the already-migrated schema +// and seed data, so individual tests never run migrations and never share +// state — no TRUNCATE, no ordering constraints, safe to run in parallel. +// +// The server comes from one of two places: +// +// - If MESHTENDER_TEST_DATABASE_URL is set, that server is reused (this is how +// CI points at its postgres service container). +// - Otherwise a postgres:17 container is started via testcontainers, so a +// local `go test ./...` just works as long as Docker is running. +// +// This package deliberately does not import internal/store: store's own +// (internal) tests import testdb, and the reverse dependency would be a cycle. +// Migrations are injected by the caller through the migrate callback. +package testdb + +import ( + "context" + "fmt" + "net/url" + "os" + "sync" + "sync/atomic" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/testcontainers/testcontainers-go/modules/postgres" +) + +// pid disambiguates database names across the separate test binaries (one per +// package) that may share a single reused server in CI. +var pid = os.Getpid() + +var ( + serverOnce sync.Once + serverErr error + adminDSN string // points at a maintenance DB on the server + container *postgres.PostgresContainer // nil when reusing an external server + + templateOnce sync.Once + templateErr error + templateName string + + createMu sync.Mutex // serializes per-test CREATE DATABASE within this process + dbCounter atomic.Int64 +) + +// templateLockKey is an arbitrary, fixed advisory-lock id. It serializes +// template creation across the package test binaries that share one server (CI), +// where they would otherwise race to CREATE DATABASE from template1. +const templateLockKey int64 = 0x4d5465737444_42 + +// ensureServer resolves a Postgres server exactly once per process: a reused +// external one via MESHTENDER_TEST_DATABASE_URL, or a fresh container. +func ensureServer(ctx context.Context) error { + serverOnce.Do(func() { + if dsn := os.Getenv("MESHTENDER_TEST_DATABASE_URL"); dsn != "" { + adminDSN = dsn + return + } + c, err := postgres.Run(ctx, "postgres:17", + postgres.WithDatabase("postgres"), + postgres.WithUsername("meshtender"), + postgres.WithPassword("meshtender"), + postgres.BasicWaitStrategies(), + ) + if err != nil { + serverErr = fmt.Errorf("start postgres container (is Docker running?): %w", err) + return + } + container = c + dsn, err := c.ConnectionString(ctx, "sslmode=disable") + if err != nil { + serverErr = fmt.Errorf("container connection string: %w", err) + return + } + adminDSN = dsn + }) + return serverErr +} + +// ensureTemplate creates and migrates the per-process template database exactly +// once. migrate is invoked with the template's DSN and must apply the schema and +// then release all its connections (the subsequent CREATE DATABASE ... TEMPLATE +// requires that no sessions are connected to the template). +func ensureTemplate(ctx context.Context, migrate func(dsn string) error) error { + templateOnce.Do(func() { + templateName = fmt.Sprintf("mt_tmpl_%d", pid) + conn, err := pgx.Connect(ctx, adminDSN) + if err != nil { + templateErr = fmt.Errorf("admin connect: %w", err) + return + } + defer conn.Close(ctx) + // Hold a cross-process advisory lock only around the template DDL: this is + // the one CREATE DATABASE that copies the shared template1, so concurrent + // package binaries on one server must take turns. Released before migrate, + // which runs on its own connection against the new template. + if _, err := conn.Exec(ctx, `SELECT pg_advisory_lock($1)`, templateLockKey); err != nil { + templateErr = fmt.Errorf("advisory lock: %w", err) + return + } + _, dropErr := conn.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(templateName)+` WITH (FORCE)`) + var createErr error + if dropErr == nil { + _, createErr = conn.Exec(ctx, `CREATE DATABASE `+quoteIdent(templateName)) + } + if _, err := conn.Exec(ctx, `SELECT pg_advisory_unlock($1)`, templateLockKey); err != nil { + templateErr = fmt.Errorf("advisory unlock: %w", err) + return + } + if dropErr != nil { + templateErr = fmt.Errorf("drop stale template: %w", dropErr) + return + } + if createErr != nil { + templateErr = fmt.Errorf("create template: %w", createErr) + return + } + tmplDSN, err := dsnWithDB(adminDSN, templateName) + if err != nil { + templateErr = err + return + } + if err := migrate(tmplDSN); err != nil { + templateErr = fmt.Errorf("migrate template: %w", err) + } + }) + return templateErr +} + +// Fresh provisions a brand-new database cloned from the migrated template and +// returns its DSN. The database is dropped when the test finishes. migrate is +// used only to build the template the first time it's called in this process; +// later calls reuse the existing template. +func Fresh(t *testing.T, migrate func(dsn string) error) string { + t.Helper() + ctx := context.Background() + if err := ensureServer(ctx); err != nil { + t.Fatalf("testdb: %v", err) + } + if err := ensureTemplate(ctx, migrate); err != nil { + t.Fatalf("testdb: %v", err) + } + + name := fmt.Sprintf("mt_test_%d_%d", pid, dbCounter.Add(1)) + createMu.Lock() + conn, err := pgx.Connect(ctx, adminDSN) + if err == nil { + _, err = conn.Exec(ctx, `CREATE DATABASE `+quoteIdent(name)+` TEMPLATE `+quoteIdent(templateName)) + conn.Close(ctx) + } + createMu.Unlock() + if err != nil { + t.Fatalf("testdb: create database: %v", err) + } + + t.Cleanup(func() { + c, err := pgx.Connect(ctx, adminDSN) + if err != nil { + return + } + defer c.Close(ctx) + _, _ = c.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(name)+` WITH (FORCE)`) + }) + + dsn, err := dsnWithDB(adminDSN, name) + if err != nil { + t.Fatalf("testdb: %v", err) + } + return dsn +} + +// RunMain wraps a package's tests with process-level teardown: it drops the +// template and, when we started one, terminates the container. Use it from a +// package's TestMain: +// +// func TestMain(m *testing.M) { os.Exit(testdb.RunMain(m)) } +func RunMain(m *testing.M) int { + code := m.Run() + ctx := context.Background() + if templateName != "" && adminDSN != "" { + if conn, err := pgx.Connect(ctx, adminDSN); err == nil { + _, _ = conn.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(templateName)+` WITH (FORCE)`) + conn.Close(ctx) + } + } + if container != nil { + _ = container.Terminate(ctx) + } + return code +} + +// dsnWithDB returns base with its database (path) replaced by dbName and a small +// pool cap applied. The cap matters under parallel tests: many tests each open a +// pool against the same server, so an uncapped default would exhaust Postgres's +// connection limit. These DSNs are only ever handed to pgxpool (store.New), so +// the pool_* parameter is safe here. +func dsnWithDB(base, dbName string) (string, error) { + u, err := url.Parse(base) + if err != nil { + return "", fmt.Errorf("parse dsn: %w", err) + } + u.Path = "/" + dbName + q := u.Query() + q.Set("pool_max_conns", "4") + u.RawQuery = q.Encode() + return u.String(), nil +} + +// quoteIdent double-quotes a generated identifier. Names here are built from a +// fixed prefix plus integers, so this is just correctness, not injection +// defense. +func quoteIdent(name string) string { + return `"` + name + `"` +} diff --git a/internal/web/ratelimit_test.go b/internal/web/ratelimit_test.go index 45710d3..f9a7d01 100644 --- a/internal/web/ratelimit_test.go +++ b/internal/web/ratelimit_test.go @@ -6,6 +6,7 @@ import ( ) func TestRateLimiterBurstThenThrottle(t *testing.T) { + t.Parallel() now := time.Unix(0, 0) l := NewRateLimiter(3, time.Second) l.now = func() time.Time { return now } @@ -35,6 +36,7 @@ func TestRateLimiterBurstThenThrottle(t *testing.T) { } func TestRateLimiterSweepReclaims(t *testing.T) { + t.Parallel() now := time.Unix(0, 0) l := NewRateLimiter(2, time.Second) l.now = func() time.Time { return now } diff --git a/internal/wsbridge/wsbridge_test.go b/internal/wsbridge/wsbridge_test.go index 5b73143..e71d80a 100644 --- a/internal/wsbridge/wsbridge_test.go +++ b/internal/wsbridge/wsbridge_test.go @@ -12,6 +12,7 @@ import ( // (a split immediately after a frame's trailing FEND) that made the SDK's // ExtractFrames silently drop the following frame. func TestFeedReassemblesAcrossChunkBoundaries(t *testing.T) { + t.Parallel() // Three back-to-back frames: a data frame and two hardware frames // (TX_DONE, RX_META) — exactly the mix a modem emits around a transmit. f1 := hardware.EncodeDataFrame([]byte{0x21, 0x00, 0x3d, 0x37, 0xaa, 0xbb, 0xcc})