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/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/CLAUDE.md b/CLAUDE.md index 42f22480..63722291 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,14 @@ 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, 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 @@ -136,9 +145,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 +395,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 +454,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 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/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/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/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/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{ 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/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}) 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 // diff --git a/scripts/test-backends.sh b/scripts/test-backends.sh new file mode 100755 index 00000000..6ed247e5 --- /dev/null +++ b/scripts/test-backends.sh @@ -0,0 +1,118 @@ +#!/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: 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: +# +# 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 (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 + # -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") + 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"