From ab9461f9a956fe7b7d92779cabd71f14e64ca594 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 21:20:27 +0200 Subject: [PATCH 1/7] test(oauth2): skip the real-Redis legacy cases while Redis is unset The legacy-payload tests of oauth2/store/redis listed the real Redis only when OAUTH2_TEST_REDIS_ADDR was set, and otherwise left its cases out without a trace. A renamed variable would then drop, unnoticed, the only real-Redis evidence for cjson's truthy userdata null, even from a run that refuses skipped tests. The real server is now always listed and its cases skip while the variable is unset, like the conformance test, which reads the same constant. A case that forgets the skip fails on its nil client instead of passing. TestContextBackendsListTheRealServer pins the listing. Audit: STORE-DL. --- oauth2/store/redis/store_context_test.go | 54 +++++++++++++++++++----- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/oauth2/store/redis/store_context_test.go b/oauth2/store/redis/store_context_test.go index e907059e..36a02921 100644 --- a/oauth2/store/redis/store_context_test.go +++ b/oauth2/store/redis/store_context_test.go @@ -37,10 +37,17 @@ const ( `"consumed":false,"issued_at":0,"expires_at":0,"issuer":null,"extension_data":null}` ) +// envRedisAddr names the variable holding the address of a disposable +// Redis server (e.g. 127.0.0.1:6379) for the real-server tests. They are +// opt-in: a real-server case skips when the variable is unset. CI sets it +// (scripts/test-backends.sh) and fails on any skip. +const envRedisAddr = "OAUTH2_TEST_REDIS_ADDR" + // contextBackend is a Redis server the legacy-payload tests write raw -// payloads to: miniredis always, and the real server -// OAUTH2_TEST_REDIS_ADDR names when it is set — cjson decodes a JSON null -// as nil on miniredis, as a truthy userdata on a real Redis. +// payloads to: miniredis always, and the real server envRedisAddr names — +// cjson decodes a JSON null as nil on miniredis, as a truthy userdata on a +// real Redis. The real server is listed even when the variable is unset, +// without a client: its cases then skip (skipIfUnset). type contextBackend struct { name string client *redis.Client @@ -48,6 +55,30 @@ type contextBackend struct { prefix string } +// skipIfUnset skips t when b is the real server and envRedisAddr is unset. +func (b contextBackend) skipIfUnset(t *testing.T) { + t.Helper() + + if b.client == nil { + t.Skipf("set %s to run against a real Redis (opt-in)", envRedisAddr) + } +} + +// TestContextBackendsListTheRealServer: the real Redis is listed whether +// or not its variable is set, so that an unset or renamed variable shows +// as skipped cases — which the CI job of the real backends refuses +// (scripts/test-backends.sh) — instead of cases that silently vanish. +func TestContextBackendsListTheRealServer(t *testing.T) { + t.Setenv(envRedisAddr, "") + + var names []string + for _, b := range contextBackends(t) { + names = append(names, b.name) + } + + assert.Equal(t, []string{"miniredis", "redis"}, names) +} + // contextBackends returns the servers to run a legacy-payload test on. func contextBackends(t *testing.T) []contextBackend { t.Helper() @@ -61,9 +92,9 @@ func contextBackends(t *testing.T) []contextBackend { backends := []contextBackend{{name: "miniredis", client: local, store: localStore, prefix: "oauth2:"}} - addr := os.Getenv("OAUTH2_TEST_REDIS_ADDR") + addr := os.Getenv(envRedisAddr) if addr == "" { - return backends + return append(backends, contextBackend{name: "redis"}) } remote := redis.NewClient(&redis.Options{Addr: addr}) @@ -103,6 +134,8 @@ func TestRedisLegacyPayloadsReadEmptyContext(t *testing.T) { for _, b := range contextBackends(t) { t.Run(b.name, func(t *testing.T) { + b.skipIfUnset(t) + ctx := t.Context() b.set(t, "code:legacy", legacyCodePayload) @@ -166,6 +199,8 @@ func TestRedisRotationReadsMissingContextAsEmpty(t *testing.T) { for _, b := range contextBackends(t) { for name, payload := range map[string]string{"absent": legacyRefreshPayload, "null": nullContextRefreshPayload} { t.Run(b.name+"/"+name, func(t *testing.T) { + b.skipIfUnset(t) + ctx := t.Context() key := "rt-legacy-" + name @@ -191,13 +226,12 @@ func TestRedisRotationReadsMissingContextAsEmpty(t *testing.T) { // TestRedisStoreConformanceRealServer runs the shared suite against a real // Redis — cjson's encoder and decoder differ from miniredis' Lua ones on -// the re-encoded replay and rotation payloads. Opt-in: set -// OAUTH2_TEST_REDIS_ADDR (e.g. 127.0.0.1:6379) to a disposable server; -// every run uses its own key prefix and deletes its keys. +// the re-encoded replay and rotation payloads. Opt-in (envRedisAddr, set +// by CI): every run uses its own key prefix and deletes its keys. func TestRedisStoreConformanceRealServer(t *testing.T) { - addr := os.Getenv("OAUTH2_TEST_REDIS_ADDR") + addr := os.Getenv(envRedisAddr) if addr == "" { - t.Skip("set OAUTH2_TEST_REDIS_ADDR to run the conformance suite against a real Redis") + t.Skipf("set %s to run the conformance suite against a real Redis (opt-in)", envRedisAddr) } client := redis.NewClient(&redis.Options{Addr: addr}) From 22a3aa69f2bf267af24909e5afde84b8162fedc5 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 21:20:37 +0200 Subject: [PATCH 2/7] test(saml): skip the unset shared-store backends instead of omitting them The shared-store tests left Redis, PostgreSQL and MySQL out of a run whose variable was unset, without a trace: a renamed variable would silently drop a real server even from a run that refuses skipped tests. The five opt-in backends are now always listed. Their subtests skip while their variable is unset, as in the other real-backend suites, and their namespace opener skips too, so that no client is ever built on an empty address (go-redis would take it for localhost:6379). TestStoreBackendsListTheOptInServers pins the listing. Audit: STORE-DL. --- internal/interop/saml/shared_store_test.go | 107 ++++++++++++++++----- 1 file changed, 84 insertions(+), 23 deletions(-) diff --git a/internal/interop/saml/shared_store_test.go b/internal/interop/saml/shared_store_test.go index 0f604130..321e0acc 100644 --- a/internal/interop/saml/shared_store_test.go +++ b/internal/interop/saml/shared_store_test.go @@ -12,16 +12,18 @@ package samlinterop_test // (plan review F5). // // Backends: a SQLite file in WAL mode and miniredis always; Redis, -// PostgreSQL and MySQL (with and without CLIENT_FOUND_ROWS) when their -// environment variables point at a disposable server (see compose.yaml): +// PostgreSQL and MySQL (with and without CLIENT_FOUND_ROWS) on the +// disposable servers their environment variables name (see compose.yaml), +// skipped when a variable is unset. CI sets all of them +// (scripts/test-backends.sh) and fails on any skip: // // SAMLSP_REDIS_ADDR=127.0.0.1:16379 // SAMLSP_POSTGRES_DSN=postgres://samlsp:@127.0.0.1:15432/samlsp?sslmode=disable // SAMLSP_MYSQL_DSN=root:@tcp(127.0.0.1:13306)/ // -// With SAMLSP_MYSQL_DSN, a third MySQL backend uses a latin1 database and -// a non-strict sql_mode with interpolated parameters: the deployment that -// altered raw UTF-8 payloads (audit STORE-I2). +// A third MySQL backend uses a latin1 database and a non-strict sql_mode +// with interpolated parameters: the deployment that altered raw UTF-8 +// payloads (audit STORE-I2). import ( "context" @@ -56,16 +58,51 @@ import ( _ "modernc.org/sqlite" // pure-Go SQLite driver ) +// The variables naming the opt-in servers of the shared-store tests. +const ( + envRedisAddr = "SAMLSP_REDIS_ADDR" + envPostgresDSN = "SAMLSP_POSTGRES_DSN" + envMySQLDSN = "SAMLSP_MYSQL_DSN" +) + // storeBackend opens namespaces of one persistence backend: each namespace // is fresh and empty, and opens any number of independent instances. type storeBackend struct { name string + // env names the variable holding the server of an opt-in backend; + // empty for the local ones. + env string // namespace returns an opener of instances sharing one new namespace. namespace func(t *testing.T) func() samlsp.Storage // fastForward advances the backend's expiry clock (miniredis only). fastForward func(d time.Duration) } +// skipIfUnset skips t when b is an opt-in backend whose variable is unset. +func (b storeBackend) skipIfUnset(t *testing.T) { + t.Helper() + + if b.env != "" && os.Getenv(b.env) == "" { + t.Skipf("set %s to run against a real %s (opt-in)", b.env, b.name) + } +} + +// optIn returns the opt-in backend name on the server the variable env +// names, its namespaces opened by open. The namespace skips t while env is +// unset: no client is ever built on an empty address, which go-redis would +// take for localhost:6379. +func optIn(name, env string, open func(server string) func(t *testing.T) func() samlsp.Storage) storeBackend { + b := storeBackend{name: name, env: env} + b.namespace = func(t *testing.T) func() samlsp.Storage { + t.Helper() + b.skipIfUnset(t) + + return open(os.Getenv(env))(t) + } + + return b +} + // unique returns a short random suffix for namespaces. func unique() string { b := make([]byte, 6) @@ -90,6 +127,27 @@ func sqlInstance(t *testing.T, driver, dsn string, d sqlstore.Dialect) samlsp.St return s } +// TestStoreBackendsListTheOptInServers: the opt-in servers are listed +// whether or not their variables are set, so that an unset or renamed +// variable shows as skipped subtests — which the CI job of the real +// backends refuses (scripts/test-backends.sh) — instead of backends that +// silently vanish. +func TestStoreBackendsListTheOptInServers(t *testing.T) { + for _, env := range []string{envRedisAddr, envPostgresDSN, envMySQLDSN} { + t.Setenv(env, "") + } + + var names []string + for _, b := range storeBackends(t) { + names = append(names, b.name) + } + + require.Equal(t, []string{ + "sqlite-file", "miniredis", + "redis", "postgres", "mysql", "mysql-client-found-rows", "mysql-latin1-nonstrict", + }, names) +} + // storeBackends returns the backends of this run. func storeBackends(t *testing.T) []storeBackend { t.Helper() @@ -112,24 +170,19 @@ func storeBackends(t *testing.T) []storeBackend { }, } - if addr := os.Getenv("SAMLSP_REDIS_ADDR"); addr != "" { - backends = append(backends, storeBackend{name: "redis", namespace: redisNamespace(addr)}) - } - - if dsn := os.Getenv("SAMLSP_POSTGRES_DSN"); dsn != "" { - backends = append(backends, storeBackend{name: "postgres", namespace: postgresNamespace(dsn)}) - } - - if base := os.Getenv("SAMLSP_MYSQL_DSN"); base != "" { - backends = append(backends, - storeBackend{name: "mysql", namespace: mysqlNamespace(base, "", "")}, - storeBackend{name: "mysql-client-found-rows", namespace: mysqlNamespace(base, "clientFoundRows=true", "")}, - storeBackend{name: "mysql-latin1-nonstrict", namespace: mysqlNamespace(base, - "sql_mode="+url.QueryEscape("''")+"&interpolateParams=true", "latin1")}, - ) - } - - return backends + return append(backends, + optIn("redis", envRedisAddr, redisNamespace), + optIn("postgres", envPostgresDSN, postgresNamespace), + optIn("mysql", envMySQLDSN, func(base string) func(t *testing.T) func() samlsp.Storage { + return mysqlNamespace(base, "", "") + }), + optIn("mysql-client-found-rows", envMySQLDSN, func(base string) func(t *testing.T) func() samlsp.Storage { + return mysqlNamespace(base, "clientFoundRows=true", "") + }), + optIn("mysql-latin1-nonstrict", envMySQLDSN, func(base string) func(t *testing.T) func() samlsp.Storage { + return mysqlNamespace(base, "sql_mode="+url.QueryEscape("''")+"&interpolateParams=true", "latin1") + }), + ) } func redisNamespace(addr string) func(t *testing.T) func() samlsp.Storage { @@ -406,6 +459,8 @@ func TestSharedStoreRaces(t *testing.T) { for _, b := range storeBackends(t) { t.Run(b.name, func(t *testing.T) { + b.skipIfUnset(t) + c := newCluster(t, b) rounds := raceRounds(t) @@ -512,6 +567,10 @@ func TestSharedStoreConformance(t *testing.T) { for _, b := range storeBackends(t) { t.Run(b.name, func(t *testing.T) { + // Before RunConformance: the namespace opener runs in the goroutine + // of each case, where skipping the backend's test is not allowed. + b.skipIfUnset(t) + storetest.RunConformance(t, func() samlsp.Storage { return b.namespace(t)() }) }) } @@ -553,6 +612,8 @@ func TestSharedStorePayloadRoundTrip(t *testing.T) { for _, b := range storeBackends(t) { t.Run(b.name, func(t *testing.T) { + b.skipIfUnset(t) + store := &recordingStorage{Storage: b.namespace(t)()} sp, err := samlsp.New(samlsp.Config{ From edae579457d3f17d9556863b07565592232d1240 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 21:20:38 +0200 Subject: [PATCH 3/7] ci: run the store suites on PostgreSQL, MySQL, MariaDB and Redis CI ran the SQL and Redis stores on SQLite and miniredis only; their real-server suites were opt-in and ran locally. A second job of the Go workflow, "Store suites (real backends)", now runs them with the race detector on every pull request and push to master. It uses service containers of the images the compose files pin by digest: PostgreSQL 16, MySQL 8.0 and 8.4, MariaDB 11.4 and Redis 7.4. make test-backends (scripts/test-backends.sh) runs these suites: - internal/interop/sqlstores; - the root package of internal/interop/oidcstack; - internal/interop/saml, except its Keycloak and browser-helper tests; - oauth2/store/redis. It fails when one of its eleven variables is missing, when a test fails, when a suite runs no test, and when any test is skipped. A real-backend test skips while its variable is unset, so a skip means a server the run does not provide. -count=1 keeps a cached result from standing in for a run. The services hold no credentials: PostgreSQL trusts every connection, the MySQL and MariaDB root has no password, and Redis has no auth. Their ports are published on the runner's 127.0.0.1 only, so nothing outside the disposable VM can reach them, and every process inside it is the job's own code. A generated password would not stay secret anyway: the runner prints service environments in the log. The job has a cache key of its own for its race-built packages. Audit: STORE-DL. --- .github/workflows/go.yml | 106 ++++++++++++++++++ Makefile | 9 ++ internal/interop/oidcstack/backends_test.go | 5 +- internal/interop/oidcstack/compose.yaml | 10 +- internal/interop/oidcstack/doc_test.go | 9 +- internal/interop/saml/compose.yaml | 7 +- internal/interop/sqlstores/backends_test.go | 5 +- internal/interop/sqlstores/compose.yaml | 14 ++- internal/interop/sqlstores/doc_test.go | 3 +- scripts/test-backends.sh | 114 ++++++++++++++++++++ 10 files changed, 262 insertions(+), 20 deletions(-) create mode 100755 scripts/test-backends.sh diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 39ea5043..fc394470 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -62,3 +62,109 @@ jobs: env: COVERALLS_TOKEN: ${{ github.token }} run: make coveralls + + # The store suites on real servers (make test-backends, see + # scripts/test-backends.sh), which `make test` runs on SQLite and + # miniredis only: the SQL stores on PostgreSQL, MySQL 8.0 and 8.4 and + # MariaDB, the Redis stores on Redis. Each image is pinned by the digest + # the compose.yaml files under internal/interop/ use: bump them together + # (Dependabot updates neither). + # + # No credentials: PostgreSQL trusts every connection, the MySQL and + # MariaDB root has no password, Redis has no auth. The ports are + # published on the runner's 127.0.0.1 only: nothing outside this + # disposable VM can reach the servers, and every process inside it is + # this job's own code, which would read any password from its + # environment. A generated password would not stay secret either: the + # runner prints each service's environment in the log. + backends: + name: Store suites (real backends) + runs-on: ubuntu-latest + # A backstop: go test bounds each of the four suites at 300 s. + timeout-minutes: 30 + services: + postgres: + image: postgres:16@sha256:ca0bd484cb98bf4b24eb1010e73fb3fcbd6714d240fbc1a10eea5b7dbecb641d + env: + POSTGRES_USER: stores + POSTGRES_DB: stores + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - "127.0.0.1:35432:5432" + # Over TCP: the server that initializes a database listens on its + # socket only (MySQL's runs with --skip-networking), so the checks + # pass once the final server is up. + options: >- + --health-cmd "pg_isready -h 127.0.0.1 -U stores -d stores" + --health-interval 2s --health-timeout 5s --health-retries 30 + mysql80: + image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b + env: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + ports: + - "127.0.0.1:33306:3306" + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot --silent" + --health-interval 2s --health-timeout 5s --health-retries 60 + mysql84: + image: mysql:8.4@sha256:24989179f58856db763f418010c1169fecc944e3ec1ea7e921572d83eb070f70 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + ports: + - "127.0.0.1:33307:3306" + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot --silent" + --health-interval 2s --health-timeout 5s --health-retries 60 + mariadb: + image: mariadb:11.4@sha256:1292844148b311e4ed4300022a996d39083f415a963e970cf47cad1b3b18e3a6 + env: + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "1" + ports: + - "127.0.0.1:33308:3306" + options: >- + --health-cmd "healthcheck.sh --connect --innodb_initialized" + --health-interval 2s --health-timeout 5s --health-retries 60 + # The image's defaults: maxmemory 0 and the noeviction policy the + # stores require. Its RDB snapshots stay on, which is harmless here + # (the compose files turn them off). + redis: + image: redis:7.4@sha256:4fa24486b8bcca8eec45ee0eb166edc674795e53a2b53d1a9ef263eecebaac85 + ports: + - "127.0.0.1:36379:6379" + options: >- + --health-cmd "redis-cli ping" + --health-interval 2s --health-timeout 5s --health-retries 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.work + # A cache key of this job's own, for its race-built packages: + # hashing the script with the go.sum files makes it differ from + # the key the other jobs share. + cache-dependency-path: | + **/go.sum + scripts/test-backends.sh + + # Generates the mocks first. Fails when a test fails, when a suite + # runs no test, and when a test is skipped: a server this job does + # not provide. + - name: Test the stores on PostgreSQL, MySQL, MariaDB and Redis + env: + SQLSTORES_POSTGRES_DSN: postgres://stores@127.0.0.1:35432/stores?sslmode=disable + SQLSTORES_MYSQL80_DSN: root@tcp(127.0.0.1:33306)/ + SQLSTORES_MYSQL84_DSN: root@tcp(127.0.0.1:33307)/ + SQLSTORES_MARIADB_DSN: root@tcp(127.0.0.1:33308)/ + OIDCCLIENT_POSTGRES_DSN: postgres://stores@127.0.0.1:35432/stores?sslmode=disable + OIDCCLIENT_MYSQL_DSN: root@tcp(127.0.0.1:33306)/ + OIDCCLIENT_REDIS_ADDR: 127.0.0.1:36379 + SAMLSP_POSTGRES_DSN: postgres://stores@127.0.0.1:35432/stores?sslmode=disable + SAMLSP_MYSQL_DSN: root@tcp(127.0.0.1:33306)/ + SAMLSP_REDIS_ADDR: 127.0.0.1:36379 + OAUTH2_TEST_REDIS_ADDR: 127.0.0.1:36379 + run: make test-backends diff --git a/Makefile b/Makefile index 4eca7460..a1082f31 100644 --- a/Makefile +++ b/Makefile @@ -77,6 +77,15 @@ test: _build generate @cat $(BUILD_DIR)/coverage.body >> $(BUILD_DIR)/coverage.out @rm -f $(BUILD_DIR)/*.cover $(BUILD_DIR)/coverage.body +# The store suites on real PostgreSQL, MySQL, MariaDB and Redis servers, +# which `make test` runs on SQLite and miniredis only. The header of +# scripts/test-backends.sh lists the variables naming the servers and +# starts them locally; CI runs it in the "Store suites (real backends)" +# job, against service containers. +.PHONY: test-backends +test-backends: generate + @./scripts/test-backends.sh + # Sends build/coverage.out to Coveralls with goveralls (tools/goveralls). CI # passes the workflow token as COVERALLS_TOKEN (goveralls' GitHub Actions # setup). diff --git a/internal/interop/oidcstack/backends_test.go b/internal/interop/oidcstack/backends_test.go index f5195699..930a7a93 100644 --- a/internal/interop/oidcstack/backends_test.go +++ b/internal/interop/oidcstack/backends_test.go @@ -27,8 +27,9 @@ import ( "github.com/stretchr/testify/require" ) -// The real backends are opt-in — routine CI never sets these variables. -// Start them with compose.yaml (see its header), then: +// The real backends are opt-in: a test skips while its variable is unset. +// CI sets them all (scripts/test-backends.sh) and fails on any skip. +// Locally, start them with compose.yaml (see its header), then: // // OIDCCLIENT_POSTGRES_DSN=postgres://oidcclient:$OIDCCLIENT_DB_PASSWORD@127.0.0.1:25432/oidcclient?sslmode=disable // OIDCCLIENT_MYSQL_DSN=root:$OIDCCLIENT_DB_PASSWORD@tcp(127.0.0.1:23306)/oidcclient diff --git a/internal/interop/oidcstack/compose.yaml b/internal/interop/oidcstack/compose.yaml index eb60db06..d640d8e9 100644 --- a/internal/interop/oidcstack/compose.yaml +++ b/internal/interop/oidcstack/compose.yaml @@ -1,8 +1,10 @@ # Disposable databases for the opt-in real-backend tests of the OpenID -# Connect client's transaction stores (never used by routine CI). Images -# are pinned by digest — the ones internal/interop/saml uses — and every -# port is bound to 127.0.0.1 on a non-default port, distinct from that -# project's, so both can run at once. Nothing is persisted. +# Connect client's transaction stores. CI runs the same tests against +# service containers of the same images (the "Store suites (real +# backends)" job of .github/workflows/go.yml): bump the digests together. +# Images are pinned by digest — the ones internal/interop/saml uses — and +# every port is bound to 127.0.0.1 on a non-default port, distinct from +# that project's, so both can run at once. Nothing is persisted. # # export OIDCCLIENT_DB_PASSWORD="$(openssl rand -hex 16)" # docker compose -f internal/interop/oidcstack/compose.yaml up -d --wait diff --git a/internal/interop/oidcstack/doc_test.go b/internal/interop/oidcstack/doc_test.go index ca51daa0..7f669911 100644 --- a/internal/interop/oidcstack/doc_test.go +++ b/internal/interop/oidcstack/doc_test.go @@ -5,8 +5,9 @@ // Package oidcstack_test checks this repository's OpenID Connect stack // end to end — the relying party of oidc/client against the provider of // oidc/provider — and runs the client's transaction stores on real -// PostgreSQL, MySQL and Redis servers (opt-in). The module holds tests -// only; they import no third-party OpenID Connect, OAuth 2.0 or JOSE -// library, so that their evidence is not mediated by one — the client's -// own go-jose is part of the code under test. +// PostgreSQL, MySQL and Redis servers (opt-in, run in CI by +// scripts/test-backends.sh). The module holds tests only; they import no +// third-party OpenID Connect, OAuth 2.0 or JOSE library, so that their +// evidence is not mediated by one — the client's own go-jose is part of +// the code under test. package oidcstack_test diff --git a/internal/interop/saml/compose.yaml b/internal/interop/saml/compose.yaml index bcc2c5d8..3e02bd51 100644 --- a/internal/interop/saml/compose.yaml +++ b/internal/interop/saml/compose.yaml @@ -1,6 +1,9 @@ # Disposable services for the opt-in SAML interoperability and persistence -# tests of this module (never used by routine CI). Images are pinned by -# digest; credentials come from the environment and nothing is persisted. +# tests of this module. CI runs the persistence tests against service +# containers of the same database images (the "Store suites (real +# backends)" job of .github/workflows/go.yml): bump the digests together; +# Keycloak stays opt-in. Images are pinned by digest; credentials come from +# the environment and nothing is persisted. # # export SAMLSP_KEYCLOAK_ADMIN_PASSWORD="$(openssl rand -hex 16)" # export SAMLSP_DB_PASSWORD="$(openssl rand -hex 16)" diff --git a/internal/interop/sqlstores/backends_test.go b/internal/interop/sqlstores/backends_test.go index 30294e53..8a481ac9 100644 --- a/internal/interop/sqlstores/backends_test.go +++ b/internal/interop/sqlstores/backends_test.go @@ -24,8 +24,9 @@ import ( _ "modernc.org/sqlite" // registers the "sqlite" database/sql driver ) -// The real backends are opt-in — routine CI never sets these variables. -// Start them with compose.yaml (see its header), then: +// The real backends are opt-in: a test skips while its variable is unset. +// CI sets them all (scripts/test-backends.sh) and fails on any skip. +// Locally, start them with compose.yaml (see its header), then: // // SQLSTORES_POSTGRES_DSN=postgres://sqlstores:$SQLSTORES_DB_PASSWORD@127.0.0.1:35432/sqlstores?sslmode=disable // SQLSTORES_MYSQL80_DSN=root:$SQLSTORES_DB_PASSWORD@tcp(127.0.0.1:33306)/ diff --git a/internal/interop/sqlstores/compose.yaml b/internal/interop/sqlstores/compose.yaml index 9b3d5182..8454e7c6 100644 --- a/internal/interop/sqlstores/compose.yaml +++ b/internal/interop/sqlstores/compose.yaml @@ -1,9 +1,13 @@ # Disposable databases for the opt-in real-backend tests of the SQL stores -# (oauth2/store/sql, oidc/client/store/sql, saml/sp/store/sql); routine CI -# runs the SQLite cases only. Images are pinned by digest — postgres:16 and -# mysql:8.0 are the ones the other interop projects use — and every port is -# bound to 127.0.0.1 on a non-default port, distinct from those projects', -# so all of them can run at once. Nothing is persisted. +# (oauth2/store/sql, oidc/client/store/sql, saml/sp/store/sql). CI runs the +# same tests against service containers of the same images (the "Store +# suites (real backends)" job of .github/workflows/go.yml): bump the +# digests together. Images are pinned by digest — postgres:16 and mysql:8.0 +# are the ones the other interop projects use — and every port is bound to +# 127.0.0.1 on a non-default port, distinct from those projects', so all +# of them can run at once. Nothing is persisted. With the Redis of +# internal/interop/oidcstack/compose.yaml, these servers also run +# `make test-backends` (scripts/test-backends.sh). # # export SQLSTORES_DB_PASSWORD="$(openssl rand -hex 16)" # docker compose -f internal/interop/sqlstores/compose.yaml up -d --wait diff --git a/internal/interop/sqlstores/doc_test.go b/internal/interop/sqlstores/doc_test.go index f6babd10..89748dd8 100644 --- a/internal/interop/sqlstores/doc_test.go +++ b/internal/interop/sqlstores/doc_test.go @@ -8,5 +8,6 @@ // advisory lock Migrate takes on PostgreSQL, the oauth2 storage // conformance suite, and the MySQL upgrade statements of MIGRATION.md. The // module holds tests only and is never released. SQLite runs everywhere; -// PostgreSQL, MySQL and MariaDB are opt-in (see compose.yaml). +// PostgreSQL, MySQL and MariaDB are opt-in (see compose.yaml) and run in +// CI (scripts/test-backends.sh). package sqlstores_test diff --git a/scripts/test-backends.sh b/scripts/test-backends.sh new file mode 100755 index 00000000..885ab81b --- /dev/null +++ b/scripts/test-backends.sh @@ -0,0 +1,114 @@ +#!/bin/sh +# Copyright 2026 Hyperscale. All rights reserved. +# Use of this source code is governed by a MIT +# license that can be found in the LICENSE file. +# +# Runs the store suites on real servers, with the race detector +# (`make test-backends`; in CI, the "Store suites (real backends)" job of +# .github/workflows/go.yml, against service containers): +# +# internal/interop/sqlstores the three SQL stores on PostgreSQL, MySQL +# 8.0 and 8.4, MariaDB (and SQLite) +# internal/interop/oidcstack the OIDC client stores on PostgreSQL, +# MySQL and Redis (the root package) +# internal/interop/saml the SAML stores on PostgreSQL, MySQL and +# Redis (the whole package but its Keycloak +# and browser-helper tests, opt-in on their +# own) +# oauth2/store/redis the oauth2 store on Redis +# +# Every variable below must name a disposable server: the tests create a +# namespace of their own (schema, database or key prefix) and drop it +# afterwards. The run fails when a variable is missing, when a test fails, +# when a suite runs no test, and when any test is skipped: a real-backend +# test skips while its variable is unset, so a skip means a server this +# run does not provide. A new real-backend test follows that rule, and its +# variable is added here and to the job. +# +# Locally (Docker), the sqlstores compose file and the Redis of the +# oidcstack one provide every server: +# +# export SQLSTORES_DB_PASSWORD="$(openssl rand -hex 16)" +# export OIDCCLIENT_DB_PASSWORD="$(openssl rand -hex 16)" +# docker compose -f internal/interop/sqlstores/compose.yaml up -d --wait +# docker compose -f internal/interop/oidcstack/compose.yaml up -d --wait redis +# pg="postgres://sqlstores:$SQLSTORES_DB_PASSWORD@127.0.0.1:35432/sqlstores?sslmode=disable" +# my="root:$SQLSTORES_DB_PASSWORD@tcp(127.0.0.1" +# export SQLSTORES_POSTGRES_DSN="$pg" OIDCCLIENT_POSTGRES_DSN="$pg" SAMLSP_POSTGRES_DSN="$pg" +# export SQLSTORES_MYSQL80_DSN="$my:33306)/" OIDCCLIENT_MYSQL_DSN="$my:33306)/" SAMLSP_MYSQL_DSN="$my:33306)/" +# export SQLSTORES_MYSQL84_DSN="$my:33307)/" SQLSTORES_MARIADB_DSN="$my:33308)/" +# export OIDCCLIENT_REDIS_ADDR=127.0.0.1:26379 SAMLSP_REDIS_ADDR=127.0.0.1:26379 OAUTH2_TEST_REDIS_ADDR=127.0.0.1:26379 +# make test-backends +# +# then stop them: docker compose -f down. +set -eu + +cd "$(dirname "$0")/.." + +command -v jq >/dev/null 2>&1 || { + echo "test-backends: jq is required" >&2 + exit 1 +} + +missing= +for var in SQLSTORES_POSTGRES_DSN SQLSTORES_MYSQL80_DSN SQLSTORES_MYSQL84_DSN SQLSTORES_MARIADB_DSN \ + OIDCCLIENT_POSTGRES_DSN OIDCCLIENT_MYSQL_DSN OIDCCLIENT_REDIS_ADDR \ + SAMLSP_POSTGRES_DSN SAMLSP_MYSQL_DSN SAMLSP_REDIS_ADDR \ + OAUTH2_TEST_REDIS_ADDR; do + eval "value=\${$var:-}" + [ -n "$value" ] || missing="$missing $var" +done +if [ -n "$missing" ]; then + echo "test-backends: set$missing (see scripts/test-backends.sh)" >&2 + exit 1 +fi + +mkdir -p build +failed= + +# The go test -json events of a file; a line that is not JSON (it should +# not happen) becomes an output event, so that it is printed, not lost. +# shellcheck disable=SC2016 # a jq program: $line is jq's, not the shell's +events='. as $line | try fromjson catch {Action: "output", Output: ($line + "\n")}' + +# suite MODULE PACKAGES [SKIP]: runs the tests of PACKAGES in MODULE but +# those SKIP matches, prints their log, and records MODULE as failed when +# a test fails, a test is skipped or no test runs. +suite() { + out="$PWD/build/backends-$(printf '%s' "$1" | tr / -).json" + echo "==> $1" + status=0 + if [ -n "${3:-}" ]; then + (cd "$1" && go test -race -count=1 -timeout 300s -json -skip "$3" "$2" >"$out") || status=$? + else + (cd "$1" && go test -race -count=1 -timeout 300s -json "$2" >"$out") || status=$? + fi + jq -Rj "$events"' | select(.Action == "output" or .Action == "build-output") | .Output' "$out" + skipped=$(jq -Rr "$events"' | select(.Action == "skip" and .Test != null) | .Test' "$out") + passed=$(jq -Rn "[inputs | $events"' | select(.Action == "pass" and .Test != null)] | length' "$out") + if [ "$status" -ne 0 ]; then + failed="$failed $1" + echo "test-backends: $1: FAILED (go test exited with $status)" >&2 + elif [ -n "$skipped" ]; then + failed="$failed $1" + printf '%s\n' "$skipped" >&2 + echo "test-backends: $1: FAILED: the tests above were skipped, a server this run does not provide" >&2 + elif [ "$passed" -eq 0 ]; then + failed="$failed $1" + echo "test-backends: $1: FAILED: no test ran" >&2 + else + echo "test-backends: $1: $passed tests passed, none skipped" + fi +} + +suite internal/interop/sqlstores ./... +suite internal/interop/oidcstack . +suite internal/interop/saml . '^(TestKeycloakLive|TestCaptureKeycloakFixture|TestE2EHelperProcess)$' +suite oauth2/store/redis ./... + +if [ -n "$failed" ]; then + echo "test-backends: failed:$failed" >&2 + exit 1 +fi + +echo "test-backends: ok" From d265bc423005ca82ea8facd3e3c1f7365719f07f Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 21:29:58 +0200 Subject: [PATCH 4/7] docs: state that CI runs the store suites on real servers LIMITATIONS, the security considerations, the OpenID Connect and SAML guides, the CHANGELOG and the package documentation of the three SQL stores said that PostgreSQL and MySQL were not exercised in CI, and that their suites ran locally only. The "Store suites (real backends)" job now runs them, so they say what CI runs and on which servers: PostgreSQL 16.15, MySQL 8.0.46 and 8.4.11, MariaDB 11.4.13, Redis 7.4.11. They also note that MySQL 8.0 reached its end of life in April 2026, and that other versions, compatible engines, replicas and proxies stay untested. docs/releasing.md now covers both jobs of the Go workflow in the release checklist. It names the new job as a required check once proven stable, and warns that renaming a job breaks the required check. It also notes that Dependabot updates neither the service images nor the compose files, which pin the same digests and are bumped together. README and MIGRATION list `make test-backends`. Audit: STORE-DL. --- CHANGELOG.md | 27 +++++++++++++---- LIMITATIONS.md | 54 +++++++++++++++++++-------------- MIGRATION.md | 5 +-- README.md | 1 + docs/architecture.md | 4 +-- docs/oidc-conformance.md | 9 +++--- docs/releasing.md | 20 +++++++++--- docs/saml-sp.md | 13 ++++---- docs/security-considerations.md | 10 +++--- oauth2/store/sql/doc.go | 10 +++--- oidc/client/store/sql/doc.go | 10 +++--- saml/sp/store/sql/doc.go | 8 ++--- 12 files changed, 106 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 916815a8..0cc00062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,7 +147,8 @@ legacy packages (`authentication/`, `authorization/`, the in-tree replay of its 14 captures in CI, including XSW1–XSW8, signature removal and comment injection on Apache Santuario output; - races across Service Provider instances on shared SQL and Redis stores: - SQLite and miniredis in CI, PostgreSQL, MySQL and Redis opt-in; + SQLite and miniredis in `make test`, PostgreSQL, MySQL and Redis in CI's + `make test-backends` (opt-in locally); - IdP metadata retrieval behind a Service Provider: key rollover, fetches driven by time only, entity pin, outbound refusals; - an opt-in browser suite (`make e2e`, Tales and Chrome) driving @@ -251,7 +252,8 @@ legacy packages (`authentication/`, `authorization/`, the in-tree private and test-only, #86): the `oidc/client/storetest` conformance suite and a race of 120 consumers spread over four store instances — 20 rounds, a third with a wrong binding — on real PostgreSQL 16, MySQL 8.0 and - Redis 7.4 (digest-pinned `compose.yaml`), opt-in through + Redis 7.4 (digest-pinned `compose.yaml`), run by CI + (`make test-backends`) and opt-in locally through `OIDCCLIENT_POSTGRES_DSN`, `OIDCCLIENT_MYSQL_DSN`, `OIDCCLIENT_REDIS_ADDR`. - **End-to-end tests of the OpenID Connect stack** (`internal/interop/oidcstack`, #85, #86): our Relying Party against our Provider over HTTP — every @@ -617,6 +619,19 @@ legacy packages (`authentication/`, `authorization/`, the in-tree with until now (HTTP/2 crashes, request smuggling, `crypto/tls`, `encoding/asn1`; govulncheck finds none on go1.27.2). CI runs the version `go.work` names. See [MIGRATION.md](MIGRATION.md). +- **Store suites on real servers in CI**: a second job of the Go + workflow, `Store suites (real backends)`, runs the real-backend tests of + `internal/interop/sqlstores`, `internal/interop/oidcstack`, + `internal/interop/saml` and `oauth2/store/redis` with the race detector, + against PostgreSQL 16, MySQL 8.0 and 8.4, MariaDB 11.4 and Redis 7.4 + service containers pinned by the digests of the compose files. They used + to run locally only, so the PostgreSQL and MySQL locking the SQL stores + rely on was never checked by CI. `make test-backends` + (`scripts/test-backends.sh`) runs them wherever the servers are: it + fails when a variable is missing, when a suite runs no test, and when a + test is skipped — a server the run does not provide. The SAML + shared-store tests and the `oauth2/store/redis` legacy-payload tests now + report an unset real server as skipped instead of leaving it out. - **Module and release checks**: `make check-modules` (run by CI) fails unless `go.work` and every `go.mod` share one `go` line, `go.work` lists every module, `go work sync` changes nothing and every module builds, vets @@ -958,10 +973,10 @@ legacy packages (`authentication/`, `authorization/`, the in-tree in each package: concurrent calls run one after the other, a waiting call waits as long as its context allows, and a failed call creates nothing. MySQL, MariaDB and SQLite (with the busy timeout the stores require) - already serialized the creation of a table, and run as before. The opt-in - suite of the new private module `internal/interop/sqlstores` checks the - four engines (PostgreSQL 16, MySQL 8.0 and 8.4, MariaDB 11.4, from its - `compose.yaml`). + already serialized the creation of a table, and run as before. The suite + of the new private module `internal/interop/sqlstores` checks the four + engines (PostgreSQL 16, MySQL 8.0 and 8.4, MariaDB 11.4, from its + `compose.yaml`), in CI too (`make test-backends`). - The `oauth2.Storage` contract no longer says that a failed `SaveTokenPair` or `RotateRefreshToken` persists nothing (I3). When the acknowledgement of a commit is lost, the call fails although all its diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 55f36978..cd66fb67 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -53,13 +53,15 @@ future refactor phases. anything: consumed and expired codes, expired tokens and revoked families stay for the process lifetime. It is meant for development and tests only. -- **PostgreSQL and MySQL are not exercised in CI** — CI runs the SQL - store on SQLite only; its PostgreSQL and MySQL guarantees (row locks, - READ COMMITTED on PostgreSQL, InnoDB on MySQL) are argued from their - locking semantics. The opt-in suite of `internal/interop/sqlstores` - (PostgreSQL 16, MySQL 8.0 and 8.4, MariaDB 11.4, from its `compose.yaml`) - runs the conformance suite, `Migrate` concurrently and the MySQL upgrade - statements on them, locally only. +- **Tested servers** — CI runs the SQL store on SQLite and, in its + "Store suites (real backends)" job (`make test-backends`), on + PostgreSQL 16.15, MySQL 8.0.46 and 8.4.11 and MariaDB 11.4.13: the + conformance suite, concurrent `Migrate` and the MySQL upgrade statements + (`internal/interop/sqlstores`). The Redis store runs its conformance + suite on Redis 7.4.11. Other versions, compatible engines, replicas and + proxies are not tested: there the guarantees (row locks, READ COMMITTED + on PostgreSQL, InnoDB on MySQL) rest on the locking semantics the + package documentation describes. - **SQL schema upgrades are manual** — `Store.Migrate` creates missing tables and never alters an existing one: the columns a release adds (#80's `consumed`, #83's `issuer` and `extension_data`, the codes' @@ -276,21 +278,23 @@ PKCE. Not supported, and refused where a provider could select them: (`memory.ErrFull`) until transactions expire or are consumed. `oidc/client/store/sql` ships `PurgeExpired`, a single unbatched `DELETE` the operator schedules (index `expires_at`, see its - package documentation), and its PostgreSQL and MySQL guarantees are not - exercised in CI (SQLite only) — the opt-in runs of - `internal/interop/oidcstack` (`TestRealBackend*`, `compose.yaml`) cover - them, with Redis, locally. On Redis, a consumption acknowledged by a - primary that fails before replicating it can be lost in a failover, - making the transaction consumable again: single use then rests on the - provider's single-use authorization code and on the binding secret only - the user agent holds. The browser test of the binding cookie - (`internal/interop/oidcrp`, headless Chrome, `OIDC_BROWSER_TESTS=1`) and - the live discovery check against six real providers - (`OIDC_LIVE_DISCOVERY=1`) are opt-in and do not run in CI. + package documentation). CI (`make test-backends`) runs the conformance + suite and the replica races of the SQL and Redis stores on PostgreSQL + 16.15, MySQL 8.0.46 (end of life since April 2026) and Redis 7.4.11 + (`internal/interop/oidcstack`, `TestRealBackend*`), and the concurrent + `Migrate` of the SQL store also on MySQL 8.4.11 and MariaDB 11.4.13 + (`internal/interop/sqlstores`); other versions are not tested. On Redis, + a consumption acknowledged by a primary that fails before replicating it + can be lost in a failover, making the transaction consumable again: + single use then rests on the provider's single-use authorization code + and on the binding secret only the user agent holds. The browser test of + the binding cookie (`internal/interop/oidcrp`, headless Chrome, + `OIDC_BROWSER_TESTS=1`) and the live discovery check against six real + providers (`OIDC_LIVE_DISCOVERY=1`) are opt-in and do not run in CI. - **Opt-in evidence** — the end-to-end suites (`make e2e-oidc`), the - conformance-suite runs (`make conformance`), the real-backend, browser - and live checks need Docker, Chrome, Tales or the Internet and run - locally only; [docs/oidc-conformance.md](docs/oidc-conformance.md) keeps + conformance-suite runs (`make conformance`), the browser and live checks + need Docker, Chrome, Tales or the Internet and run locally only; + [docs/oidc-conformance.md](docs/oidc-conformance.md) keeps their latest results and the release checklist. - **Provider metadata in the wild** — of the six providers of the pinned corpus, only Auth0 lists `none` in `token_endpoint_auth_methods_supported`: @@ -436,8 +440,12 @@ PKCE. Not supported, and refused where a provider could select them: - **Stores** — `saml/sp/storage/memory` never purges anything (development and tests only); `saml/sp/store/sql` ships no expiry job (its package documentation gives the cleanup statements). CI runs the stores on - SQLite and miniredis; PostgreSQL 16, MySQL 8.0 and Redis 7.4 are covered - by an opt-in suite (`internal/interop/saml/compose.yaml`). + SQLite and miniredis, and (`make test-backends`) their races, + conformance suite and payload round trip on PostgreSQL 16.15, MySQL + 8.0.46 (end of life since April 2026) and Redis 7.4.11 + (`internal/interop/saml`), and the concurrent `Migrate` of the SQL store + also on MySQL 8.4.11 and MariaDB 11.4.13 (`internal/interop/sqlstores`); + other versions are not tested. - **SP key rollover with Keycloak** — a Keycloak SAML client keeps one SP signing certificate: `AdditionalSigningCertificates` cannot announce the next key there, and the client's certificate must be replaced when the diff --git a/MIGRATION.md b/MIGRATION.md index 464ff440..8c5397d5 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -32,9 +32,9 @@ released on its own cadence. | `./internal/integrations` | `github.com/hyperscale-stack/security/internal/integrations` | Cross-module end-to-end tests (private) | | `./internal/interop/oidcop` | `github.com/hyperscale-stack/security/internal/interop/oidcop` | OpenID Provider interoperability tests with go-oidc + x/oauth2 (private, tests only) | | `./internal/interop/oidcrp` | `github.com/hyperscale-stack/security/internal/interop/oidcrp` | OIDC client interoperability tests against zitadel/oidc (private, tests only) | -| `./internal/interop/oidcstack` | `github.com/hyperscale-stack/security/internal/interop/oidcstack` | Our RP against our OP end to end, attacks included; opt-in real-backend runs of the client stores (private, tests only) | +| `./internal/interop/oidcstack` | `github.com/hyperscale-stack/security/internal/interop/oidcstack` | Our RP against our OP end to end, attacks included; real-backend runs of the client stores (CI: `make test-backends`) (private, tests only) | | `./internal/interop/saml` | `github.com/hyperscale-stack/security/internal/interop/saml` | SAML interoperability tests: crewjam/saml IdP, Keycloak, shared stores, e2e helper (private, tests only) | -| `./internal/interop/sqlstores` | `github.com/hyperscale-stack/security/internal/interop/sqlstores` | The three SQL stores on SQLite and, opt-in, real PostgreSQL / MySQL / MariaDB: concurrent `Migrate`, the oauth2 conformance suite, the MySQL upgrade statements (private, tests only) | +| `./internal/interop/sqlstores` | `github.com/hyperscale-stack/security/internal/interop/sqlstores` | The three SQL stores on SQLite and real PostgreSQL / MySQL / MariaDB (CI: `make test-backends`): concurrent `Migrate`, the oauth2 conformance suite, the MySQL upgrade statements (private, tests only) | `oauth2/storage/memory` is a sub-package of the `oauth2` module (not a standalone module): it ships the in-memory `oauth2.Storage` used for dev @@ -151,6 +151,7 @@ make lint # golangci-lint (tools/golangci-lint) on every module with the make vuln # govulncheck -test on every module, outside the workspace make tidy # go work sync + go mod tidy on every module and tool + go work sync make check-modules # one go line, go.work complete, sync no-op, every module on its own (GOWORK=off) +make test-backends # the store suites on real PostgreSQL/MySQL/MariaDB/Redis servers (Docker; scripts/test-backends.sh), run by CI make e2e # opt-in browser suites (Tales + Chrome), never run by CI ``` diff --git a/README.md b/README.md index fd85e7b5..483838c3 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ make test # race + coverage make lint # golangci-lint with the shared config (pinned in tools/golangci-lint) make vuln # govulncheck on every module make check-modules # every module on its own (GOWORK=off), workspace in sync +make test-backends # the store suites on real PostgreSQL, MySQL, MariaDB and Redis servers (run by CI; locally, start them as scripts/test-backends.sh shows) make e2e # opt-in browser suites (Tales and Chrome: docs/saml-sp.md, e2e/oidc/run.sh) make conformance # opt-in: the OpenID Foundation conformance suite, locally (docs/oidc-conformance.md) ``` diff --git a/docs/architecture.md b/docs/architecture.md index 11464322..e6b31389 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,9 +46,9 @@ transitive dependencies. | `./internal/integrations` | (private) | Cross-module end-to-end tests | | `./internal/interop/oidcop` | (private) | OpenID Provider interoperability tests (go-oidc + x/oauth2 as the RP) | | `./internal/interop/oidcrp` | (private) | OIDC client interoperability tests (zitadel/oidc, headless Chrome) | -| `./internal/interop/oidcstack` | (private) | Our RP against our OP, attacks included; opt-in real PG/MySQL/Redis store runs | +| `./internal/interop/oidcstack` | (private) | Our RP against our OP, attacks included; real PG/MySQL/Redis store runs (CI: `make test-backends`) | | `./internal/interop/saml` | (private) | SAML interoperability tests (crewjam/saml IdP, Keycloak, shared stores, e2e helper) | -| `./internal/interop/sqlstores` | (private) | The three SQL stores on SQLite and, opt-in, real PG/MySQL/MariaDB: concurrent `Migrate`, oauth2 conformance, MySQL upgrade | +| `./internal/interop/sqlstores` | (private) | The three SQL stores on SQLite and real PG/MySQL/MariaDB (CI: `make test-backends`): concurrent `Migrate`, oauth2 conformance, MySQL upgrade | `oauth2/storage/memory` is a sub-package of the `oauth2` module (not a separate module) — it ships an in-memory `oauth2.Storage` for dev and tests. diff --git a/docs/oidc-conformance.md b/docs/oidc-conformance.md index dc7befe2..2de6f60b 100644 --- a/docs/oidc-conformance.md +++ b/docs/oidc-conformance.md @@ -19,7 +19,7 @@ and the OAuth 2.0 Security BCP (RFC 9700). | Unit and protocol tests | `oidc/provider`, `oidc/client`, `oauth2`, `jwt` | CI | every rule of the matrices below, with fakes | | Our client against our provider | `internal/interop/oidcstack` | CI | complete flows over HTTP, attacks and failures across both modules | | Independent implementations | `internal/interop/oidcop`, `internal/interop/oidcrp` | CI | our provider through go-oidc + x/oauth2; our client against zitadel/oidc and real metadata | -| Real stores | `internal/interop/oidcstack` (`TestRealBackend*`) | opt-in, local | atomic consumption of login transactions on PostgreSQL 16, MySQL 8.0, Redis 7.4 | +| Real stores | `internal/interop/oidcstack` (`TestRealBackend*`) | CI (`make test-backends`) | atomic consumption of login transactions on PostgreSQL 16, MySQL 8.0, Redis 7.4 | | Browser end to end | `e2e/oidc` (Tales), `internal/interop/oidcrp` (chromedp) | opt-in, local | the two examples through HTTP and a real Chrome; SameSite behaviour of the binding cookie | | OpenID Foundation suite | `internal/interop/oidcstack/conformance` (`make conformance`) | opt-in, local | the suite's OP and RP plans, every module's result pinned | | Live | `OIDC_LIVE_DISCOVERY=1`, `make e2e-oidc-live` | opt-in, manual | real providers' metadata; a real account's sign-in | @@ -309,7 +309,8 @@ The release process itself (versions, order, `make release-check`) is in [releasing.md](releasing.md). Before tagging `oidc/provider` or `oidc/client`, also check: -1. CI is green (`make sync build test lint`): every test cited above. +1. CI is green (`make sync build test lint`, `make test-backends`): every + test cited above. 2. `make e2e-oidc` passes: 21 scenarios. 3. `make conformance` exits 0: every OP module ends as listed above (one expected warning, one expected skip, four reviews whose images are those @@ -318,8 +319,8 @@ The release process itself (versions, order, `make release-check`) is in show, the provider's error page for the two `redirect_uri` modules), every RP module as `testdata/rp-expected.json` pins. 4. When the stores or the provider's key handling changed, the opt-in - runs too: the real backends (`internal/interop/oidcstack/compose.yaml`), - `OIDC_BROWSER_TESTS=1`, `OIDC_LIVE_DISCOVERY=1`. + runs too: `OIDC_BROWSER_TESTS=1`, `OIDC_LIVE_DISCOVERY=1` (the real + stores run in CI, item 1). 5. Add a row below. A failure in 1 to 3 blocks the tag. A new expected failure, skip or diff --git a/docs/releasing.md b/docs/releasing.md index a1a78f31..4d77c57c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -38,8 +38,10 @@ requirements are the `github.com/hyperscale-stack/security…` lines of its ## Checklist -1. **master is green.** The `Go` and `govulncheck` workflows passed on the - commit to release, and no reachable advisory is open (see below). +1. **master is green.** The `Go` workflow (both jobs: the build and test + of the workspace, and the store suites on real servers) and the + `govulncheck` workflow passed on the commit to release, and no reachable + advisory is open (see below). 2. **Release commit** (a pull request to master): - `CHANGELOG.md`: turn `[Unreleased]` into `[1.0.0] - YYYY-MM-DD`. - Set every in-repo requirement of every module of this release to its new @@ -147,6 +149,12 @@ requirements are the `github.com/hyperscale-stack/security…` lines of its `cd && go get @`, then `make tidy`. For a tool: `cd tools/ && GOWORK=off go get -tool @`. Then `make check-modules` and a pull request. +- Container images: Dependabot updates neither the service images of the + `Store suites (real backends)` job (`.github/workflows/go.yml`) nor the + `compose.yaml` files under `internal/interop/`. They pin the same images + by digest: bump them together, by hand. `docker buildx imagetools inspect + :` prints a tag's current digest, the image index that covers + every platform. ## Tests of published modules @@ -166,8 +174,12 @@ These are not versioned in the repository; set them once: - untick "Allow GitHub Actions to create and approve pull requests"; - require actions to be pinned to a full-length commit SHA. - Rulesets: - - `master`: pull request plus the required status check - `Build & test (workspace)`; + - `master`: pull request plus the required status checks + `Build & test (workspace)` and `Store suites (real backends)`, the + latter once it has proven stable (its first pull request run, three + re-runs and the first push to master, all green). A required check is + matched by its job name: renaming a job of `go.yml` needs the same + change here, or every pull request waits for a check that never comes; - tags `v*` and `**/v*`: creation, update and deletion restricted to maintainers. - Releases: enable release immutability, so the tag of a published release diff --git a/docs/saml-sp.md b/docs/saml-sp.md index 3d07b04f..196667c4 100644 --- a/docs/saml-sp.md +++ b/docs/saml-sp.md @@ -530,7 +530,7 @@ resolver failures are returned as they are and do not match `ErrRejected`. | Pinned adversarial corpus | `saml/sp/testdata/corpus`, `TestCorpus` | 390 entries over 360 signed responses: wrong issuer, audience, destination, recipient and InResponseTo; the received location and the origin of the POST; time windows; statuses; missing fields; untrusted keys and `KeyInfo` without certificate; XSW1–XSW8 and wrapping variants; duplicate IDs; substitution; algorithms; malformed XML; size and decompression limits; parser differentials; records altered in the store. Each entry pins the full refusal message, or the exact identity, and whether the login was consumed. `TestCorpusCoversEveryRule` requires every refusal rule of the sources in some entry | | crewjam/saml IdP | `internal/interop/saml` | SSO over TLS, metadata exchange both ways, request signatures checked by an independent verifier, IdP and SP key rollover, SHA-1 policy, two IdPs and two connections, cross-tenant substitution, replay, concurrent instances | | Keycloak replays | `internal/interop/saml/testdata/keycloak-*` | 15 pinned Keycloak responses (14 from 26.7.4, one from 26.0.7); XSW1–XSW8, signature removal, tampering and comment injection applied to the signed 26.7.4 ones | -| Shared stores | `internal/interop/saml` | three SP instances with their own handles on a SQLite file (WAL) and on miniredis; 16 contenders per race, held at the store until all arrive; the conformance suite, retention over time included | +| Shared stores | `internal/interop/saml` | three SP instances with their own handles on a SQLite file (WAL) and on miniredis and, in the "Store suites (real backends)" job (`make test-backends`), on PostgreSQL 16, MySQL 8.0 (also with `clientFoundRows`, and as a latin1 database under a non-strict `sql_mode`) and Redis 7.4; 16 contenders per race, held at the store until all arrive; the conformance suite, retention over time included | | Metadata retrieval | `internal/interop/saml` | a `MetadataSource` behind a connection: key rollover, a hung refresh that never delays a login, fetches driven by time only, entity pin, policy refusals | Regenerate the corpus after a deliberate change with @@ -559,8 +559,9 @@ cd internal/interop/saml # "-args -keycloak.fixture", against a Keycloak 26.0.7. SAMLSP_KEYCLOAK_URL=http://127.0.0.1:18080 go test -count=1 -run '^TestKeycloakLive$' . -# Shared stores on real servers (MySQL also as a latin1 database under a -# non-strict sql_mode, which used to alter non-ASCII payloads): +# Shared stores on real servers, which CI runs too (make test-backends); +# MySQL also as a latin1 database under a non-strict sql_mode, which used +# to alter non-ASCII payloads. A backend whose variable is unset is skipped: SAMLSP_REDIS_ADDR=127.0.0.1:16379 \ SAMLSP_POSTGRES_DSN="postgres://samlsp:$SAMLSP_DB_PASSWORD@127.0.0.1:15432/samlsp?sslmode=disable" \ SAMLSP_MYSQL_DSN="root:$SAMLSP_DB_PASSWORD@tcp(127.0.0.1:13306)/" \ @@ -604,9 +605,9 @@ fixes: | crewjam/saml IdP | v0.5.1 | CI, in process | pass | | Keycloak (Apache Santuario) | 26.7.4 (`sha256:82a77884…`) | live, K1–K8 | 17/17 subtests pass | | Chrome | 154.0.8037.98 | Tales v0.1.0-rc.23, `make e2e` | 16 scenarios pass | -| PostgreSQL | 16 (`sha256:ca0bd484…`) | conformance suite and races (10 rounds, 16 contenders, race detector) | pass | -| MySQL | 8.0 (`sha256:7dcddc01…`), with and without `clientFoundRows`, and as a latin1 database under `sql_mode=''` | conformance suite, races (10 rounds, 16 contenders, race detector) and a non-ASCII `AppState` round trip | pass | -| Redis | 7.4 (`sha256:4fa24486…`), `noeviction` | conformance suite and races (10 rounds, 16 contenders, race detector) | pass | +| PostgreSQL | 16 (`sha256:ca0bd484…`) | CI (`make test-backends`): conformance suite and races (10 rounds, 16 contenders, race detector) | pass | +| MySQL | 8.0 (`sha256:7dcddc01…`), with and without `clientFoundRows`, and as a latin1 database under `sql_mode=''` | CI (`make test-backends`): conformance suite, races (10 rounds, 16 contenders, race detector) and a non-ASCII `AppState` round trip | pass | +| Redis | 7.4 (`sha256:4fa24486…`), `noeviction` | CI (`make test-backends`): conformance suite and races (10 rounds, 16 contenders, race detector) | pass | What the Keycloak run established: - Keycloak applies the client's NameID format only when the AuthnRequest diff --git a/docs/security-considerations.md b/docs/security-considerations.md index dfb47f4c..20453943 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -702,10 +702,12 @@ MySQL tables use InnoDB, Redis is a standalone server or a Sentinel primary (not Cluster) running 6.0 or later with `maxmemory-policy noeviction`, no family row or key is deleted before it expires, and durability is the backend's: a revocation acknowledged by a primary that fails before -persisting or replicating it can be lost in a failover. Only SQLite is -exercised in CI; the PostgreSQL and MySQL guarantees rest on their locking -semantics, which the opt-in suite of `internal/interop/sqlstores` checks on -PostgreSQL, MySQL and MariaDB servers. +persisting or replicating it can be lost in a failover. CI checks these +guarantees on SQLite and, in its "Store suites (real backends)" job +(`make test-backends`), on PostgreSQL 16, MySQL 8.0 and 8.4, MariaDB 11.4 +(`internal/interop/sqlstores`) and Redis 7.4 (the conformance suite of +`oauth2/store/redis`). On other versions and engines they rest on the +locking semantics above. ### Offline JWT access tokens diff --git a/oauth2/store/sql/doc.go b/oauth2/store/sql/doc.go index 8f23a14e..09eb5a3c 100644 --- a/oauth2/store/sql/doc.go +++ b/oauth2/store/sql/doc.go @@ -44,11 +44,11 @@ // MySQL truncates an over-long value — a subject, an extension's data — // instead of refusing it. // -// The CI of this module exercises SQLite only. The PostgreSQL and MySQL -// guarantees rest on the locking described in [Store]; the opt-in suite of -// internal/interop/sqlstores runs the conformance suite, concurrent -// Migrate and the MySQL upgrade on PostgreSQL, MySQL and MariaDB servers -// (see its compose.yaml). +// The tests of this module run on SQLite. The PostgreSQL and MySQL +// guarantees rest on the locking described in [Store], which the suite of +// internal/interop/sqlstores checks in CI (make test-backends): the +// conformance suite, concurrent Migrate and the MySQL upgrade, on +// PostgreSQL 16, MySQL 8.0 and 8.4 and MariaDB 11.4. // // # Upgrading // diff --git a/oidc/client/store/sql/doc.go b/oidc/client/store/sql/doc.go index c0abb35a..99917d6d 100644 --- a/oidc/client/store/sql/doc.go +++ b/oidc/client/store/sql/doc.go @@ -37,11 +37,11 @@ // - Every instance must reach the primary database: a replica, or a // read/write-splitting proxy, could answer from before a consumption. // -// The CI of this module exercises SQLite only, with several connections -// racing on a database file; the PostgreSQL and MySQL guarantees rest on -// the locking described in [Store], which the opt-in suites of -// internal/interop/oidcstack and internal/interop/sqlstores check on real -// servers. +// The tests of this module run on SQLite, with several connections racing +// on a database file. The PostgreSQL and MySQL guarantees rest on the +// locking described in [Store], which the suites of +// internal/interop/oidcstack and internal/interop/sqlstores check in CI +// (make test-backends) on PostgreSQL, MySQL and MariaDB servers. // // # Cleanup // diff --git a/saml/sp/store/sql/doc.go b/saml/sp/store/sql/doc.go index d7dacd59..ae0a792b 100644 --- a/saml/sp/store/sql/doc.go +++ b/saml/sp/store/sql/doc.go @@ -54,10 +54,10 @@ // or can be dropped and recreated with Migrate: it only holds logins in // progress, which fail once either way. // -// The CI of this module exercises SQLite only; the PostgreSQL and MySQL -// guarantees are argued from the statements above, and checked by the -// opt-in suites of internal/interop/saml and internal/interop/sqlstores on -// real servers. +// The tests of this module run on SQLite. The PostgreSQL and MySQL +// guarantees are argued from the statements above, and checked in CI +// (make test-backends) by the suites of internal/interop/saml and +// internal/interop/sqlstores on PostgreSQL, MySQL and MariaDB servers. // // # Cleanup // From 739aa37b80c5c5e4cb662dd3a888b46e79f88a96 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 21:33:12 +0200 Subject: [PATCH 5/7] docs: describe the real-backend store job in CLAUDE.md CLAUDE.md still called the real-backend store runs opt-in only. It now lists `make test-backends`, describes the "Store suites (real backends)" job of go.yml (PostgreSQL 16, MySQL 8.0/8.4, MariaDB 11.4 and Redis 7.4 service containers, pinned by digest, loopback only, no credentials), and states the rule that keeps that job meaningful: a real-backend test skips while its variable is unset, the job fails on any skip, so a new real-backend test adds its variable to scripts/test-backends.sh and to the job. The interop table and the oidcstack and SAML bullets say that CI runs these suites, opt-in locally. Audit: STORE-DL. --- CLAUDE.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 42f22480..36d590fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,7 @@ make generate # mockery mocks (in-package mock_*_test.go, never committed), se make test # generate, then go test -race -cover in every module, aggregated -> build/coverage.out make lint # golangci-lint (pinned in tools/golangci-lint, shared .golangci.yml) on every module make vuln # govulncheck -test on every module, outside the workspace +make test-backends # the store suites on real PostgreSQL/MySQL/MariaDB/Redis (variables: scripts/test-backends.sh); CI runs it make tools # build the pinned tools (tools/) into build/bin make tidy # go work sync + go mod tidy per module (and tools/*) + go work sync make check-modules # one go line, go.work complete, sync no-op, every module tidy/buildable with GOWORK=off @@ -95,6 +96,12 @@ persisted; keep it `actionlint`- and `zizmor`-clean): - `.github/workflows/go.yml` runs `make tools generate check-modules build test lint` against the whole workspace, then `make coveralls` (goveralls, pinned in `tools/goveralls`; a Coveralls outage does not fail the build); + a second job, `Store suites (real backends)`, runs `make test-backends` + against PostgreSQL 16, MySQL 8.0/8.4, MariaDB 11.4 and Redis 7.4 service + containers (digests of the compose files, loopback only, no credentials). + A real-backend test skips while its variable is unset, and that run fails + on any skip: a new real-backend test follows that rule, and its variable + is added to `scripts/test-backends.sh` and to the job; - `govulncheck.yml` runs `make vuln` (every module, tests included) on every change and weekly; - `release.yml`, on a `v*` or `**/v*` tag: a read-only `verify` job @@ -136,9 +143,9 @@ persisted; keep it `actionlint`- and `zizmor`-clean): | `./internal/integrations` | (private) | Cross-module end-to-end tests | | `./internal/interop/oidcop` | (private, tests only) | `oidc/provider` vs go-oidc + x/oauth2 as independent RP | | `./internal/interop/oidcrp` | (private, tests only) | `oidc/client` against zitadel/oidc + opt-in Chrome test | -| `./internal/interop/oidcstack` | (private, tests only) | our RP ↔ our OP end to end, attacks included; opt-in real-backend store runs | +| `./internal/interop/oidcstack` | (private, tests only) | our RP ↔ our OP end to end, attacks included; real-backend store runs (CI job; opt-in locally) | | `./internal/interop/saml` | (private, tests only) | `saml/sp` against crewjam's IdP, Keycloak (pinned + opt-in live), shared stores, e2e helper | -| `./internal/interop/sqlstores` | (private, tests only) | the three SQL stores on real servers (SQLite always; opt-in PostgreSQL, MySQL, MariaDB): racing `Migrate`, oauth2 conformance, MySQL upgrade statements | +| `./internal/interop/sqlstores` | (private, tests only) | the three SQL stores on real servers (SQLite always; PostgreSQL, MySQL, MariaDB in the CI job, opt-in locally): racing `Migrate`, oauth2 conformance, MySQL upgrade statements | `oauth2/storage/memory` is **not** a standalone module — it is a sub-package of `oauth2`; likewise `oidc/client/storage/memory` and `oidc/client/storetest` @@ -386,9 +393,10 @@ cookie included). `UserInfo` fetches claims whose `sub` must match. - `internal/interop/oidcstack` runs the client against the provider over HTTP — flows, attacks, failures, concurrency — importing no third-party OIDC, OAuth 2.0 or JOSE library (`TestDependencyPolicy`; the client's own - go-jose is part of the code under test); its real-backend store runs are - opt-in (`OIDCCLIENT_POSTGRES_DSN`, `OIDCCLIENT_MYSQL_DSN`, - `OIDCCLIENT_REDIS_ADDR`, `compose.yaml`). `TestDocumentedTestsExist` + go-jose is part of the code under test); its real-backend store runs + (`OIDCCLIENT_POSTGRES_DSN`, `OIDCCLIENT_MYSQL_DSN`, + `OIDCCLIENT_REDIS_ADDR`, `compose.yaml`) are opt-in locally and run in CI + through `make test-backends`. `TestDocumentedTestsExist` fails when `docs/oidc-{conformance,client,provider}.md` cite a test that does not exist, or when a page claims a certification. - Opt-in, never in CI: `make e2e-oidc` (`e2e/oidc/*.tales`, Tales @@ -444,7 +452,8 @@ by response content). `SAMLSP_KEYCLOAK_ADMIN_PASSWORD` (`TestKeycloakLive`; `-args -keycloak.capture` rewrites the 26.7.4 captures, and only `-args -keycloak.fixture` against Keycloak 26.0.7 rewrites the 26.0.7 fixture), - `SAMLSP_REDIS_ADDR`, `SAMLSP_POSTGRES_DSN`, `SAMLSP_MYSQL_DSN`. + `SAMLSP_REDIS_ADDR`, `SAMLSP_POSTGRES_DSN`, `SAMLSP_MYSQL_DSN` — CI runs + the shared-store backends through `make test-backends`. - `e2e/saml/*.tales` (`make e2e`, `e2e/saml/run.sh`) drives `examples/saml-sp` in Chrome; its sites are served by `TestE2EHelperProcess` (skipped unless `SAMLSP_E2E_SERVE` is set). CI From b7d1143523ec8b44471e3c10a45720e180774843 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 22:25:18 +0200 Subject: [PATCH 6/7] docs: complete the real-backend test rule in CLAUDE.md A real-backend test in a package that scripts/test-backends.sh does not list never runs in CI, and the job stays green: the rule now says such a package also gets a `suite` line in the script, as well as its variable. Audit: STORE-DL. --- CLAUDE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 36d590fc..63722291 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,8 +100,10 @@ persisted; keep it `actionlint`- and `zizmor`-clean): against PostgreSQL 16, MySQL 8.0/8.4, MariaDB 11.4 and Redis 7.4 service containers (digests of the compose files, loopback only, no credentials). A real-backend test skips while its variable is unset, and that run fails - on any skip: a new real-backend test follows that rule, and its variable - is added to `scripts/test-backends.sh` and to the job; + on any skip: a new real-backend test follows that rule, its variable is + added to `scripts/test-backends.sh` and to the job, and a package outside + the suites the script lists gets a `suite` line there (otherwise it never + runs in CI and the job stays green); - `govulncheck.yml` runs `make vuln` (every module, tests included) on every change and weekly; - `release.yml`, on a `v*` or `**/v*` tag: a read-only `verify` job From 141e607163b46b8db63fb6c3282d81e6e4a6bcbb Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 22:33:53 +0200 Subject: [PATCH 7/7] ci: complete the real-backend rule and explain -count=1 The header of scripts/test-backends.sh told a new real-backend test to skip while its variable is unset, and to add the variable to the script and to the job. That is not enough for a test in a package the script does not run: it never runs in CI, and the job stays green. Such a package also gets a `suite` line. A new opt-in test of another kind, in a listed package, goes into that suite's skip pattern, or the run fails on its skip. One go test invocation replaces the two that avoided an empty -skip: an empty pattern skips nothing. A comment now says why -count=1 is required. The DSNs are fixed strings, so a cached result would stand in for a run against the servers and pass every guard: with the servers stopped, a run without the flag passed every suite from the cache in 2 s. Audit: STORE-DL. --- scripts/test-backends.sh | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/scripts/test-backends.sh b/scripts/test-backends.sh index 885ab81b..6ed247e5 100755 --- a/scripts/test-backends.sh +++ b/scripts/test-backends.sh @@ -22,8 +22,12 @@ # afterwards. The run fails when a variable is missing, when a test fails, # when a suite runs no test, and when any test is skipped: a real-backend # test skips while its variable is unset, so a skip means a server this -# run does not provide. A new real-backend test follows that rule, and its -# variable is added here and to the job. +# run does not provide. A new real-backend test follows that rule: its +# variable is added here and to the job, and its package gets a `suite` +# line below unless this script already runs it — a skip in a package the +# script does not run is never seen. A new opt-in test of another kind, in +# a package listed below, goes into that suite's skip pattern, or the run +# fails on its skip. # # Locally (Docker), the sqlstores compose file and the Redis of the # oidcstack one provide every server: @@ -72,17 +76,17 @@ failed= events='. as $line | try fromjson catch {Action: "output", Output: ($line + "\n")}' # suite MODULE PACKAGES [SKIP]: runs the tests of PACKAGES in MODULE but -# those SKIP matches, prints their log, and records MODULE as failed when -# a test fails, a test is skipped or no test runs. +# those SKIP matches (an empty pattern skips nothing), prints their log, +# and records MODULE as failed when a test fails, a test is skipped or no +# test runs. suite() { out="$PWD/build/backends-$(printf '%s' "$1" | tr / -).json" echo "==> $1" status=0 - if [ -n "${3:-}" ]; then - (cd "$1" && go test -race -count=1 -timeout 300s -json -skip "$3" "$2" >"$out") || status=$? - else - (cd "$1" && go test -race -count=1 -timeout 300s -json "$2" >"$out") || status=$? - fi + # -count=1: never replay a cached result. The DSNs are fixed strings, so + # the test cache would stand in for a run against the servers, and pass + # every guard below without reaching any of them. + (cd "$1" && go test -race -count=1 -timeout 300s -json -skip "${3:-}" "$2" >"$out") || status=$? jq -Rj "$events"' | select(.Action == "output" or .Action == "build-output") | .Output' "$out" skipped=$(jq -Rr "$events"' | select(.Action == "skip" and .Test != null) | .Test' "$out") passed=$(jq -Rn "[inputs | $events"' | select(.Action == "pass" and .Test != null)] | length' "$out")