Przeglądaj źródła

Migrated unit tests from per-test containers to shared test services

Efren Yevale Varela 3 tygodni temu
rodzic
commit
0d522a39e8
63 zmienionych plików z 1287 dodań i 1311 usunięć
  1. 14 6
      AGENTS.md
  2. 24 7
      README.md
  3. 19 0
      compose.test.yaml
  4. 29 14
      docs/CONTRIBUTING.md
  5. 38 69
      src/cli/test_support.rs
  6. 21 25
      src/cli/tests.rs
  7. 26 67
      src/db/mod.rs
  8. 16 42
      src/db/user_repo/lockout.rs
  9. 9 9
      src/jose/dpop/reject.rs
  10. 6 6
      src/jose/dpop/reject_key.rs
  11. 14 50
      src/jose/dpop/support.rs
  12. 5 5
      src/jose/dpop/tests.rs
  13. 2 0
      src/lib.rs
  14. 17 26
      src/middleware/ratelimit/test_support.rs
  15. 53 29
      src/middleware/ratelimit/tests.rs
  16. 20 39
      src/protocol/client_auth/tests_containers.rs
  17. 4 4
      src/protocol/client_auth/tests_containers/assertions.rs
  18. 8 8
      src/protocol/client_auth/tests_containers/secrets.rs
  19. 66 45
      src/protocol/code/tests.rs
  20. 48 42
      src/protocol/dcr/manage/support.rs
  21. 7 7
      src/protocol/dcr/manage/tests.rs
  22. 32 8
      src/protocol/dcr/manage/tests_delete.rs
  23. 9 9
      src/protocol/dcr/manage/tests_update.rs
  24. 36 57
      src/protocol/dcr/tests.rs
  25. 32 51
      src/protocol/device/tests.rs
  26. 47 63
      src/protocol/introspect/tests.rs
  27. 11 26
      src/protocol/par/tests.rs
  28. 5 5
      src/protocol/par/tests/consume.rs
  29. 3 3
      src/protocol/par/tests/rules.rs
  30. 17 42
      src/protocol/purge.rs
  31. 25 43
      src/protocol/revoke/tests.rs
  32. 7 7
      src/protocol/revoke/tests/cascade.rs
  33. 23 11
      src/protocol/revoke/tests/mtls.rs
  34. 26 51
      src/protocol/token/grants/authorization_code.rs
  35. 11 44
      src/protocol/token/grants/client_credentials/tests.rs
  36. 7 7
      src/protocol/token/grants/client_credentials/tests/binding.rs
  37. 5 5
      src/protocol/token/grants/client_credentials/tests/issue.rs
  38. 24 42
      src/protocol/token/grants/device_code/tests.rs
  39. 4 4
      src/protocol/token/grants/device_code/tests/backoff.rs
  40. 1 1
      src/protocol/token/grants/device_code/tests/expired.rs
  41. 4 4
      src/protocol/token/grants/device_code/tests/matrix.rs
  42. 2 2
      src/protocol/token/grants/device_code/tests/poll.rs
  43. 1 1
      src/protocol/token/grants/device_code/tests/race.rs
  44. 2 2
      src/protocol/token/grants/device_code/tests/scope.rs
  45. 11 44
      src/protocol/token/grants/token_exchange/tests.rs
  46. 1 1
      src/protocol/token/grants/token_exchange/tests/exchange_ok.rs
  47. 3 3
      src/protocol/token/grants/token_exchange/tests/policy.rs
  48. 3 3
      src/protocol/token/grants/token_exchange/tests/proof.rs
  49. 14 46
      src/protocol/token/tests.rs
  50. 1 1
      src/protocol/token/tests/matrix.rs
  51. 3 3
      src/protocol/token/tests/mint_find.rs
  52. 1 1
      src/protocol/token/tests/peppers.rs
  53. 1 1
      src/protocol/token/tests/rotate.rs
  54. 3 0
      src/state/test_support.rs
  55. 6 18
      src/state/tests_guard.rs
  56. 12 34
      src/store/pending.rs
  57. 178 82
      src/store/tests_store.rs
  58. 7 24
      src/store/tokens.rs
  59. 9 1
      src/test_guard.rs
  60. 222 0
      src/test_support.rs
  61. 4 28
      src/ui/consent/tests.rs
  62. 14 30
      src/ui/login/tests.rs
  63. 14 3
      src/ui/login/tests/enroll.rs

+ 14 - 6
AGENTS.md

@@ -85,12 +85,20 @@ Integration tests use the real shared Postgres and Redis from
 `compose.test.yaml` (`docker compose -f compose.test.yaml up -d`: Postgres on
 `compose.test.yaml` (`docker compose -f compose.test.yaml up -d`: Postgres on
 `127.0.0.1:5433`, Redis on `127.0.0.1:6380`; override with
 `127.0.0.1:5433`, Redis on `127.0.0.1:6380`; override with
 `D_OAUTH_TEST_POSTGRES_URL` and `D_OAUTH_TEST_REDIS_URL`). They get per-test
 `D_OAUTH_TEST_POSTGRES_URL` and `D_OAUTH_TEST_REDIS_URL`). They get per-test
-isolation from a fresh database and unique Redis key prefixes. Testcontainers
-remains only for unit-test helpers under `src/` and for `tests/resilience.rs`,
-which stops and restarts containers; `tests/topology.rs` uses in-process
-`redis-test` fixtures that need a `redis-server` binary. Prefer a real object or
-an in-memory fake over a mock. Assert the observable contract. Tear the services
-down with `docker compose -f compose.test.yaml down -v`.
+isolation from a fresh database and unique Redis key prefixes. `testcontainers`
+remains only for the suites that need Redis nodes of their own:
+`src/test_guard.rs` and `src/state/test_support.rs` (the shared container
+helpers), `tests/resilience.rs` (stop/start lifecycle),
+`src/state/tests_topology.rs` (Sentinel/Cluster/`requirepass`), the
+`--appendfsync always` refresh-token suite
+(`src/protocol/token/grants/refresh_token/tests.rs` and its `tests/failover.rs`),
+the two Redis durability tests in `src/store/tests_store.rs`, and
+`dcr_manage_token_survives_redis_flush` in
+`src/protocol/dcr/manage/tests_delete.rs`. Every other unit and integration
+suite uses the shared services. `tests/topology.rs` uses in-process `redis-test`
+fixtures that need a `redis-server` binary. Prefer a real object or an in-memory
+fake over a mock. Assert the observable contract. Tear the services down with
+`docker compose -f compose.test.yaml down -v`.
 
 
 Quality gates for any change:
 Quality gates for any change:
 
 

+ 24 - 7
README.md

@@ -32,8 +32,9 @@ state is in Redis, so replicas are interchangeable.
 - Redis 8 or newer, with persistence enabled and eviction off.
 - Redis 8 or newer, with persistence enabled and eviction off.
 - A TLS certificate and key for the public listener.
 - A TLS certificate and key for the public listener.
 - Docker, for the container image, for the shared test Postgres and Redis in
 - Docker, for the container image, for the shared test Postgres and Redis in
-  `compose.test.yaml`, and for the tests that still use testcontainers (the
-  unit-test helpers under `src/` and the lifecycle suite).
+  `compose.test.yaml`, and for the handful of suites that still use
+  `testcontainers` because they need Redis nodes of their own (the keep-list is
+  under [Tests](#tests)).
 - `cargo-nextest` for the test suite:
 - `cargo-nextest` for the test suite:
   `cargo install cargo-nextest --locked`.
   `cargo install cargo-nextest --locked`.
 
 
@@ -166,11 +167,27 @@ Override the endpoints with `D_OAUTH_TEST_POSTGRES_URL` and
 `D_OAUTH_TEST_REDIS_URL`. Each test gets its own freshly created database and
 `D_OAUTH_TEST_REDIS_URL`. Each test gets its own freshly created database and
 unique Redis key prefixes, so tests run in parallel and nothing flushes shared
 unique Redis key prefixes, so tests run in parallel and nothing flushes shared
 state. Killing the suite mid-run no longer orphans containers: the lifecycle
 state. Killing the suite mid-run no longer orphans containers: the lifecycle
-tests still `rm` theirs on drop. Testcontainers remains only for the unit-test
-helpers under `src/` and for `tests/resilience.rs`, which stops and restarts
-containers; `tests/topology.rs` is the other lifecycle suite and spawns
-in-process `redis-test` Sentinel/Cluster fixtures that need a `redis-server`
-binary. Docker must be running. To iterate on one area, filter by name:
+tests still `rm` theirs on drop.
+
+Only these suites use `testcontainers`; every other unit and integration suite
+uses the shared `compose.test.yaml` services:
+
+- `src/test_guard.rs` and `src/state/test_support.rs`: the shared container
+  helpers the keep-list below is built on.
+- `tests/resilience.rs`: stop and restart lifecycle.
+- `src/state/tests_topology.rs`: Sentinel, Cluster, and `requirepass` topology.
+- The `--appendfsync always` refresh-token suite:
+  `src/protocol/token/grants/refresh_token/tests.rs` and
+  `src/protocol/token/grants/refresh_token/tests/failover.rs`.
+- The two Redis durability tests in `src/store/tests_store.rs`
+  (`store_persistence_survives_restart` and `store_durability_barrier_modes`).
+- `dcr_manage_token_survives_redis_flush` in
+  `src/protocol/dcr/manage/tests_delete.rs`, which starts a private Redis for
+  the flush.
+
+`tests/topology.rs` is separate: it spawns in-process `redis-test`
+Sentinel/Cluster fixtures that need a real `redis-server` binary. Docker must
+be running. To iterate on one area, filter by name:
 
 
 ```bash
 ```bash
 cargo nextest run -E 'test(refresh_rotation)'
 cargo nextest run -E 'test(refresh_rotation)'

+ 19 - 0
compose.test.yaml

@@ -15,12 +15,31 @@
 #
 #
 # Ports 5433/6380 avoid clashing with a developer's local 5432/6379.
 # Ports 5433/6380 avoid clashing with a developer's local 5432/6379.
 # This file is test-only; the local proof deployment stays in `compose.yaml`.
 # This file is test-only; the local proof deployment stays in `compose.yaml`.
+#
+# `testcontainers` remains only for suites that need Redis nodes of their own:
+# the stop/start lifecycle, Sentinel/Cluster topology, the `--appendfsync always`
+# durability suites, and the private-container Redis flush test. Every other
+# unit and integration suite uses these shared services.
+#
+# `test-db` runs with `max_connections=500`: the shared server now backs many
+# parallel per-suite databases (one nextest process per CPU, each test holding
+# a max-4 pool), so the default 100 is too low. 500 is the floor for high-core
+# CI; hosts above ~125 logical CPUs need `max(500, 4*nproc + margin)`.
+#
+# The same concentration raised aggregate Postgres parallel-worker DSM
+# pressure: the Docker `/dev/shm` default is 64 MB and hit 95% full,
+# producing `could not resize shared memory segment` (SQLSTATE 53100) flakes.
+# So `shm_size` is pinned to 1 GB and parallel gather is disabled
+# (`max_parallel_workers_per_gather=0`); a test server does not need parallel
+# query, and this removes the DSM/`/dev/shm` allocation entirely.
 
 
 name: d-oauth-test
 name: d-oauth-test
 
 
 services:
 services:
   test-db:
   test-db:
     image: postgres:18-alpine
     image: postgres:18-alpine
+    command: ["postgres", "-c", "max_connections=500", "-c", "max_parallel_workers_per_gather=0"]
+    shm_size: 1gb
     environment:
     environment:
       POSTGRES_PASSWORD: oauth-test-only
       POSTGRES_PASSWORD: oauth-test-only
     ports:
     ports:

+ 29 - 14
docs/CONTRIBUTING.md

@@ -18,7 +18,8 @@ with the [README](../README.md) and [OPERATIONS.md](OPERATIONS.md).
   `cargo install cargo-nextest --locked`.
   `cargo install cargo-nextest --locked`.
 - Docker, to run the shared test Postgres and Redis from
 - Docker, to run the shared test Postgres and Redis from
   `compose.test.yaml` (`docker compose -f compose.test.yaml up -d`), and for
   `compose.test.yaml` (`docker compose -f compose.test.yaml up -d`), and for
-  the lifecycle tests that still use `testcontainers`.
+  the handful of suites that still use `testcontainers` (see
+  [Database-backed tests](#database-backed-tests)).
 - `sqlx-cli` only when you add or change a SQL query or a migration:
 - `sqlx-cli` only when you add or change a SQL query or a migration:
   `cargo install sqlx-cli --locked --features runtime-tokio,tls-rustls-ring`.
   `cargo install sqlx-cli --locked --features runtime-tokio,tls-rustls-ring`.
 
 
@@ -64,9 +65,10 @@ Start the shared test services before `cargo nextest run --all-targets`:
 `docker compose -f compose.test.yaml up -d`, then tear them down with
 `docker compose -f compose.test.yaml up -d`, then tear them down with
 `docker compose -f compose.test.yaml down -v`. `cargo nextest run` is the
 `docker compose -f compose.test.yaml down -v`. `cargo nextest run` is the
 authoritative test entry point. It runs unit, integration, and end-to-end tests;
 authoritative test entry point. It runs unit, integration, and end-to-end tests;
-the integration tests use the shared services, and the lifecycle suites keep
-`testcontainers`. `cargo test --doc` runs the doc examples separately because
-nextest does not cover doctests.
+they all use the shared services except the keep-list under
+[Database-backed tests](#database-backed-tests), which needs `testcontainers`.
+`cargo test --doc` runs the doc examples separately because nextest does not
+cover doctests.
 
 
 ### Narrow scope while developing
 ### Narrow scope while developing
 
 
@@ -99,13 +101,24 @@ Migrations are never run by the test process against the shared `postgres`
 database; a test that asserts on a migration applies it to its own fresh
 database; a test that asserts on a migration applies it to its own fresh
 database.
 database.
 
 
-The lifecycle tests are the exception. `tests/resilience.rs` stops and restarts
-containers through `testcontainers`; the guards synchronously `rm` them on drop,
-so killing the suite mid-run no longer orphans containers. `tests/topology.rs`
-spawns in-process `redis-test` Sentinel/Cluster fixtures, which need a real
-`redis-server` binary. Unit-test helpers under `src/` also still use
-`testcontainers`. They need a working Docker daemon and, for the in-process
-topology fixtures, the `redis-server` binary.
+The suites that need their own Redis nodes are the exception. Only these use
+`testcontainers`:
+
+- `src/test_guard.rs` and `src/state/test_support.rs`: the shared container
+  helpers the rest of this list is built on.
+- `tests/resilience.rs`: stops and restarts containers; the guards synchronously
+  `rm` them on drop, so killing the suite mid-run no longer orphans containers.
+- `src/state/tests_topology.rs`: Sentinel, Cluster, and `requirepass` topology.
+- The `--appendfsync always` refresh-token suite:
+  `src/protocol/token/grants/refresh_token/tests.rs` and its `tests/failover.rs`.
+- The two Redis durability tests in `src/store/tests_store.rs`.
+- `dcr_manage_token_survives_redis_flush` in
+  `src/protocol/dcr/manage/tests_delete.rs`.
+
+Every other unit and integration suite uses the shared services.
+`tests/topology.rs` is separate: it spawns in-process `redis-test`
+Sentinel/Cluster fixtures, which need a real `redis-server` binary. Docker must
+be running, and the in-process topology fixtures additionally need that binary.
 
 
 ### Fuzzing
 ### Fuzzing
 
 
@@ -212,9 +225,11 @@ Every behavior change follows red, green, refactor:
 Write the test against the shared Postgres and Redis from
 Write the test against the shared Postgres and Redis from
 `compose.test.yaml` (defaults `127.0.0.1:5433` and `127.0.0.1:6380`, override
 `compose.test.yaml` (defaults `127.0.0.1:5433` and `127.0.0.1:6380`, override
 with `D_OAUTH_TEST_POSTGRES_URL` / `D_OAUTH_TEST_REDIS_URL`), with a fresh
 with `D_OAUTH_TEST_POSTGRES_URL` / `D_OAUTH_TEST_REDIS_URL`), with a fresh
-database and unique Redis prefixes per test. Keep `testcontainers` for the
-stop/start lifecycle cases only. Prefer a real object or an in-memory fake over
-a mock. Assert the observable contract, not an implementation dump.
+database and unique Redis prefixes per test. Reach for `testcontainers` only
+when the test needs Redis nodes of its own (the keep-list under
+[Database-backed tests](#database-backed-tests)); stop/start lifecycle cases are
+the common reason. Prefer a real object or an in-memory fake over a mock. Assert
+the observable contract, not an implementation dump.
 
 
 ## Commit style
 ## Commit style
 
 

+ 38 - 69
src/cli/test_support.rs

@@ -1,33 +1,26 @@
-//! Shared testcontainers harness for the `test(cli)` suite.
+//! Compose-services harness for the `test(cli)` suite.
 //!
 //!
-//! One Postgres (migrated once) and one Redis (AOF on, satisfying the
-//! durability contract) are shared across the suite; tests use unique emails
-//! and scope names so parallel execution stays isolated.
+//! Tests connect to the shared Postgres and Redis from `compose.test.yaml`
+//! through the crate-wide helpers. Each harness build creates a fresh database
+//! on the shared server (migrated once) and mints a Redis prefix pair that is
+//! per-process under `cargo test` (shared `static HARNESS`) and per-test under
+//! `cargo nextest`; handler-side `Store::from_config` resolves the same
+//! keyspace because the prefixes live in the config.
 
 
 use std::sync::{Arc, Mutex};
 use std::sync::{Arc, Mutex};
 
 
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use tracing_subscriber::layer::SubscriberExt as _;
 use tracing_subscriber::layer::SubscriberExt as _;
 
 
-use super::context::{self, CliError, Context};
+use super::context::{CliError, Context};
 use crate::{
 use crate::{
   config::Config,
   config::Config,
   state::MIGRATOR,
   state::MIGRATOR,
-  test_guard::{ContainerGuard, Lease, Shared},
+  test_guard::{Lease, Shared},
 };
 };
 
 
-const PG_PASSWORD: &str = "cli-test-secret";
-
 /// Errors raised by the CLI test scaffolding.
 /// Errors raised by the CLI test scaffolding.
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 pub enum TestError {
 pub enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// Crate configuration failed to load.
   /// Crate configuration failed to load.
   #[error("config error: {0}")]
   #[error("config error: {0}")]
   Config(#[from] crate::config::ConfigError),
   Config(#[from] crate::config::ConfigError),
@@ -72,34 +65,32 @@ pub enum TestError {
   Harness(String),
   Harness(String),
 }
 }
 
 
-/// Shared Postgres + Redis containers plus the validated config.
+/// Validated configuration pointing at the shared services.
 ///
 ///
-/// The containers are [`ContainerGuard`]s, so the last test releasing the
-/// shared slot removes them synchronously instead of orphaning them.
+/// Under `cargo test` the shared `static HARNESS` means one harness build per
+/// process owns a fresh database plus a per-process Redis prefix pair; under
+/// `cargo nextest` each test runs in its own process, so the pair is per-test.
 pub struct Harness {
 pub struct Harness {
-  /// Validated configuration pointing at the containers.
+  /// Validated configuration pointing at the fresh database and shared Redis.
   pub config: Arc<Config>,
   pub config: Arc<Config>,
-  _postgres: ContainerGuard,
-  _redis: ContainerGuard,
 }
 }
 
 
 static HARNESS: Shared<Harness> = Shared::new();
 static HARNESS: Shared<Harness> = Shared::new();
 
 
-/// Start (once) and return a lease on the shared harness.
+/// Start (once per process) and return a lease on the harness.
 ///
 ///
 /// The lease dereferences to the harness; hold it for the duration of the
 /// The lease dereferences to the harness; hold it for the duration of the
-/// test so the shared containers stay alive, and dropping the last lease
-/// removes them deterministically.
+/// test alongside the context.
 ///
 ///
 /// # Errors
 /// # Errors
-/// Returns [`TestError`] when a container, config load, or migration fails.
+/// Returns [`TestError`] when the database, config load, or migration fails.
 pub async fn harness() -> Result<Lease<Harness>, TestError> {
 pub async fn harness() -> Result<Lease<Harness>, TestError> {
   HARNESS.lease(build()).await
   HARNESS.lease(build()).await
 }
 }
 
 
 /// Build an operator context over the shared harness.
 /// Build an operator context over the shared harness.
 ///
 ///
-/// The lease keeps the shared containers alive; bind it (usually as
+/// The lease keeps the harness alive; bind it (usually as
 /// `_harness`) for the duration of the test alongside the context.
 /// `_harness`) for the duration of the test alongside the context.
 ///
 ///
 /// # Errors
 /// # Errors
@@ -158,56 +149,34 @@ pub fn captured(buffer: &Arc<Mutex<Vec<u8>>>) -> String {
 }
 }
 
 
 async fn build() -> Result<Harness, TestError> {
 async fn build() -> Result<Harness, TestError> {
-  let (postgres, pg_url) = postgres_container().await?;
-  let (redis, redis_url) = redis_container().await?;
-  let config = Arc::new(test_config(&pg_url, &redis_url)?);
-  let pool = context::open_pool(&config).await?;
+  let (pg_url, pool) = crate::test_support::fresh_database_pool()
+    .await
+    .map_err(|error| TestError::Harness(format!("shared postgres: {error}")))?;
   MIGRATOR.run(&pool).await?;
   MIGRATOR.run(&pool).await?;
-  drop(pool);
-  Ok(Harness {
-    config,
-    _postgres: postgres,
-    _redis: redis,
-  })
-}
-
-pub(crate) async fn postgres_container() -> Result<(ContainerGuard, String), TestError> {
-  let container = GenericImage::new("postgres", "18-alpine")
-    .with_exposed_port(5432.tcp())
-    .with_wait_for(WaitFor::message_on_stderr(
-      "database system is ready to accept connections",
-    ))
-    .with_wait_for(WaitFor::message_on_stdout(
-      "database system is ready to accept connections",
-    ))
-    .with_env_var("POSTGRES_PASSWORD", PG_PASSWORD)
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(5432).await?;
-  let url = format!("postgres://postgres:{PG_PASSWORD}@127.0.0.1:{port}/postgres?sslmode=disable");
-  Ok((guarded, url))
-}
-
-async fn redis_container() -> Result<(ContainerGuard, String), TestError> {
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .with_cmd(vec!["redis-server", "--appendonly", "yes"])
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  Ok((guarded, format!("redis://127.0.0.1:{port}")))
+  pool.close().await;
+  let redis_url = crate::test_support::test_redis_url();
+  let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let config = Arc::new(test_config(&pg_url, &redis_url, &state_prefix, &rl_prefix)?);
+  Ok(Harness { config })
 }
 }
 
 
-fn test_config(pg_url: &str, redis_url: &str) -> Result<Config, TestError> {
+fn test_config(
+  pg_url: &str,
+  redis_url: &str,
+  state_prefix: &str,
+  rl_prefix: &str,
+) -> Result<Config, TestError> {
   let fixture = serde_json::json!({
   let fixture = serde_json::json!({
     "issuer": "https://auth.example.test",
     "issuer": "https://auth.example.test",
     "environment": "development",
     "environment": "development",
     "tls": {"cert_path": "/tmp/cli-test-cert.pem", "key_path": "/tmp/cli-test-key.pem"},
     "tls": {"cert_path": "/tmp/cli-test-cert.pem", "key_path": "/tmp/cli-test-key.pem"},
     "database": {"url": pg_url},
     "database": {"url": pg_url},
-    "redis": {"url": redis_url, "durability": "waitaof"},
+    "redis": {
+      "url": redis_url,
+      "durability": "waitaof",
+      "token_key_prefix": state_prefix,
+      "ratelimit_key_prefix": rl_prefix,
+    },
     "token_pepper": "cli-test-pepper-0123456789abcdef",
     "token_pepper": "cli-test-pepper-0123456789abcdef",
     "totp_encryption_key": "cli-test-totp-key-0123456789abcdef",
     "totp_encryption_key": "cli-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "cli-test-pii-key-0123456789abcdef",
     "pii_encryption_key": "cli-test-pii-key-0123456789abcdef",

+ 21 - 25
src/cli/tests.rs

@@ -12,11 +12,6 @@ use ring::{
 };
 };
 use secrecy::{ExposeSecret as _, SecretString};
 use secrecy::{ExposeSecret as _, SecretString};
 use sqlx::Row as _;
 use sqlx::Row as _;
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 
 
 use super::{
 use super::{
@@ -58,12 +53,18 @@ async fn bootstrap_setup(
   let (db, pool) = crate::db::test_support::migrated_pool()
   let (db, pool) = crate::db::test_support::migrated_pool()
     .await
     .await
     .map_err(|error| TestError::Harness(error.to_string()))?;
     .map_err(|error| TestError::Harness(error.to_string()))?;
+  let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
   let fixture = serde_json::json!({
   let fixture = serde_json::json!({
     "issuer": "https://auth.example.test",
     "issuer": "https://auth.example.test",
     "environment": "development",
     "environment": "development",
     "tls": {"cert_path": "/tmp/bootstrap-test-cert.pem", "key_path": "/tmp/bootstrap-test-key.pem"},
     "tls": {"cert_path": "/tmp/bootstrap-test-cert.pem", "key_path": "/tmp/bootstrap-test-key.pem"},
     "database": {"url": "postgres://127.0.0.1:1/bootstrap"},
     "database": {"url": "postgres://127.0.0.1:1/bootstrap"},
-    "redis": {"url": redis_url, "durability": "waitaof"},
+    "redis": {
+      "url": redis_url,
+      "durability": "waitaof",
+      "token_key_prefix": state_prefix,
+      "ratelimit_key_prefix": rl_prefix,
+    },
     "token_pepper": "bootstrap-test-pepper-0123456789abcdef",
     "token_pepper": "bootstrap-test-pepper-0123456789abcdef",
     "totp_encryption_key": "bootstrap-test-totp-key-0123456789abcdef",
     "totp_encryption_key": "bootstrap-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "bootstrap-test-pii-key-0123456789abcdef",
     "pii_encryption_key": "bootstrap-test-pii-key-0123456789abcdef",
@@ -1087,20 +1088,10 @@ async fn admin_bootstrap_generated_password_is_256_bit_and_unlogged() -> Result<
 
 
 #[tokio::test]
 #[tokio::test]
 async fn admin_bootstrap_first_login_restricted_until_rotation() -> Result<(), TestError> {
 async fn admin_bootstrap_first_login_restricted_until_rotation() -> Result<(), TestError> {
-  let (_db, pool, config) = bootstrap_setup("redis://127.0.0.1:9").await?;
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .with_cmd(vec!["redis-server", "--appendonly", "yes"])
-    .start()
-    .await?;
-  let guarded = crate::test_guard::ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  let client = redis::Client::open(format!("redis://127.0.0.1:{port}").as_str())?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(std::time::Duration::from_secs(10)))
-    .set_connection_timeout(Some(std::time::Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+  let (_db, pool, config) = bootstrap_setup(&crate::test_support::test_redis_url()).await?;
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Harness(format!("shared redis: {error}")))?;
   let mut conn = RedisConn::Single(manager);
   let mut conn = RedisConn::Single(manager);
   let store = crate::store::Store::from_config(&config.redis);
   let store = crate::store::Store::from_config(&config.redis);
 
 
@@ -1352,7 +1343,11 @@ async fn user_unlock_unknown_user_is_not_found() -> Result<(), TestError> {
 /// without an audit event and the account row stays exactly as it was.
 /// without an audit event and the account row stays exactly as it was.
 #[tokio::test]
 #[tokio::test]
 async fn user_unlock_redis_outage_fails_closed() -> Result<(), TestError> {
 async fn user_unlock_redis_outage_fails_closed() -> Result<(), TestError> {
-  let (_postgres, pg_url) = test_support::postgres_container().await?;
+  let (pg_url, setup) = crate::test_support::fresh_database_pool()
+    .await
+    .map_err(|error| TestError::Harness(format!("shared postgres: {error}")))?;
+  crate::state::MIGRATOR.run(&setup).await?;
+  setup.close().await;
   let fixture = serde_json::json!({
   let fixture = serde_json::json!({
     "issuer": "https://auth.example.test",
     "issuer": "https://auth.example.test",
     "environment": "development",
     "environment": "development",
@@ -1365,9 +1360,6 @@ async fn user_unlock_redis_outage_fails_closed() -> Result<(), TestError> {
   });
   });
   let source = ::config::Config::try_from(&fixture)?;
   let source = ::config::Config::try_from(&fixture)?;
   let config = Arc::new(Config::from_source(source)?);
   let config = Arc::new(Config::from_source(source)?);
-  let pool = super::context::open_pool(&config).await?;
-  crate::state::MIGRATOR.run(&pool).await?;
-  pool.close().await;
   let context = super::context::Context::from_config(config).await?;
   let context = super::context::Context::from_config(config).await?;
   let email = test_support::unique_email("unlock-outage");
   let email = test_support::unique_email("unlock-outage");
   user::create(&context, &email, false, PASSWORD).await?;
   user::create(&context, &email, false, PASSWORD).await?;
@@ -1437,7 +1429,11 @@ fn admin_bootstrap_runbook_matches_cli() -> Result<(), TestError> {
 /// plus `password_file` connects (the cluster migrate-Job contract).
 /// plus `password_file` connects (the cluster migrate-Job contract).
 #[tokio::test]
 #[tokio::test]
 async fn open_pool_uses_mounted_database_password() -> Result<(), TestError> {
 async fn open_pool_uses_mounted_database_password() -> Result<(), TestError> {
-  let (_postgres, pg_url) = test_support::postgres_container().await?;
+  let (pg_url, setup) = crate::test_support::fresh_database_pool()
+    .await
+    .map_err(|error| TestError::Harness(format!("shared postgres: {error}")))?;
+  crate::state::MIGRATOR.run(&setup).await?;
+  setup.close().await;
   let user_prefix = "postgres://postgres";
   let user_prefix = "postgres://postgres";
   let user_end = user_prefix.len();
   let user_end = user_prefix.len();
   assert!(pg_url.starts_with(&format!("{user_prefix}:")));
   assert!(pg_url.starts_with(&format!("{user_prefix}:")));

+ 26 - 67
src/db/mod.rs

@@ -15,90 +15,49 @@ pub use redirect_repo::{NewRedirectUri, RedirectKind, RedirectUri, UpsertOutcome
 
 
 /// Test-only scaffolding shared by the repository suites.
 /// Test-only scaffolding shared by the repository suites.
 ///
 ///
-/// One `postgres:18-alpine` container is started lazily and reused by every test
-/// in the lib test binary; each test gets a freshly created database with all
-/// embedded migrations applied, so parallel tests stay isolated at one
-/// container cost. Integration tests under `tests/` cannot reach this
-/// `#[cfg(test)]` module and carry their own container helper instead.
+/// Compose-first: the pool is built on the shared external Postgres started by
+/// `docker compose -f compose.test.yaml up -d`, using a fresh database per call
+/// from [`crate::test_support::fresh_database_pool`]. No container is started
+/// here. The [`Shared`]/[`Lease`] pair is retained only so the many callers
+/// that bind `_db: Lease<Db>` compile unchanged.
 #[cfg(test)]
 #[cfg(test)]
 pub(crate) mod test_support {
 pub(crate) mod test_support {
-  use sqlx::{Executor as _, PgPool, postgres::PgPoolOptions};
-  use testcontainers::{
-    GenericImage, ImageExt,
-    core::{IntoContainerPort, WaitFor},
-    runners::AsyncRunner,
-  };
+  use sqlx::PgPool;
 
 
-  use crate::test_guard::{ContainerGuard, Lease, Shared};
+  use crate::test_guard::{Lease, Shared};
 
 
   /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
   /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
   pub(crate) type TestError = Box<dyn std::error::Error + Send + Sync>;
   pub(crate) type TestError = Box<dyn std::error::Error + Send + Sync>;
 
 
-  /// Test Postgres superuser password.
-  const PG_PASSWORD: &str = "db-repo-secret";
-
-  /// The shared Postgres server plus the container keeping it alive.
+  /// URL-only holder of the shared Postgres lease.
   ///
   ///
-  /// The container is a [`ContainerGuard`], so the last test releasing the
-  /// shared slot removes it synchronously instead of orphaning it.
+  /// It owns no external resource; the lease merely satisfies the caller
+  /// signatures.
   pub(crate) struct Db {
   pub(crate) struct Db {
-    _container: ContainerGuard,
+    /// Retained only for the `Lease<Db>` type the callers bind; the URL is
+    /// intentionally never dereferenced.
+    #[allow(dead_code)]
     url: String,
     url: String,
   }
   }
 
 
   static DB: Shared<Db> = Shared::new();
   static DB: Shared<Db> = Shared::new();
 
 
-  /// Start (once) and return a lease on the shared Postgres server.
+  /// A fresh database on the shared compose server with every embedded
+  /// migration applied, pooled for the calling test.
   ///
   ///
-  /// The lease must stay alive as long as the returned reference is used;
-  /// dropping the last lease removes the shared container deterministically.
-  async fn shared_db() -> Result<Lease<Db>, TestError> {
-    DB.lease::<TestError>(async {
-      let container = GenericImage::new("postgres", "18-alpine")
-        .with_exposed_port(5432.tcp())
-        .with_wait_for(WaitFor::message_on_stderr(
-          "database system is ready to accept connections",
-        ))
-        .with_wait_for(WaitFor::message_on_stdout(
-          "database system is ready to accept connections",
-        ))
-        .with_env_var("POSTGRES_PASSWORD", PG_PASSWORD)
-        .start()
-        .await?;
-      let guarded = ContainerGuard::new(container);
-      let port = guarded.get_host_port_ipv4(5432).await?;
-      let url =
-        format!("postgres://postgres:{PG_PASSWORD}@127.0.0.1:{port}/postgres?sslmode=disable");
-      Ok(Db {
-        _container: guarded,
-        url,
-      })
-    })
-    .await
-  }
-
-  /// A fresh database on the shared server with every embedded migration
-  /// applied, pooled for the calling test.
-  ///
-  /// The returned lease keeps the shared container alive; bind it (usually
-  /// as `_db`) for the duration of the test.
+  /// Acquires the (resource-free) shared [`Db`] lease, creates a uniquely named
+  /// database via [`crate::test_support::fresh_database_pool`], applies the
+  /// migrations here, and returns the lease plus the pool. Bind the lease
+  /// (usually as `_db`) for the duration of the test.
   pub(crate) async fn migrated_pool() -> Result<(Lease<Db>, PgPool), TestError> {
   pub(crate) async fn migrated_pool() -> Result<(Lease<Db>, PgPool), TestError> {
-    let db = shared_db().await?;
-    let admin = PgPoolOptions::new()
-      .max_connections(1)
-      .connect(&db.url)
-      .await?;
-    let name = format!("repo_{}", uuid::Uuid::new_v4().simple());
-    // `CREATE DATABASE` cannot take a bind parameter and `&Pool`'s `Executor`
-    // requires a `'static` query; leak this one tiny test-only string.
-    let ddl: &'static str = Box::leak(format!("CREATE DATABASE \"{name}\"").into_boxed_str());
-    admin.execute(ddl).await?;
-    admin.close().await;
-    let target = db.url.replacen("/postgres?", &format!("/{name}?"), 1);
-    let pool = PgPoolOptions::new()
-      .max_connections(4)
-      .connect(&target)
+    let db = DB
+      .lease::<TestError>(async {
+        Ok(Db {
+          url: crate::test_support::test_postgres_admin_url(),
+        })
+      })
       .await?;
       .await?;
+    let (_fresh_url, pool) = crate::test_support::fresh_database_pool().await?;
     crate::state::MIGRATOR.run(&pool).await?;
     crate::state::MIGRATOR.run(&pool).await?;
     Ok((db, pool))
     Ok((db, pool))
   }
   }

+ 16 - 42
src/db/user_repo/lockout.rs

@@ -302,24 +302,15 @@ pub async fn lockout_remaining(
 mod tests {
 mod tests {
   use std::{
   use std::{
     net::{IpAddr, Ipv4Addr},
     net::{IpAddr, Ipv4Addr},
-    sync::atomic::{AtomicU64, Ordering},
     time::Duration,
     time::Duration,
   };
   };
 
 
-  use testcontainers::{
-    GenericImage, ImageExt,
-    core::{IntoContainerPort, WaitFor},
-    runners::AsyncRunner,
-  };
-
   use super::{
   use super::{
     LockoutPolicy, clear_login_failures, ip_failure_count, is_locked_out, is_unknown_locked_out,
     LockoutPolicy, clear_login_failures, ip_failure_count, is_locked_out, is_unknown_locked_out,
     login_failure_count, register_login_failure, register_unknown_failure, unknown_failure_count,
     login_failure_count, register_login_failure, register_unknown_failure, unknown_failure_count,
     unlock_login,
     unlock_login,
   };
   };
-  use crate::{config::DurabilityMode, state::RedisConn, store::Store, test_guard::ContainerGuard};
-
-  static NONCE: AtomicU64 = AtomicU64::new(1);
+  use crate::{config::DurabilityMode, state::RedisConn, store::Store};
 
 
   fn attacker_ip() -> IpAddr {
   fn attacker_ip() -> IpAddr {
     IpAddr::V4(Ipv4Addr::new(192, 0, 2, 7))
     IpAddr::V4(Ipv4Addr::new(192, 0, 2, 7))
@@ -333,34 +324,17 @@ mod tests {
     LockoutPolicy::new(5, 20, Duration::from_secs(900)).ok_or("invalid policy".into())
     LockoutPolicy::new(5, 20, Duration::from_secs(900)).ok_or("invalid policy".into())
   }
   }
 
 
-  async fn harness() -> Result<(ContainerGuard, RedisConn, Store), TestError> {
-    let n = NONCE.fetch_add(1, Ordering::Relaxed);
-    let container = GenericImage::new("redis", "8-alpine")
-      .with_exposed_port(6379.tcp())
-      .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-      .with_cmd(["redis-server", "--appendonly", "yes"])
-      .start()
-      .await?;
-    let guarded = ContainerGuard::new(container);
-    let port = guarded.get_host_port_ipv4(6379).await?;
-    let url = format!("redis://127.0.0.1:{port}");
-    let client = redis::Client::open(url.as_str())?;
-    let manager_config = redis::aio::ConnectionManagerConfig::new()
-      .set_response_timeout(Some(Duration::from_secs(10)))
-      .set_connection_timeout(Some(Duration::from_secs(5)));
-    let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
-    let store = Store::from_parts(
-      format!("lockout-test-{n}:{{state}}:"),
-      "oauth:{rl}:".to_owned(),
-      DurabilityMode::WaitAof,
-    );
-    Ok((guarded, RedisConn::Single(manager), store))
+  async fn harness() -> Result<(RedisConn, Store), TestError> {
+    let manager = crate::test_support::redis_manager()
+      .await
+      .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+    let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+    let store = Store::from_parts(state_prefix, rl_prefix, DurabilityMode::WaitAof);
+    Ok((RedisConn::Single(manager), store))
   }
   }
 
 
   #[derive(Debug, thiserror::Error)]
   #[derive(Debug, thiserror::Error)]
   enum TestError {
   enum TestError {
-    #[error("container error: {0}")]
-    Containers(#[from] testcontainers::TestcontainersError),
     #[error("redis error: {0}")]
     #[error("redis error: {0}")]
     Redis(#[from] redis::RedisError),
     Redis(#[from] redis::RedisError),
     #[error("store error: {0}")]
     #[error("store error: {0}")]
@@ -397,7 +371,7 @@ mod tests {
   /// bucket only; the same account from a different IP is not locked.
   /// bucket only; the same account from a different IP is not locked.
   #[tokio::test]
   #[tokio::test]
   async fn lockout_attacker_ip_burst_does_not_lock_victim_ip() -> TestResult {
   async fn lockout_attacker_ip_burst_does_not_lock_victim_ip() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let policy = policy().map_err(|_| TestError::Unexpected("invalid policy".to_owned()))?;
     let policy = policy().map_err(|_| TestError::Unexpected("invalid policy".to_owned()))?;
     let label = "victim@example.test";
     let label = "victim@example.test";
     for _ in 0..5 {
     for _ in 0..5 {
@@ -420,7 +394,7 @@ mod tests {
   /// attempt from one IP lands in that IP's single bounded bucket.
   /// attempt from one IP lands in that IP's single bounded bucket.
   #[tokio::test]
   #[tokio::test]
   async fn lockout_unknown_labels_share_one_bounded_bucket() -> TestResult {
   async fn lockout_unknown_labels_share_one_bounded_bucket() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let policy = policy().map_err(|_| TestError::Unexpected("invalid policy".to_owned()))?;
     let policy = policy().map_err(|_| TestError::Unexpected("invalid policy".to_owned()))?;
     for _ in 0..25 {
     for _ in 0..25 {
       register_unknown_failure(&mut conn, &store, policy, Some(attacker_ip())).await?;
       register_unknown_failure(&mut conn, &store, policy, Some(attacker_ip())).await?;
@@ -454,7 +428,7 @@ mod tests {
   /// per-IP composite bucket for the label.
   /// per-IP composite bucket for the label.
   #[tokio::test]
   #[tokio::test]
   async fn lockout_unlock_clears_composite_and_account() -> TestResult {
   async fn lockout_unlock_clears_composite_and_account() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let policy = LockoutPolicy::new(2, 4, Duration::from_secs(900))
     let policy = LockoutPolicy::new(2, 4, Duration::from_secs(900))
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
     let label = "locked@example.test";
     let label = "locked@example.test";
@@ -494,7 +468,7 @@ mod tests {
   /// every source stays below the per-IP threshold (distributed burst).
   /// every source stays below the per-IP threshold (distributed burst).
   #[tokio::test]
   #[tokio::test]
   async fn lockout_global_cap_locks_fresh_ip_while_each_ip_below_threshold() -> TestResult {
   async fn lockout_global_cap_locks_fresh_ip_while_each_ip_below_threshold() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let policy = LockoutPolicy::new(5, 8, Duration::from_secs(900))
     let policy = LockoutPolicy::new(5, 8, Duration::from_secs(900))
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
     let label = "global-cap@example.test";
     let label = "global-cap@example.test";
@@ -536,7 +510,7 @@ mod tests {
   /// advances; no composite bucket is minted and the clear removes just it.
   /// advances; no composite bucket is minted and the clear removes just it.
   #[tokio::test]
   #[tokio::test]
   async fn lockout_peerless_attempts_track_account_counter_only() -> TestResult {
   async fn lockout_peerless_attempts_track_account_counter_only() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let policy = LockoutPolicy::new(5, 20, Duration::from_secs(900))
     let policy = LockoutPolicy::new(5, 20, Duration::from_secs(900))
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
     let label = "peerless@example.test";
     let label = "peerless@example.test";
@@ -583,7 +557,7 @@ mod tests {
   /// the calling IP's composite bucket; another IP's locked bucket survives.
   /// the calling IP's composite bucket; another IP's locked bucket survives.
   #[tokio::test]
   #[tokio::test]
   async fn lockout_clear_success_releases_only_calling_ip() -> TestResult {
   async fn lockout_clear_success_releases_only_calling_ip() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let policy = LockoutPolicy::new(5, 100, Duration::from_secs(900))
     let policy = LockoutPolicy::new(5, 100, Duration::from_secs(900))
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
     let label = "scoped-clear@example.test";
     let label = "scoped-clear@example.test";
@@ -625,7 +599,7 @@ mod tests {
   /// buckets (no cross-family normalization; each family locks alone).
   /// buckets (no cross-family normalization; each family locks alone).
   #[tokio::test]
   #[tokio::test]
   async fn lockout_ipv4_and_mapped_ipv6_use_distinct_buckets() -> TestResult {
   async fn lockout_ipv4_and_mapped_ipv6_use_distinct_buckets() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let policy = LockoutPolicy::new(5, 100, Duration::from_secs(900))
     let policy = LockoutPolicy::new(5, 100, Duration::from_secs(900))
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
     let label = "family-split@example.test";
     let label = "family-split@example.test";
@@ -664,7 +638,7 @@ mod tests {
   /// touches the label-less unknown buckets.
   /// touches the label-less unknown buckets.
   #[tokio::test]
   #[tokio::test]
   async fn lockout_unlock_sweeps_more_than_scan_page() -> TestResult {
   async fn lockout_unlock_sweeps_more_than_scan_page() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let policy = LockoutPolicy::new(5, 1_000_000, Duration::from_secs(900))
     let policy = LockoutPolicy::new(5, 1_000_000, Duration::from_secs(900))
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
     let label = "paged-sweep@example.test";
     let label = "paged-sweep@example.test";

+ 9 - 9
src/jose/dpop/reject.rs

@@ -15,7 +15,7 @@ use super::{
 /// A proof bound to one method does not verify for another.
 /// A proof bound to one method does not verify for another.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_tampered_htm_rejected() -> TestResult {
 async fn dpop_tampered_htm_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let proof = mint_proof(&valid_params(
   let proof = mint_proof(&valid_params(
@@ -38,7 +38,7 @@ async fn dpop_tampered_htm_rejected() -> TestResult {
 /// A proof bound to one URI does not verify for another.
 /// A proof bound to one URI does not verify for another.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_htu_mismatch_rejected() -> TestResult {
 async fn dpop_htu_mismatch_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let proof = mint_proof(&valid_params(
   let proof = mint_proof(&valid_params(
@@ -61,7 +61,7 @@ async fn dpop_htu_mismatch_rejected() -> TestResult {
 /// A proof `htu` carrying a query string is malformed (RFC 9449 §4.2).
 /// A proof `htu` carrying a query string is malformed (RFC 9449 §4.2).
 #[tokio::test]
 #[tokio::test]
 async fn dpop_htu_with_query_in_proof_rejected() -> TestResult {
 async fn dpop_htu_with_query_in_proof_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let jti = unique_jti();
   let jti = unique_jti();
@@ -84,7 +84,7 @@ async fn dpop_htu_with_query_in_proof_rejected() -> TestResult {
 /// A long-dead proof is rejected by the freshness window.
 /// A long-dead proof is rejected by the freshness window.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_stale_iat_rejected() -> TestResult {
 async fn dpop_stale_iat_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let proof = mint_proof(&valid_params(
   let proof = mint_proof(&valid_params(
@@ -107,7 +107,7 @@ async fn dpop_stale_iat_rejected() -> TestResult {
 /// A proof from the far future is rejected despite clock-skew tolerance.
 /// A proof from the far future is rejected despite clock-skew tolerance.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_future_iat_rejected() -> TestResult {
 async fn dpop_future_iat_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let proof = mint_proof(&valid_params(
   let proof = mint_proof(&valid_params(
@@ -130,7 +130,7 @@ async fn dpop_future_iat_rejected() -> TestResult {
 /// A missing proof (no `DPoP` header) is `invalid_dpop_proof`, not silent.
 /// A missing proof (no `DPoP` header) is `invalid_dpop_proof`, not silent.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_missing_proof_rejected() -> TestResult {
 async fn dpop_missing_proof_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let request = DpopRequest {
   let request = DpopRequest {
     proof: None,
     proof: None,
     htm: TEST_HTM,
     htm: TEST_HTM,
@@ -145,7 +145,7 @@ async fn dpop_missing_proof_rejected() -> TestResult {
 /// Only the exact `dpop+jwt` type is accepted.
 /// Only the exact `dpop+jwt` type is accepted.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_wrong_or_missing_typ_rejected() -> TestResult {
 async fn dpop_wrong_or_missing_typ_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   for typ in [Some("jwt"), Some("dpop"), None] {
   for typ in [Some("jwt"), Some("dpop"), None] {
@@ -167,7 +167,7 @@ async fn dpop_wrong_or_missing_typ_rejected() -> TestResult {
 /// A proof without an embedded JWK leaves nothing to verify against.
 /// A proof without an embedded JWK leaves nothing to verify against.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_missing_jwk_rejected() -> TestResult {
 async fn dpop_missing_jwk_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let jti = unique_jti();
   let jti = unique_jti();
@@ -187,7 +187,7 @@ async fn dpop_missing_jwk_rejected() -> TestResult {
 /// Oversized and misshapen inputs are rejected before any crypto runs.
 /// Oversized and misshapen inputs are rejected before any crypto runs.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_malformed_shape_rejected() -> TestResult {
 async fn dpop_malformed_shape_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let now = now_unix();
   let now = now_unix();
   let big = "p".repeat(super::MAX_PROOF_LEN + 1);
   let big = "p".repeat(super::MAX_PROOF_LEN + 1);
   for proof in ["not-a-jwt", "a.b", "a.b.c.d", "", big.as_str()] {
   for proof in ["not-a-jwt", "a.b", "a.b.c.d", "", big.as_str()] {

+ 6 - 6
src/jose/dpop/reject_key.rs

@@ -17,7 +17,7 @@ use super::{
 /// `alg:none` never verifies, even with well-formed claims.
 /// `alg:none` never verifies, even with well-formed claims.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_alg_none_rejected() -> TestResult {
 async fn dpop_alg_none_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let header =
   let header =
@@ -47,7 +47,7 @@ async fn dpop_alg_none_rejected() -> TestResult {
 /// HMAC proofs are rejected at the allowlist, before any key is built.
 /// HMAC proofs are rejected at the allowlist, before any key is built.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_hmac_alg_rejected() -> TestResult {
 async fn dpop_hmac_alg_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let mut header = Header::new(Algorithm::HS256);
   let mut header = Header::new(Algorithm::HS256);
@@ -75,7 +75,7 @@ async fn dpop_hmac_alg_rejected() -> TestResult {
 /// embedding another fails the signature check.
 /// embedding another fails the signature check.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_jwk_signature_mismatch_rejected() -> TestResult {
 async fn dpop_jwk_signature_mismatch_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let signer = fresh_es256_key()?;
   let signer = fresh_es256_key()?;
   let embedded = fresh_es256_key()?;
   let embedded = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
@@ -99,7 +99,7 @@ async fn dpop_jwk_signature_mismatch_rejected() -> TestResult {
 /// A JWK smuggling private material is refused before signature work.
 /// A JWK smuggling private material is refused before signature work.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_private_jwk_rejected() -> TestResult {
 async fn dpop_private_jwk_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let mut evil = key.jwk.clone();
   let mut evil = key.jwk.clone();
@@ -131,7 +131,7 @@ async fn dpop_private_jwk_rejected() -> TestResult {
 /// Symmetric JWKs have no place in `DPoP` and are refused outright.
 /// Symmetric JWKs have no place in `DPoP` and are refused outright.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_oct_jwk_rejected() -> TestResult {
 async fn dpop_oct_jwk_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let oct = serde_json::json!({"kty": "oct", "k": "GawgguFyGrWKav7AX4VKUg"});
   let oct = serde_json::json!({"kty": "oct", "k": "GawgguFyGrWKav7AX4VKUg"});
@@ -151,7 +151,7 @@ async fn dpop_oct_jwk_rejected() -> TestResult {
 /// absent `ath` is rejected when a token is presented.
 /// absent `ath` is rejected when a token is presented.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_ath_mismatch_rejected() -> TestResult {
 async fn dpop_ath_mismatch_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let token = "dpop-test-access-token";
   let token = "dpop-test-access-token";

+ 14 - 50
src/jose/dpop/support.rs

@@ -1,19 +1,11 @@
 //! Shared harness for the `dpop_*` suite: test keys, proof minting, Redis.
 //! Shared harness for the `dpop_*` suite: test keys, proof minting, Redis.
 
 
-use std::{
-  sync::atomic::{AtomicU64, Ordering},
-  time::Duration,
-};
+use std::sync::atomic::{AtomicU64, Ordering};
 
 
 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
 use serde_json::Value;
 use serde_json::Value;
 use sha2::{Digest as _, Sha256};
 use sha2::{Digest as _, Sha256};
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort as _, WaitFor},
-  runners::AsyncRunner as _,
-};
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 
 
 use crate::{
 use crate::{
@@ -22,7 +14,6 @@ use crate::{
   jose::jwk::JwkError,
   jose::jwk::JwkError,
   state::RedisConn,
   state::RedisConn,
   store::{Store, StoreError},
   store::{Store, StoreError},
-  test_guard::{ContainerGuard, Lease, Shared},
 };
 };
 
 
 /// Test body result (no `unwrap`/`expect` under the deny lints).
 /// Test body result (no `unwrap`/`expect` under the deny lints).
@@ -31,9 +22,6 @@ pub(super) type TestResult = Result<(), TestError>;
 /// Errors raised by the `dpop` test scaffolding itself.
 /// Errors raised by the `dpop` test scaffolding itself.
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 pub(super) enum TestError {
 pub(super) enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   /// A Redis command failed.
   #[error("redis error")]
   #[error("redis error")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
@@ -65,44 +53,20 @@ pub(super) const TEST_HTM: &str = "POST";
 /// URI every test proof is bound to.
 /// URI every test proof is bound to.
 pub(super) const TEST_HTU: &str = "https://as.example.test/token";
 pub(super) const TEST_HTU: &str = "https://as.example.test/token";
 
 
-/// One shared Redis 8 container for the whole `dpop` suite.
-///
-/// The slot holds a [`ContainerGuard`], so the last test releasing it removes
-/// the container synchronously instead of orphaning it at process exit.
-static REDIS: Shared<ContainerGuard> = Shared::new();
-
-/// Fresh connection plus store handle against the shared container.
-///
-/// The returned lease keeps the shared container alive; bind it (usually as
-/// `_redis`) for the duration of the test.
+/// Fresh connection plus store handle against the shared compose Redis.
 ///
 ///
-/// Timeouts are generous (10 s/5 s): the container is local and no barrier
-/// runs here, so any failure is a real defect, not a budget cut.
-pub(super) async fn test_redis() -> Result<(Lease<ContainerGuard>, RedisConn, Store), TestError> {
-  let container = REDIS
-    .lease::<TestError>(async {
-      let started = GenericImage::new("redis", "8-alpine")
-        .with_exposed_port(6379.tcp())
-        .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-        .with_cmd(["redis-server", "--appendonly", "yes"])
-        .start()
-        .await?;
-      Ok(ContainerGuard::new(started))
-    })
-    .await?;
-  let port = container.get_host_port_ipv4(6379).await?;
-  let url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(url.as_str())?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
-  let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
-    DurabilityMode::WaitAof,
-  );
-  Ok((container, RedisConn::Single(manager), store))
+/// Isolation comes from a per-test [`crate::test_support::unique_prefixes`]
+/// pair, so parallel `dpop` tests never observe each other's keys. The
+/// [`crate::test_support::redis_manager`] helper uses the suite-wide 10 s/5 s
+/// timeouts: the server is local and no barrier runs here, so any failure is a
+/// real defect, not a budget cut.
+pub(super) async fn test_redis() -> Result<(RedisConn, Store), TestError> {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let store = Store::from_parts(state_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
+  Ok((RedisConn::Single(manager), store))
 }
 }
 
 
 static JTI_NONCE: AtomicU64 = AtomicU64::new(1);
 static JTI_NONCE: AtomicU64 = AtomicU64::new(1);

+ 5 - 5
src/jose/dpop/tests.rs

@@ -16,7 +16,7 @@ use crate::jose::jwk::thumbprint;
 /// A valid proof verifies and returns the proof-key thumbprint as `jkt`.
 /// A valid proof verifies and returns the proof-key thumbprint as `jkt`.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_valid_proof_accepted_returns_bound_jkt() -> TestResult {
 async fn dpop_valid_proof_accepted_returns_bound_jkt() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let jti = unique_jti();
   let jti = unique_jti();
@@ -53,7 +53,7 @@ fn dpop_rfc7638_thumbprint_vector() -> TestResult {
 /// `iat` values verify, one second beyond either side is rejected.
 /// `iat` values verify, one second beyond either side is rejected.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_iat_window_boundaries_enforced() -> TestResult {
 async fn dpop_iat_window_boundaries_enforced() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   for offset in [-DPOP_IAT_WINDOW_SECS, DPOP_FUTURE_SKEW_SECS] {
   for offset in [-DPOP_IAT_WINDOW_SECS, DPOP_FUTURE_SKEW_SECS] {
@@ -96,7 +96,7 @@ async fn dpop_iat_window_boundaries_enforced() -> TestResult {
 /// The second presentation of a proof loses the `jti` race and is rejected.
 /// The second presentation of a proof loses the `jti` race and is rejected.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_replayed_jti_rejected() -> TestResult {
 async fn dpop_replayed_jti_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let jti = unique_jti();
   let jti = unique_jti();
@@ -117,7 +117,7 @@ async fn dpop_replayed_jti_rejected() -> TestResult {
 /// the token matches.
 /// the token matches.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_ath_bound_access_accepted() -> TestResult {
 async fn dpop_ath_bound_access_accepted() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let token = "dpop-test-access-token";
   let token = "dpop-test-access-token";
@@ -143,7 +143,7 @@ async fn dpop_ath_bound_access_accepted() -> TestResult {
 /// token-endpoint call with parameters still matches a canonical proof.
 /// token-endpoint call with parameters still matches a canonical proof.
 #[tokio::test]
 #[tokio::test]
 async fn dpop_htu_query_stripped_server_side_accepted() -> TestResult {
 async fn dpop_htu_query_stripped_server_side_accepted() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let key = fresh_es256_key()?;
   let key = fresh_es256_key()?;
   let now = now_unix();
   let now = now_unix();
   let jti = unique_jti();
   let jti = unique_jti();

+ 2 - 0
src/lib.rs

@@ -24,6 +24,8 @@ pub mod state;
 pub mod store;
 pub mod store;
 #[cfg(test)]
 #[cfg(test)]
 pub(crate) mod test_guard;
 pub(crate) mod test_guard;
+#[cfg(test)]
+pub(crate) mod test_support;
 pub mod tls;
 pub mod tls;
 pub mod ui;
 pub mod ui;
 
 

+ 17 - 26
src/middleware/ratelimit/test_support.rs

@@ -1,4 +1,4 @@
-//! Fixtures for the `test(ratelimit)` suite: a real Redis container, a lazy
+//! Fixtures for the `test(ratelimit)` suite: the shared compose Redis, a lazy
 //! Postgres pool the limiter never touches, and a `Send` passthrough handler.
 //! Postgres pool the limiter never touches, and a `Send` passthrough handler.
 
 
 use std::error::Error;
 use std::error::Error;
@@ -10,16 +10,10 @@ use axum::body::Body;
 use axum::extract::ConnectInfo;
 use axum::extract::ConnectInfo;
 use axum::http::{Request, Response, header};
 use axum::http::{Request, Response, header};
 use serde_json::{Value, json};
 use serde_json::{Value, json};
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use tower::{Service, ServiceExt};
 use tower::{Service, ServiceExt};
 
 
 use crate::config::{Config, ConfigError};
 use crate::config::{Config, ConfigError};
 use crate::state::{AppState, Clock, RedisConn};
 use crate::state::{AppState, Clock, RedisConn};
-use crate::test_guard::ContainerGuard;
 
 
 /// Result alias keeping `?` usable across containers, Redis, config, and sqlx.
 /// Result alias keeping `?` usable across containers, Redis, config, and sqlx.
 pub(super) type TestResult = Result<(), Box<dyn Error>>;
 pub(super) type TestResult = Result<(), Box<dyn Error>>;
@@ -46,29 +40,26 @@ impl Service<Request<Body>> for Passthrough {
   }
   }
 }
 }
 
 
-/// Start a single-node Redis with AOF enabled and return its URL.
-///
-/// The guard removes the container synchronously when the test drops it.
-pub(super) async fn redis_container() -> Result<(ContainerGuard, String), Box<dyn Error>> {
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .with_cmd(["redis-server", "--appendonly", "yes"])
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  Ok((guarded, format!("redis://127.0.0.1:{port}")))
-}
-
-/// Build a validated config over `redis_url` with the given rate-limit block.
-pub(super) fn test_config(redis_url: &str, rate_limit: Value) -> Result<Config, ConfigError> {
+/// Build a validated config over `redis_url` with the given rate-limit block
+/// and a per-test keyspace prefix pair, so counter keys never collide on the
+/// shared Redis.
+pub(super) fn test_config(
+  redis_url: &str,
+  prefixes: (String, String),
+  rate_limit: Value,
+) -> Result<Config, ConfigError> {
+  let (token_key_prefix, ratelimit_key_prefix) = prefixes;
   let fixture = json!({
   let fixture = json!({
     "issuer": "https://auth.example.test",
     "issuer": "https://auth.example.test",
     "environment": "development",
     "environment": "development",
     "tls": {"cert_path": "/tmp/ratelimit-test-cert.pem", "key_path": "/tmp/ratelimit-test-key.pem"},
     "tls": {"cert_path": "/tmp/ratelimit-test-cert.pem", "key_path": "/tmp/ratelimit-test-key.pem"},
     "database": {"url": "postgres://127.0.0.1:1/ratelimit"},
     "database": {"url": "postgres://127.0.0.1:1/ratelimit"},
-    "redis": {"url": redis_url, "durability": "waitaof"},
+    "redis": {
+      "url": redis_url,
+      "durability": "waitaof",
+      "token_key_prefix": token_key_prefix,
+      "ratelimit_key_prefix": ratelimit_key_prefix,
+    },
     "token_pepper": "ratelimit-test-pepper-0123456789abcdef",
     "token_pepper": "ratelimit-test-pepper-0123456789abcdef",
     "totp_encryption_key": "ratelimit-test-totp-key-0123456789abcdef",
     "totp_encryption_key": "ratelimit-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "ratelimit-test-pii-key-0123456789abcdef",
     "pii_encryption_key": "ratelimit-test-pii-key-0123456789abcdef",
@@ -96,7 +87,7 @@ fn build_state(
   }
   }
 }
 }
 
 
-/// State backed by a live Redis container.
+/// State backed by the shared live Redis.
 pub(super) async fn live_state(
 pub(super) async fn live_state(
   config: Config,
   config: Config,
   redis_url: &str,
   redis_url: &str,

+ 53 - 29
src/middleware/ratelimit/tests.rs

@@ -1,7 +1,8 @@
 //! `test(ratelimit)` suite: fixed-window enforcement against real Redis.
 //! `test(ratelimit)` suite: fixed-window enforcement against real Redis.
 //!
 //!
-//! Each test owns a throwaway `redis:8-alpine` container (AOF on), so
-//! counter keys never leak across tests. Fixtures live in [`super::test_support`].
+//! Each test uses the shared compose Redis with its own unique key prefixes,
+//! so counter keys never leak across tests. Fixtures live in
+//! [`super::test_support`].
 
 
 use std::net::{IpAddr, Ipv6Addr, SocketAddr};
 use std::net::{IpAddr, Ipv6Addr, SocketAddr};
 use std::time::Duration;
 use std::time::Duration;
@@ -13,16 +14,17 @@ use tower::Layer;
 use super::RateLimitLayer;
 use super::RateLimitLayer;
 use super::identity::{client_bucket_raw, counter_key};
 use super::identity::{client_bucket_raw, counter_key};
 use super::test_support::{
 use super::test_support::{
-  Extras, Passthrough, TestResult, live_state, peer, redis_container, retry_after, scan_keys, send,
-  send_with, test_config, unavailable_state,
+  Extras, Passthrough, TestResult, live_state, peer, retry_after, scan_keys, send, send_with,
+  test_config, unavailable_state,
 };
 };
 
 
 /// N in-window requests pass; the N+1th is `429` + `Retry-After`.
 /// N in-window requests pass; the N+1th is `429` + `Retry-After`.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_token_per_ip_rejects_over_budget_with_retry_after() -> TestResult {
 async fn ratelimit_token_per_ip_rejects_over_budget_with_retry_after() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 3, "token_per_client": 1000}),
     json!({"enabled": true, "window_secs": 60, "per_ip": 3, "token_per_client": 1000}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
@@ -65,9 +67,10 @@ async fn ratelimit_token_per_ip_rejects_over_budget_with_retry_after() -> TestRe
 /// same client from a different peer owns a separate bucket (T3-C2).
 /// same client from a different peer owns a separate bucket (T3-C2).
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_token_per_client_enforced_per_peer() -> TestResult {
 async fn ratelimit_token_per_client_enforced_per_peer() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1000, "token_per_client": 2}),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1000, "token_per_client": 2}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
@@ -118,9 +121,10 @@ async fn ratelimit_token_per_client_enforced_per_peer() -> TestResult {
 /// A counter resets after its window, so the next request is admitted again.
 /// A counter resets after its window, so the next request is admitted again.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_recovers_after_window_expires() -> TestResult {
 async fn ratelimit_recovers_after_window_expires() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 1, "per_ip": 1}),
     json!({"enabled": true, "window_secs": 1, "per_ip": 1}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
@@ -146,12 +150,14 @@ async fn ratelimit_recovers_after_window_expires() -> TestResult {
 /// Counter keys are hash-tagged and contain no raw IP or `client_id`.
 /// Counter keys are hash-tagged and contain no raw IP or `client_id`.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_keys_are_hashed_and_cluster_tagged() -> TestResult {
 async fn ratelimit_keys_are_hashed_and_cluster_tagged() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 100, "token_per_client": 100}),
     json!({"enabled": true, "window_secs": 60, "per_ip": 100, "token_per_client": 100}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
+  let prefix = state.config.redis.ratelimit_key_prefix.clone();
   let service = RateLimitLayer::new(state).layer(Passthrough);
   let service = RateLimitLayer::new(state).layer(Passthrough);
 
 
   let source = SocketAddr::from(([203, 0, 113, 7], 40_000));
   let source = SocketAddr::from(([203, 0, 113, 7], 40_000));
@@ -165,7 +171,7 @@ async fn ratelimit_keys_are_hashed_and_cluster_tagged() -> TestResult {
   .await?;
   .await?;
   assert_eq!(response.status(), StatusCode::OK);
   assert_eq!(response.status(), StatusCode::OK);
 
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   assert!(
   assert!(
     keys.len() >= 2,
     keys.len() >= 2,
     "both IP and client counters must exist: {keys:?}"
     "both IP and client counters must exist: {keys:?}"
@@ -192,6 +198,7 @@ async fn ratelimit_keys_are_hashed_and_cluster_tagged() -> TestResult {
 async fn ratelimit_token_fails_closed_on_store_outage() -> TestResult {
 async fn ratelimit_token_fails_closed_on_store_outage() -> TestResult {
   let config = test_config(
   let config = test_config(
     "redis://127.0.0.1:1",
     "redis://127.0.0.1:1",
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 10, "token_per_client": 10}),
     json!({"enabled": true, "window_secs": 60, "per_ip": 10, "token_per_client": 10}),
   )?;
   )?;
   let state = unavailable_state(config)?;
   let state = unavailable_state(config)?;
@@ -217,7 +224,11 @@ async fn ratelimit_token_fails_closed_on_store_outage() -> TestResult {
 /// while the store is down.
 /// while the store is down.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_leaves_unlisted_paths_untouched_on_store_outage() -> TestResult {
 async fn ratelimit_leaves_unlisted_paths_untouched_on_store_outage() -> TestResult {
-  let config = test_config("redis://127.0.0.1:1", json!({"enabled": true}))?;
+  let config = test_config(
+    "redis://127.0.0.1:1",
+    crate::test_support::unique_prefixes(),
+    json!({"enabled": true}),
+  )?;
   let state = unavailable_state(config)?;
   let state = unavailable_state(config)?;
   let service = RateLimitLayer::new(state).layer(Passthrough);
   let service = RateLimitLayer::new(state).layer(Passthrough);
 
 
@@ -231,9 +242,10 @@ async fn ratelimit_leaves_unlisted_paths_untouched_on_store_outage() -> TestResu
 /// evade the limit.
 /// evade the limit.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_ignores_spoofed_x_forwarded_for() -> TestResult {
 async fn ratelimit_ignores_spoofed_x_forwarded_for() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
@@ -270,7 +282,7 @@ async fn ratelimit_ignores_spoofed_x_forwarded_for() -> TestResult {
     "rotating X-Forwarded-For must not evade the real peer's bucket"
     "rotating X-Forwarded-For must not evade the real peer's bucket"
   );
   );
 
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   assert_eq!(
   assert_eq!(
     keys,
     keys,
     vec![counter_key(&prefix, "ip", "127.0.0.1")],
     vec![counter_key(&prefix, "ip", "127.0.0.1")],
@@ -289,9 +301,10 @@ async fn ratelimit_ignores_spoofed_x_forwarded_for() -> TestResult {
 /// different from the recorded peer.
 /// different from the recorded peer.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_ignores_spoofed_forwarded_header() -> TestResult {
 async fn ratelimit_ignores_spoofed_forwarded_header() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
@@ -328,7 +341,7 @@ async fn ratelimit_ignores_spoofed_forwarded_header() -> TestResult {
     "rotating Forwarded must not evade the real peer's bucket"
     "rotating Forwarded must not evade the real peer's bucket"
   );
   );
 
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   assert_eq!(
   assert_eq!(
     keys,
     keys,
     vec![counter_key(&prefix, "ip", "127.0.0.1")],
     vec![counter_key(&prefix, "ip", "127.0.0.1")],
@@ -341,9 +354,10 @@ async fn ratelimit_ignores_spoofed_forwarded_header() -> TestResult {
 /// never limits the same traffic from `::1`, and the reverse.
 /// never limits the same traffic from `::1`, and the reverse.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_ipv6_peer_owns_a_distinct_bucket() -> TestResult {
 async fn ratelimit_ipv6_peer_owns_a_distinct_bucket() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
@@ -399,7 +413,7 @@ async fn ratelimit_ipv6_peer_owns_a_distinct_bucket() -> TestResult {
     "the IPv6 peer's own bucket must still enforce the budget"
     "the IPv6 peer's own bucket must still enforce the budget"
   );
   );
 
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   for expected in [
   for expected in [
     counter_key(&prefix, "ip", "127.0.0.1"),
     counter_key(&prefix, "ip", "127.0.0.1"),
     counter_key(&prefix, "ip", "::1"),
     counter_key(&prefix, "ip", "::1"),
@@ -422,9 +436,10 @@ async fn ratelimit_ipv6_peer_owns_a_distinct_bucket() -> TestResult {
 /// separately.
 /// separately.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_per_ip_budget_blocks_fresh_client_from_same_ip() -> TestResult {
 async fn ratelimit_per_ip_budget_blocks_fresh_client_from_same_ip() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
@@ -455,7 +470,7 @@ async fn ratelimit_per_ip_budget_blocks_fresh_client_from_same_ip() -> TestResul
     "a fresh client from an exhausted peer must hit the per-IP budget"
     "a fresh client from an exhausted peer must hit the per-IP budget"
   );
   );
 
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   for expected in [
   for expected in [
     counter_key(&prefix, "ip", "127.0.0.1"),
     counter_key(&prefix, "ip", "127.0.0.1"),
     counter_key(
     counter_key(
@@ -486,9 +501,10 @@ async fn ratelimit_per_ip_budget_blocks_fresh_client_from_same_ip() -> TestResul
 /// configured window.
 /// configured window.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_retry_after_is_positive_and_bounded_by_window() -> TestResult {
 async fn ratelimit_retry_after_is_positive_and_bounded_by_window() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
@@ -526,12 +542,14 @@ async fn ratelimit_retry_after_is_positive_and_bounded_by_window() -> TestResult
 /// the configured budget are admitted and no limiter key is written.
 /// the configured budget are admitted and no limiter key is written.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_disabled_never_limits_and_writes_no_keys() -> TestResult {
 async fn ratelimit_disabled_never_limits_and_writes_no_keys() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": false, "window_secs": 60, "per_ip": 1, "token_per_client": 1}),
     json!({"enabled": false, "window_secs": 60, "per_ip": 1, "token_per_client": 1}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
+  let prefix = state.config.redis.ratelimit_key_prefix.clone();
   let service = RateLimitLayer::new(state).layer(Passthrough);
   let service = RateLimitLayer::new(state).layer(Passthrough);
 
 
   for attempt in 0..3 {
   for attempt in 0..3 {
@@ -550,7 +568,7 @@ async fn ratelimit_disabled_never_limits_and_writes_no_keys() -> TestResult {
     );
     );
   }
   }
 
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   assert!(
   assert!(
     keys.is_empty(),
     keys.is_empty(),
     "a disabled limiter must not write counter keys: {keys:?}"
     "a disabled limiter must not write counter keys: {keys:?}"
@@ -564,9 +582,10 @@ async fn ratelimit_disabled_never_limits_and_writes_no_keys() -> TestResult {
 /// `(peer_ip, client_id)`, so the victim's request still passes.
 /// `(peer_ip, client_id)`, so the victim's request still passes.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_client_bucket_is_scoped_to_peer_ip() -> TestResult {
 async fn ratelimit_client_bucket_is_scoped_to_peer_ip() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1000, "token_per_client": 2}),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1000, "token_per_client": 2}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
@@ -618,9 +637,10 @@ async fn ratelimit_client_bucket_is_scoped_to_peer_ip() -> TestResult {
 /// request regardless of the claimed client identity.
 /// request regardless of the claimed client identity.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_rotating_fake_client_ids_does_not_evade_ip_budget() -> TestResult {
 async fn ratelimit_rotating_fake_client_ids_does_not_evade_ip_budget() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 2, "token_per_client": 1000}),
     json!({"enabled": true, "window_secs": 60, "per_ip": 2, "token_per_client": 1000}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
@@ -666,9 +686,10 @@ async fn ratelimit_rotating_fake_client_ids_does_not_evade_ip_budget() -> TestRe
 /// peer itself.
 /// peer itself.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_trusted_proxy_end_to_end_attributes_xff_bucket() -> TestResult {
 async fn ratelimit_trusted_proxy_end_to_end_attributes_xff_bucket() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({
     json!({
       "enabled": true,
       "enabled": true,
       "window_secs": 60,
       "window_secs": 60,
@@ -729,7 +750,7 @@ async fn ratelimit_trusted_proxy_end_to_end_attributes_xff_bucket() -> TestResul
     "a different XFF client through the same proxy must own a fresh bucket"
     "a different XFF client through the same proxy must own a fresh bucket"
   );
   );
 
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   for expected in [
   for expected in [
     counter_key(&prefix, "ip", "198.51.100.7"),
     counter_key(&prefix, "ip", "198.51.100.7"),
     counter_key(&prefix, "ip", "198.51.100.8"),
     counter_key(&prefix, "ip", "198.51.100.8"),
@@ -756,9 +777,10 @@ async fn ratelimit_trusted_proxy_end_to_end_attributes_xff_bucket() -> TestResul
 /// bucket through the full layer instead of minting a foreign bucket.
 /// bucket through the full layer instead of minting a foreign bucket.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_trusted_proxy_unparseable_xff_falls_back_to_peer() -> TestResult {
 async fn ratelimit_trusted_proxy_unparseable_xff_falls_back_to_peer() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({
     json!({
       "enabled": true,
       "enabled": true,
       "window_secs": 60,
       "window_secs": 60,
@@ -819,7 +841,7 @@ async fn ratelimit_trusted_proxy_unparseable_xff_falls_back_to_peer() -> TestRes
     "`unknown` must likewise count against the peer bucket"
     "`unknown` must likewise count against the peer bucket"
   );
   );
 
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   assert_eq!(
   assert_eq!(
     keys,
     keys,
     vec![counter_key(&prefix, "ip", "10.0.0.1")],
     vec![counter_key(&prefix, "ip", "10.0.0.1")],
@@ -833,12 +855,14 @@ async fn ratelimit_trusted_proxy_unparseable_xff_falls_back_to_peer() -> TestRes
 /// to count.
 /// to count.
 #[tokio::test]
 #[tokio::test]
 async fn ratelimit_missing_peer_fails_closed_without_keys() -> TestResult {
 async fn ratelimit_missing_peer_fails_closed_without_keys() -> TestResult {
-  let (_redis, url) = redis_container().await?;
+  let url = crate::test_support::test_redis_url();
   let config = test_config(
   let config = test_config(
     &url,
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 10, "token_per_client": 10}),
     json!({"enabled": true, "window_secs": 60, "per_ip": 10, "token_per_client": 10}),
   )?;
   )?;
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
+  let prefix = state.config.redis.ratelimit_key_prefix.clone();
   let service = RateLimitLayer::new(state).layer(Passthrough);
   let service = RateLimitLayer::new(state).layer(Passthrough);
 
 
   let response = send_with(
   let response = send_with(
@@ -859,7 +883,7 @@ async fn ratelimit_missing_peer_fails_closed_without_keys() -> TestResult {
     "fail-closed response must carry the contractual Retry-After: 1"
     "fail-closed response must carry the contractual Retry-After: 1"
   );
   );
 
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   assert!(
   assert!(
     keys.is_empty(),
     keys.is_empty(),
     "a request with no identity must not write counter keys: {keys:?}"
     "a request with no identity must not write counter keys: {keys:?}"

+ 20 - 39
src/protocol/client_auth/tests_containers.rs

@@ -1,11 +1,14 @@
-//! `test(client_auth)` container suite: full dispatch against real Postgres
+//! `test(client_auth)` dispatch suite: full dispatch against real Postgres
 //! and Redis (client lookup, secret verify, `jti` replay, `jwks_uri` guard).
 //! and Redis (client lookup, secret verify, `jti` replay, `jwks_uri` guard).
+//!
+//! Historical note: the `_containers` filename is retained from when this
+//! suite booted a throwaway container. It now runs against the shared compose
+//! Postgres and Redis with a per-test unique key-prefix pair; the name is kept
+//! only to avoid churning module paths and test names.
 
 
 mod assertions;
 mod assertions;
 mod secrets;
 mod secrets;
 
 
-use std::time::Duration;
-
 use base64::Engine as _;
 use base64::Engine as _;
 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
 use ring::rand::SystemRandom;
 use ring::rand::SystemRandom;
@@ -13,11 +16,6 @@ use ring::signature::{Ed25519KeyPair, KeyPair as _};
 use secrecy::SecretString;
 use secrecy::SecretString;
 use serde::{Deserialize, Serialize};
 use serde::{Deserialize, Serialize};
 use sqlx::PgPool;
 use sqlx::PgPool;
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 use uuid::Uuid;
 use uuid::Uuid;
 
 
@@ -32,7 +30,7 @@ use crate::{
   keys::{KeyId, KeyRing},
   keys::{KeyId, KeyRing},
   state::RedisConn,
   state::RedisConn,
   store::Store,
   store::Store,
-  test_guard::{ContainerGuard, Lease},
+  test_guard::Lease,
 };
 };
 
 
 /// This suite's error type (no `unwrap`/`expect` under the deny lints).
 /// This suite's error type (no `unwrap`/`expect` under the deny lints).
@@ -41,9 +39,6 @@ type TestResult = Result<(), TestError>;
 /// Test scaffolding failures.
 /// Test scaffolding failures.
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 enum TestError {
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   /// A Redis command failed.
   #[error("redis error: {0}")]
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
@@ -62,8 +57,7 @@ enum TestError {
 }
 }
 
 
 /// Live handles for one dispatch test: Postgres pool, Redis, store layout,
 /// Live handles for one dispatch test: Postgres pool, Redis, store layout,
-/// and the canonical token endpoint. The container is returned separately so
-/// tests hold it alive with a `_container` binding.
+/// and the canonical token endpoint.
 struct Harness {
 struct Harness {
   _db: Lease<Db>,
   _db: Lease<Db>,
   pool: PgPool,
   pool: PgPool,
@@ -73,34 +67,21 @@ struct Harness {
   keys: KeyRing,
   keys: KeyRing,
 }
 }
 
 
-/// Fresh Postgres database plus a throwaway Redis for one test.
+/// Fresh Postgres database plus a Redis connection for one test, both on the
+/// shared compose services.
 ///
 ///
-/// The guard removes the Redis container synchronously when the test drops
-/// it; the Postgres container is owned by the shared `db` lease. AOF is on
-/// because the assertion-`jti` replay markers are durability-barriered.
-async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
+/// The Postgres database is owned by the shared `db` lease; Redis keys live
+/// under a per-test unique prefix pair so parallel tests never collide. AOF is
+/// on because the assertion-`jti` replay markers are durability-barriered.
+async fn harness() -> Result<Harness, TestError> {
   let (_db, pool) = crate::db::test_support::migrated_pool()
   let (_db, pool) = crate::db::test_support::migrated_pool()
     .await
     .await
     .map_err(|error| TestError::Unexpected(format!("{error}")))?;
     .map_err(|error| TestError::Unexpected(format!("{error}")))?;
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .with_cmd(["redis-server", "--appendonly", "yes"])
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  let url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(url.as_str())?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
-  let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
-    DurabilityMode::WaitAof,
-  );
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let store = Store::from_parts(state_prefix, rl_prefix, DurabilityMode::WaitAof);
   let h = Harness {
   let h = Harness {
     _db,
     _db,
     pool,
     pool,
@@ -120,7 +101,7 @@ async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
     )
     )
     .map_err(|error| TestError::Crypto(format!("{error:?}")))?,
     .map_err(|error| TestError::Crypto(format!("{error:?}")))?,
   };
   };
-  Ok((guarded, h))
+  Ok(h)
 }
 }
 
 
 /// Borrow datastore handles out of a harness.
 /// Borrow datastore handles out of a harness.

+ 4 - 4
src/protocol/client_auth/tests_containers/assertions.rs

@@ -11,7 +11,7 @@ use crate::protocol::client_auth::authenticate_client;
 
 
 #[tokio::test]
 #[tokio::test]
 async fn client_auth_assertion_wrong_aud_rejected() -> TestResult {
 async fn client_auth_assertion_wrong_aud_rejected() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-ct-wrong-aud";
   let client_id = "client-auth-ct-wrong-aud";
   let key = ContainerKey::generate("k1")?;
   let key = ContainerKey::generate("k1")?;
@@ -35,7 +35,7 @@ async fn client_auth_assertion_wrong_aud_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn client_auth_assertion_replayed_jti_rejected() -> TestResult {
 async fn client_auth_assertion_replayed_jti_rejected() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-ct-replay";
   let client_id = "client-auth-ct-replay";
   let key = ContainerKey::generate("k1")?;
   let key = ContainerKey::generate("k1")?;
@@ -97,7 +97,7 @@ fn client_auth_assertion_aud_array_membership() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn client_auth_jwks_uri_private_ip_rejected() -> TestResult {
 async fn client_auth_jwks_uri_private_ip_rejected() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-ct-ssrf";
   let client_id = "client-auth-ct-ssrf";
   insert_client(
   insert_client(
@@ -122,7 +122,7 @@ async fn client_auth_jwks_uri_private_ip_rejected() -> TestResult {
 /// plaintext); a corrupt `jwks_uri_enc` fails as 503 `PiiDecrypt`.
 /// plaintext); a corrupt `jwks_uri_enc` fails as 503 `PiiDecrypt`.
 #[tokio::test]
 #[tokio::test]
 async fn private_key_jwt_jwks_uri_fetch() -> TestResult {
 async fn private_key_jwt_jwks_uri_fetch() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-ct-jwks-fetch";
   let client_id = "client-auth-ct-jwks-fetch";
   insert_client(
   insert_client(

+ 8 - 8
src/protocol/client_auth/tests_containers/secrets.rs

@@ -11,7 +11,7 @@ use crate::protocol::client_auth::authenticate_client;
 
 
 #[tokio::test]
 #[tokio::test]
 async fn client_auth_basic_valid() -> TestResult {
 async fn client_auth_basic_valid() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-basic-valid";
   let client_id = "client-auth-basic-valid";
   insert_client(
   insert_client(
@@ -43,7 +43,7 @@ async fn client_auth_basic_valid() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn client_auth_basic_wrong_secret_is_401() -> TestResult {
 async fn client_auth_basic_wrong_secret_is_401() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-basic-wrong";
   let client_id = "client-auth-basic-wrong";
   insert_client(
   insert_client(
@@ -71,7 +71,7 @@ async fn client_auth_basic_wrong_secret_is_401() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn client_auth_post_valid() -> TestResult {
 async fn client_auth_post_valid() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-post-valid";
   let client_id = "client-auth-post-valid";
   insert_client(
   insert_client(
@@ -96,7 +96,7 @@ async fn client_auth_post_valid() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn client_auth_none_on_confidential_rejected() -> TestResult {
 async fn client_auth_none_on_confidential_rejected() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-none-on-conf";
   let client_id = "client-auth-none-on-conf";
   insert_client(
   insert_client(
@@ -125,7 +125,7 @@ async fn client_auth_none_on_confidential_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn client_auth_none_on_public_accepted() -> TestResult {
 async fn client_auth_none_on_public_accepted() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-none-public";
   let client_id = "client-auth-none-public";
   insert_client(
   insert_client(
@@ -153,7 +153,7 @@ async fn client_auth_none_on_public_accepted() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn client_auth_expired_secret_rejected() -> TestResult {
 async fn client_auth_expired_secret_rejected() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-expired-secret";
   let client_id = "client-auth-expired-secret";
   insert_client(
   insert_client(
@@ -175,7 +175,7 @@ async fn client_auth_expired_secret_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn client_auth_unknown_client_rejected() -> TestResult {
 async fn client_auth_unknown_client_rejected() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let req = post_request("client-auth-no-such-client", SecretString::from("whatever"));
   let req = post_request("client-auth-no-such-client", SecretString::from("whatever"));
   match authenticate_client(&mut stores_of(&mut h), &ctx_of(&endpoint), &req).await {
   match authenticate_client(&mut stores_of(&mut h), &ctx_of(&endpoint), &req).await {
@@ -186,7 +186,7 @@ async fn client_auth_unknown_client_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn client_auth_multiple_methods_rejected() -> TestResult {
 async fn client_auth_multiple_methods_rejected() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-multi-method";
   let client_id = "client-auth-multi-method";
   insert_client(
   insert_client(

+ 66 - 45
src/protocol/code/tests.rs

@@ -3,12 +3,8 @@
 
 
 use std::{collections::HashMap, time::Duration};
 use std::{collections::HashMap, time::Duration};
 
 
+use redis::aio::ConnectionManager;
 use secrecy::SecretString;
 use secrecy::SecretString;
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 
 
 use super::{CODE_TTL_CAP, CodeClientKind, CodeIssueError, CodeIssueRequest, CodeIssuer};
 use super::{CODE_TTL_CAP, CodeClientKind, CodeIssueError, CodeIssueRequest, CodeIssuer};
 use crate::{
 use crate::{
@@ -17,7 +13,6 @@ use crate::{
   protocol::scope::ScopeSet,
   protocol::scope::ScopeSet,
   state::RedisConn,
   state::RedisConn,
   store::{Store, StoreError, codes},
   store::{Store, StoreError, codes},
-  test_guard::ContainerGuard,
 };
 };
 
 
 /// Test body result (no `unwrap`/`expect` under the deny lints).
 /// Test body result (no `unwrap`/`expect` under the deny lints).
@@ -26,9 +21,6 @@ type TestResult = Result<(), TestError>;
 /// Errors raised by the code-issuance test scaffolding itself.
 /// Errors raised by the code-issuance test scaffolding itself.
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 enum TestError {
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// The Redis configuration was rejected.
   /// The Redis configuration was rejected.
   #[error("config error: {0}")]
   #[error("config error: {0}")]
   Config(#[from] crate::config::ConfigError),
   Config(#[from] crate::config::ConfigError),
@@ -65,31 +57,20 @@ fn test_ring() -> Result<KeyRing, TestError> {
   )?)
   )?)
 }
 }
 
 
-/// Start a single-node Redis 8 with AOF on.
-async fn redis_container() -> Result<(ContainerGuard, String), TestError> {
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .with_cmd(vec!["redis-server", "--appendonly", "yes"])
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  Ok((guarded, format!("redis://127.0.0.1:{port}")))
-}
-
-/// Connect a [`RedisConn`] plus [`Store`] directly against a live container.
-async fn direct_store(url: &str) -> Result<(RedisConn, Store), TestError> {
-  let client = redis::Client::open(url)?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+/// Connect a [`RedisConn`] plus [`Store`] to the shared compose Redis, scoped
+/// to a per-test unique prefix pair so parallel tests never share a keyspace.
+async fn direct_store(
+  manager: ConnectionManager,
+  token_prefix: &str,
+  ratelimit_prefix: &str,
+) -> Result<(RedisConn, Store), TestError> {
   let raw = RawRedisConfig {
   let raw = RawRedisConfig {
-    url: Some(url.to_owned()),
+    url: Some(crate::test_support::test_redis_url()),
     durability: Some(DurabilityMode::WaitAof),
     durability: Some(DurabilityMode::WaitAof),
     response_timeout_secs: 10,
     response_timeout_secs: 10,
     connect_timeout_secs: 5,
     connect_timeout_secs: 5,
+    token_key_prefix: Some(token_prefix.to_owned()),
+    ratelimit_key_prefix: Some(ratelimit_prefix.to_owned()),
     ..Default::default()
     ..Default::default()
   };
   };
   let config = crate::config::RedisConfig::from_raw(raw)?;
   let config = crate::config::RedisConfig::from_raw(raw)?;
@@ -134,9 +115,14 @@ fn query_pairs(location: &str) -> Result<HashMap<String, String>, TestError> {
   Ok(out)
   Ok(out)
 }
 }
 
 
-/// Whether any key or hash field in the current database contains `needle`.
-async fn keyspace_contains(conn: &mut RedisConn, needle: &str) -> Result<bool, TestError> {
-  let keys: Vec<String> = redis::cmd("KEYS").arg("*").query_async(conn).await?;
+/// Whether any key or hash field under `state_prefix` contains `needle`.
+async fn keyspace_contains(
+  conn: &mut RedisConn,
+  state_prefix: &str,
+  needle: &str,
+) -> Result<bool, TestError> {
+  let pattern = format!("{state_prefix}*");
+  let keys: Vec<String> = redis::cmd("KEYS").arg(&pattern).query_async(conn).await?;
   for key in &keys {
   for key in &keys {
     if key.contains(needle) {
     if key.contains(needle) {
       return Ok(true);
       return Ok(true);
@@ -159,8 +145,15 @@ async fn keyspace_contains(conn: &mut RedisConn, needle: &str) -> Result<bool, T
 
 
 #[tokio::test]
 #[tokio::test]
 async fn code_issue_redirect_carries_code_state_iss() -> TestResult {
 async fn code_issue_redirect_carries_code_state_iss() -> TestResult {
-  let (_guard, url) = redis_container().await?;
-  let (mut conn, store) = direct_store(&url).await?;
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let (mut conn, store) = direct_store(
+    crate::test_support::redis_manager()
+      .await
+      .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?,
+    &state_prefix,
+    &ratelimit_prefix,
+  )
+  .await?;
   let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
   let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
   let approved = ScopeSet::parse("api:read")?;
   let approved = ScopeSet::parse("api:read")?;
   let entitled = ScopeSet::parse("api:read api:admin")?;
   let entitled = ScopeSet::parse("api:read api:admin")?;
@@ -218,8 +211,15 @@ async fn code_issue_redirect_carries_code_state_iss() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn code_issue_stores_only_hash_with_bounded_ttl() -> TestResult {
 async fn code_issue_stores_only_hash_with_bounded_ttl() -> TestResult {
-  let (_guard, url) = redis_container().await?;
-  let (mut conn, store) = direct_store(&url).await?;
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let (mut conn, store) = direct_store(
+    crate::test_support::redis_manager()
+      .await
+      .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?,
+    &state_prefix,
+    &ratelimit_prefix,
+  )
+  .await?;
   let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
   let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
   let approved = ScopeSet::parse("api:read")?;
   let approved = ScopeSet::parse("api:read")?;
   let entitled = ScopeSet::parse("api:read")?;
   let entitled = ScopeSet::parse("api:read")?;
@@ -242,7 +242,7 @@ async fn code_issue_stores_only_hash_with_bounded_ttl() -> TestResult {
       "storage identity must be a hash, not the code".to_owned(),
       "storage identity must be a hash, not the code".to_owned(),
     ));
     ));
   }
   }
-  if keyspace_contains(&mut conn, &issued.code).await? {
+  if keyspace_contains(&mut conn, &state_prefix, &issued.code).await? {
     return Err(TestError::Unexpected(
     return Err(TestError::Unexpected(
       "raw code must never appear in Redis".to_owned(),
       "raw code must never appear in Redis".to_owned(),
     ));
     ));
@@ -280,8 +280,15 @@ async fn code_issue_stores_only_hash_with_bounded_ttl() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn code_issue_rejects_unentitled_approval() -> TestResult {
 async fn code_issue_rejects_unentitled_approval() -> TestResult {
-  let (_guard, url) = redis_container().await?;
-  let (mut conn, store) = direct_store(&url).await?;
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let (mut conn, store) = direct_store(
+    crate::test_support::redis_manager()
+      .await
+      .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?,
+    &state_prefix,
+    &ratelimit_prefix,
+  )
+  .await?;
   let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
   let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
   let approved = ScopeSet::parse("api:read api:admin")?;
   let approved = ScopeSet::parse("api:read api:admin")?;
   let entitled = ScopeSet::parse("api:read")?;
   let entitled = ScopeSet::parse("api:read")?;
@@ -304,7 +311,7 @@ async fn code_issue_rejects_unentitled_approval() -> TestResult {
       "approval wider than entitlement must be rejected".to_owned(),
       "approval wider than entitlement must be rejected".to_owned(),
     ));
     ));
   }
   }
-  if keyspace_contains(&mut conn, "client-abc").await? {
+  if keyspace_contains(&mut conn, &state_prefix, "client-abc").await? {
     return Err(TestError::Unexpected(
     return Err(TestError::Unexpected(
       "rejected issuance must store nothing".to_owned(),
       "rejected issuance must store nothing".to_owned(),
     ));
     ));
@@ -314,8 +321,15 @@ async fn code_issue_rejects_unentitled_approval() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn code_issue_public_requires_dpop_jkt() -> TestResult {
 async fn code_issue_public_requires_dpop_jkt() -> TestResult {
-  let (_guard, url) = redis_container().await?;
-  let (mut conn, store) = direct_store(&url).await?;
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let (mut conn, store) = direct_store(
+    crate::test_support::redis_manager()
+      .await
+      .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?,
+    &state_prefix,
+    &ratelimit_prefix,
+  )
+  .await?;
   let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
   let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
   let approved = ScopeSet::parse("api:read")?;
   let approved = ScopeSet::parse("api:read")?;
   let entitled = ScopeSet::parse("api:read api:admin")?;
   let entitled = ScopeSet::parse("api:read api:admin")?;
@@ -415,8 +429,15 @@ fn code_issuer_debug_redacts_pepper() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn code_issue_debug_redacts_raw_code() -> TestResult {
 async fn code_issue_debug_redacts_raw_code() -> TestResult {
-  let (_guard, url) = redis_container().await?;
-  let (mut conn, store) = direct_store(&url).await?;
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let (mut conn, store) = direct_store(
+    crate::test_support::redis_manager()
+      .await
+      .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?,
+    &state_prefix,
+    &ratelimit_prefix,
+  )
+  .await?;
   let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
   let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
   let approved = ScopeSet::parse("api:read")?;
   let approved = ScopeSet::parse("api:read")?;
   let entitled = ScopeSet::parse("api:read")?;
   let entitled = ScopeSet::parse("api:read")?;

+ 48 - 42
src/protocol/dcr/manage/support.rs

@@ -1,17 +1,12 @@
-//! Shared harness for the `dcr_manage` container suites.
+//! Shared harness for the `dcr_manage` suites.
 //!
 //!
-//! Real Postgres plus a throwaway Redis per test; clients are created through
-//! the production [`register_client`](super::super::register_client) path so
-//! every management test starts from a genuinely registered client.
+//! Real Postgres plus the shared compose Redis per test; clients are created
+//! through the production [`register_client`](super::super::register_client)
+//! path so every management test starts from a genuinely registered client.
 
 
 use std::time::Duration;
 use std::time::Duration;
 
 
 use sqlx::PgPool;
 use sqlx::PgPool;
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 use uuid::Uuid;
 use uuid::Uuid;
 
 
@@ -26,15 +21,12 @@ use crate::{
   error::{AppError, OAuthErrorCode},
   error::{AppError, OAuthErrorCode},
   state::RedisConn,
   state::RedisConn,
   store::{Store, dcr as dcr_store},
   store::{Store, dcr as dcr_store},
-  test_guard::{ContainerGuard, Lease},
+  test_guard::Lease,
 };
 };
 
 
 /// This suite's error type (no `unwrap`/`expect` under the deny lints).
 /// This suite's error type (no `unwrap`/`expect` under the deny lints).
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 pub(super) enum TestError {
 pub(super) enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   /// A Redis command failed.
   #[error("redis error: {0}")]
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
@@ -80,45 +72,59 @@ pub(super) struct Registered {
   pub(super) reg_token: String,
   pub(super) reg_token: String,
 }
 }
 
 
-/// Fresh Postgres database plus a throwaway Redis for one test.
-pub(super) async fn harness(policy: &str) -> Result<(ContainerGuard, Harness), TestError> {
-  let (_db, pool) = crate::db::test_support::migrated_pool()
+/// Fresh Postgres database plus the shared compose Redis for one test.
+///
+/// Postgres comes from the shared `db` lease; Redis comes from the shared
+/// compose service, isolated per test by a unique prefix pair.
+pub(super) async fn harness(policy: &str) -> Result<Harness, TestError> {
+  let manager = crate::test_support::redis_manager()
     .await
     .await
-    .map_err(|error| TestError::Unexpected(format!("{error}")))?;
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .with_cmd(["redis-server", "--appendonly", "yes"])
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  let redis_url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(redis_url.as_str())?;
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  finish_harness(policy, manager).await
+}
+
+/// Fresh Postgres database plus the Redis at `redis_url` for one test.
+///
+/// Container-free: the caller owns whatever backs `redis_url`. The suite's
+/// Redis-loss test (`tests_delete.rs`, keep-listed) builds a private
+/// container inline, keeps its guard alive for the test, and passes the
+/// container's URL here so its full wipe never touches the shared service.
+/// Every other test uses [`harness`].
+pub(super) async fn harness_with_redis(
+  redis_url: &str,
+  policy: &str,
+) -> Result<Harness, TestError> {
+  let client = redis::Client::open(redis_url)?;
   let manager_config = redis::aio::ConnectionManagerConfig::new()
   let manager_config = redis::aio::ConnectionManagerConfig::new()
     .set_response_timeout(Some(Duration::from_secs(10)))
     .set_response_timeout(Some(Duration::from_secs(10)))
     .set_connection_timeout(Some(Duration::from_secs(5)));
     .set_connection_timeout(Some(Duration::from_secs(5)));
   let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
   let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+  finish_harness(policy, manager).await
+}
+
+/// Shared tail of [`harness`] and [`harness_with_redis`]: fresh Postgres,
+/// per-test unique prefixes, and the fixture config over dummy URLs.
+async fn finish_harness(
+  policy: &str,
+  manager: redis::aio::ConnectionManager,
+) -> Result<Harness, TestError> {
+  let (_db, pool) = crate::db::test_support::migrated_pool()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("{error}")))?;
   let config = test_config(
   let config = test_config(
     "postgres://postgres:dummy@127.0.0.1:1/postgres?sslmode=disable",
     "postgres://postgres:dummy@127.0.0.1:1/postgres?sslmode=disable",
     "redis://127.0.0.1:1",
     "redis://127.0.0.1:1",
     policy,
     policy,
   )?;
   )?;
-  let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
-    DurabilityMode::WaitAof,
-  );
-  Ok((
-    guarded,
-    Harness {
-      _db,
-      pool,
-      conn: RedisConn::Single(manager),
-      store,
-      config,
-    },
-  ))
+  let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let store = Store::from_parts(state_prefix, rl_prefix, DurabilityMode::WaitAof);
+  Ok(Harness {
+    _db,
+    pool,
+    conn: RedisConn::Single(manager),
+    store,
+    config,
+  })
 }
 }
 
 
 /// Build a validated [`Config`] over fixture URLs.
 /// Build a validated [`Config`] over fixture URLs.

+ 7 - 7
src/protocol/dcr/manage/tests.rs

@@ -9,7 +9,7 @@ use crate::db::client_repo;
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_read_returns_metadata() -> TestResult {
 async fn dcr_manage_read_returns_metadata() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", Some("read")).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", Some("read")).await?;
 
 
   let response = {
   let response = {
@@ -80,7 +80,7 @@ async fn dcr_manage_read_returns_metadata() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_get() -> TestResult {
 async fn dcr_manage_get() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", Some("read")).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", Some("read")).await?;
 
 
   let response = {
   let response = {
@@ -129,7 +129,7 @@ async fn dcr_manage_get() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_read_missing_token_401() -> TestResult {
 async fn dcr_manage_read_missing_token_401() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
 
 
   let (mut stores, ctx) = manage_parts(&mut harness);
   let (mut stores, ctx) = manage_parts(&mut harness);
@@ -143,7 +143,7 @@ async fn dcr_manage_read_missing_token_401() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_read_unknown_client_401() -> TestResult {
 async fn dcr_manage_read_unknown_client_401() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
 
 
   let (mut stores, ctx) = manage_parts(&mut harness);
   let (mut stores, ctx) = manage_parts(&mut harness);
@@ -164,7 +164,7 @@ async fn dcr_manage_read_unknown_client_401() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_read_wrong_token_403() -> TestResult {
 async fn dcr_manage_read_wrong_token_403() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
 
 
   let (mut stores, ctx) = manage_parts(&mut harness);
   let (mut stores, ctx) = manage_parts(&mut harness);
@@ -218,7 +218,7 @@ async fn dcr_manage_register_request_uris_stored_verbatim() -> TestResult {
   // (docs/OAUTH.md "JAR and PAR", docs/SECURITY.md "PAR and JAR": https only,
   // (docs/OAUTH.md "JAR and PAR", docs/SECURITY.md "PAR and JAR": https only,
   // no redirects, global IPs only). A plaintext scheme, a loopback literal,
   // no redirects, global IPs only). A plaintext scheme, a loopback literal,
   // and a non-https scheme must therefore register cleanly and stay inert.
   // and a non-https scheme must therefore register cleanly and stay inert.
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let redirect = "https://app.example.test/cb";
   let redirect = "https://app.example.test/cb";
   seed_redirect(&harness.pool, &harness.config.keys, redirect).await?;
   seed_redirect(&harness.pool, &harness.config.keys, redirect).await?;
   let request_uris = serde_json::json!([
   let request_uris = serde_json::json!([
@@ -261,7 +261,7 @@ async fn dcr_manage_register_request_uris_stored_verbatim() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_update_bad_redirect_rejected() -> TestResult {
 async fn dcr_manage_update_bad_redirect_rejected() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
 
 
   let request = crate::protocol::dcr::RegisterRequest {
   let request = crate::protocol::dcr::RegisterRequest {

+ 32 - 8
src/protocol/dcr/manage/tests_delete.rs

@@ -3,11 +3,17 @@
 
 
 use uuid::Uuid;
 use uuid::Uuid;
 
 
+use testcontainers::{
+  GenericImage, ImageExt as _,
+  core::{IntoContainerPort, WaitFor},
+  runners::AsyncRunner,
+};
+
 use super::delete::delete_client;
 use super::delete::delete_client;
 use super::read_client;
 use super::read_client;
 use super::support::{
 use super::support::{
   Harness, TestError, TestResult, expect_app_error, expect_forbidden, expect_unauthorized, harness,
   Harness, TestError, TestResult, expect_app_error, expect_forbidden, expect_unauthorized, harness,
-  manage_parts, mtls_request, register, register_mtls, try_register,
+  harness_with_redis, manage_parts, mtls_request, register, register_mtls, try_register,
 };
 };
 use crate::db::client_repo::{self, NewClient};
 use crate::db::client_repo::{self, NewClient};
 use crate::error::OAuthErrorCode;
 use crate::error::OAuthErrorCode;
@@ -15,7 +21,7 @@ use crate::protocol::token::{ClientKind, MintRequest, SenderProof, TokenService}
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_cross_client_forbidden() -> TestResult {
 async fn dcr_manage_cross_client_forbidden() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let first = register(&mut harness, "https://app.example.test/a", None).await?;
   let first = register(&mut harness, "https://app.example.test/a", None).await?;
   let second = register(&mut harness, "https://app.example.test/b", None).await?;
   let second = register(&mut harness, "https://app.example.test/b", None).await?;
 
 
@@ -86,7 +92,7 @@ async fn dcr_manage_cross_client_forbidden() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_delete_deactivates() -> TestResult {
 async fn dcr_manage_delete_deactivates() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
 
 
   {
   {
@@ -142,7 +148,7 @@ async fn dcr_manage_delete_deactivates() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_delete_revokes_tokens() -> TestResult {
 async fn dcr_manage_delete_revokes_tokens() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let other = register(&mut harness, "https://app.example.test/other", None).await?;
   let other = register(&mut harness, "https://app.example.test/other", None).await?;
 
 
@@ -231,7 +237,7 @@ async fn dcr_manage_delete_token_denials_match_read() -> TestResult {
   // client answers 401, a mismatched credential on an existing client answers
   // client answers 401, a mismatched credential on an existing client answers
   // 403. A successful DELETE invalidates the token, so replaying it answers
   // 403. A successful DELETE invalidates the token, so replaying it answers
   // 401 (the deactivated row no longer authorizes management).
   // 401 (the deactivated row no longer authorizes management).
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
 
 
   {
   {
@@ -377,7 +383,7 @@ async fn dcr_manage_delete_subject_dn_stays_reserved() -> TestResult {
   // docs/DATABASE.md "oauth_clients" and its edge cases). Re-registering the
   // docs/DATABASE.md "oauth_clients" and its edge cases). Re-registering the
   // same subject DN must therefore stay a typed 400 invalid_client_metadata
   // same subject DN must therefore stay a typed 400 invalid_client_metadata
   // duplicate, while a different DN is unaffected.
   // duplicate, while a different DN is unaffected.
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let redirect = "https://app.example.test/cb";
   let redirect = "https://app.example.test/cb";
   let subject_dn = "CN=reserved-dn, O=Example";
   let subject_dn = "CN=reserved-dn, O=Example";
   let first = register_mtls(&mut harness, redirect, subject_dn).await?;
   let first = register_mtls(&mut harness, redirect, subject_dn).await?;
@@ -438,7 +444,7 @@ async fn dcr_manage_delete_thumbprint_stays_reserved() -> TestResult {
   // thumbprint is a partial unique index over every non-NULL value with no
   // thumbprint is a partial unique index over every non-NULL value with no
   // `active` predicate (docs/DATABASE.md "oauth_clients"), so deactivation
   // `active` predicate (docs/DATABASE.md "oauth_clients"), so deactivation
   // must not free it. The row is driven through the real management DELETE.
   // must not free it. The row is driven through the real management DELETE.
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let thumbprint = "x5t-s256-manage-thumbprint-fixture";
   let thumbprint = "x5t-s256-manage-thumbprint-fixture";
 
 
   let raw = crate::crypto::tokens::mint_token()
   let raw = crate::crypto::tokens::mint_token()
@@ -483,7 +489,25 @@ async fn dcr_manage_delete_thumbprint_stays_reserved() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_token_survives_redis_flush() -> TestResult {
 async fn dcr_manage_token_survives_redis_flush() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  // Private-container test: its purpose is a full Redis wipe (`FLUSHALL`),
+  // which must never touch the shared compose Redis, and DCR state is
+  // Postgres-backed, so a prefix-scoped `SCAN`+`DEL` would be a vacuous
+  // no-op. The harness is built via the container-free `harness_with_redis`
+  // over this container's URL (`_private` keeps it alive for the test).
+  let container = GenericImage::new("redis", "8-alpine")
+    .with_exposed_port(6379.tcp())
+    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
+    .with_cmd(["redis-server", "--appendonly", "yes"])
+    .start()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("private redis: {error}")))?;
+  let guarded = crate::test_guard::ContainerGuard::new(container);
+  let port = guarded
+    .get_host_port_ipv4(6379)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("private redis port: {error}")))?;
+  let _private = guarded;
+  let mut harness = harness_with_redis(&format!("redis://127.0.0.1:{port}"), "catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
 
 
   {
   {

+ 9 - 9
src/protocol/dcr/manage/tests_update.rs

@@ -48,7 +48,7 @@ async fn dcr_manage_update_rotated_token_forbidden_on_put_and_delete() -> TestRe
   // RFC 7592 §2.2 and Appendix A.1: a successful PUT issues a new registration
   // RFC 7592 §2.2 and Appendix A.1: a successful PUT issues a new registration
   // access token and invalidates the previous one. The old token must fail for
   // access token and invalidates the previous one. The old token must fail for
   // every management verb, not only the read the existing suite covers.
   // every management verb, not only the read the existing suite covers.
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
 
 
   let first_put = put_request(
   let first_put = put_request(
@@ -155,7 +155,7 @@ async fn dcr_manage_update_token_denials_match_read() -> TestResult {
   // same contract as GET. Registration tokens carry no expiry field in the
   // same contract as GET. Registration tokens carry no expiry field in the
   // schema, so revocation (a successful DELETE that deactivates the client)
   // schema, so revocation (a successful DELETE that deactivates the client)
   // is the only invalidation path besides rotation.
   // is the only invalidation path besides rotation.
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let request = put_request(
   let request = put_request(
     vec!["https://app.example.test/cb".to_owned()],
     vec!["https://app.example.test/cb".to_owned()],
@@ -249,7 +249,7 @@ async fn dcr_manage_update_rejects_jwks_and_jwks_uri_together() -> TestResult {
   // RFC 7591 §2: "The jwks_uri and jwks parameters MUST NOT both be present in
   // RFC 7591 §2: "The jwks_uri and jwks parameters MUST NOT both be present in
   // the same request or response." The PUT validation must reject the pair
   // the same request or response." The PUT validation must reject the pair
   // before any write, so the client and its registration token stay intact.
   // before any write, so the client and its registration token stay intact.
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let mut request = put_request(
   let mut request = put_request(
     vec!["https://app.example.test/cb".to_owned()],
     vec!["https://app.example.test/cb".to_owned()],
@@ -320,7 +320,7 @@ async fn dcr_manage_update_request_uris_stored_verbatim() -> TestResult {
   // fetch-time enforcement point (docs/OAUTH.md "JAR and PAR",
   // fetch-time enforcement point (docs/OAUTH.md "JAR and PAR",
   // docs/SECURITY.md "PAR and JAR"). Full-replace semantics must persist the
   // docs/SECURITY.md "PAR and JAR"). Full-replace semantics must persist the
   // array exactly, including plaintext, loopback, and non-https values.
   // array exactly, including plaintext, loopback, and non-https values.
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let request_uris = serde_json::json!([
   let request_uris = serde_json::json!([
     "http://app.example.test/req.jwt",
     "http://app.example.test/req.jwt",
@@ -376,7 +376,7 @@ async fn dcr_manage_update_request_uris_stored_verbatim() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_update_full_replace_rotates() -> TestResult {
 async fn dcr_manage_update_full_replace_rotates() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", Some("read")).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", Some("read")).await?;
   seed_redirect(
   seed_redirect(
     &harness.pool,
     &harness.pool,
@@ -499,7 +499,7 @@ async fn dcr_manage_update_full_replace_rotates() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_update_replaces_redirect_attachments() -> TestResult {
 async fn dcr_manage_update_replaces_redirect_attachments() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/a", None).await?;
   let registered = register(&mut harness, "https://app.example.test/a", None).await?;
   seed_redirect(
   seed_redirect(
     &harness.pool,
     &harness.pool,
@@ -570,7 +570,7 @@ async fn dcr_manage_update_replaces_redirect_attachments() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_update_omitted_fields_clear() -> TestResult {
 async fn dcr_manage_update_omitted_fields_clear() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", Some("read")).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", Some("read")).await?;
 
 
   let request = put_request(vec!["https://app.example.test/cb".to_owned()], None, None);
   let request = put_request(vec!["https://app.example.test/cb".to_owned()], None, None);
@@ -611,7 +611,7 @@ async fn dcr_manage_update_omitted_fields_clear() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_update_rename() -> TestResult {
 async fn dcr_manage_update_rename() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
   let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
 
 
   let request = put_request(
   let request = put_request(
@@ -656,7 +656,7 @@ async fn dcr_manage_update_rename() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_update_same_mtls_identity_ok() -> TestResult {
 async fn dcr_manage_update_same_mtls_identity_ok() -> TestResult {
-  let (_guard, mut harness) = harness("catalog").await?;
+  let mut harness = harness("catalog").await?;
   let subject_dn = "CN=manage-mtls, O=Example";
   let subject_dn = "CN=manage-mtls, O=Example";
   let registered = register_mtls(&mut harness, "https://app.example.test/cb", subject_dn).await?;
   let registered = register_mtls(&mut harness, "https://app.example.test/cb", subject_dn).await?;
 
 

+ 36 - 57
src/protocol/dcr/tests.rs

@@ -5,11 +5,6 @@
 use std::time::Duration;
 use std::time::Duration;
 
 
 use sqlx::PgPool;
 use sqlx::PgPool;
-use testcontainers::{
-  GenericImage,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 use uuid::Uuid;
 use uuid::Uuid;
 
 
@@ -24,7 +19,7 @@ use crate::{
   error::{AppError, OAuthErrorCode},
   error::{AppError, OAuthErrorCode},
   state::RedisConn,
   state::RedisConn,
   store::{Store, dcr as dcr_store},
   store::{Store, dcr as dcr_store},
-  test_guard::{ContainerGuard, Lease},
+  test_guard::Lease,
 };
 };
 
 
 /// This suite's error type (no `unwrap`/`expect` under the deny lints).
 /// This suite's error type (no `unwrap`/`expect` under the deny lints).
@@ -33,9 +28,6 @@ type TestResult = Result<(), TestError>;
 /// Test scaffolding failures.
 /// Test scaffolding failures.
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 enum TestError {
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   /// A Redis command failed.
   #[error("redis error: {0}")]
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
@@ -68,24 +60,17 @@ struct Harness {
   config: Config,
   config: Config,
 }
 }
 
 
-/// Fresh Postgres database plus a throwaway Redis for one test.
+/// Fresh Postgres database plus the shared compose Redis for one test.
 ///
 ///
-/// The guard removes the Redis container synchronously when the test drops
-/// it; the Postgres container is owned by the shared `db` lease.
-async fn harness(open: bool, policy: &str) -> Result<(ContainerGuard, Harness), TestError> {
+/// The Postgres container is owned by the shared `db` lease; Redis comes from
+/// the shared compose service, isolated per test by [`unique_prefixes`].
+async fn harness(open: bool, policy: &str) -> Result<Harness, TestError> {
   let (_db, pool) = crate::db::test_support::migrated_pool()
   let (_db, pool) = crate::db::test_support::migrated_pool()
     .await
     .await
     .map_err(|error| TestError::Unexpected(format!("{error}")))?;
     .map_err(|error| TestError::Unexpected(format!("{error}")))?;
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  let redis_url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(redis_url.as_str())?;
-  let manager = redis::aio::ConnectionManager::new(client).await?;
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
   // The config fixture is never connected through: the pool and Redis
   // The config fixture is never connected through: the pool and Redis
   // connection above are used directly, so its URLs only need to pass
   // connection above are used directly, so its URLs only need to pass
   // syntactic validation.
   // syntactic validation.
@@ -95,21 +80,15 @@ async fn harness(open: bool, policy: &str) -> Result<(ContainerGuard, Harness),
     open,
     open,
     policy,
     policy,
   )?;
   )?;
-  let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
-    DurabilityMode::WaitAof,
-  );
-  Ok((
-    guarded,
-    Harness {
-      _db,
-      pool,
-      conn: RedisConn::Single(manager),
-      store,
-      config,
-    },
-  ))
+  let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let store = Store::from_parts(state_prefix, rl_prefix, DurabilityMode::WaitAof);
+  Ok(Harness {
+    _db,
+    pool,
+    conn: RedisConn::Single(manager),
+    store,
+    config,
+  })
 }
 }
 
 
 /// Build a validated [`Config`] over fixture URLs.
 /// Build a validated [`Config`] over fixture URLs.
@@ -236,7 +215,7 @@ fn expect_error(error: AppError, code: OAuthErrorCode, status: u16) -> TestResul
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_valid_201() -> TestResult {
 async fn dcr_register_valid_201() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   seed_redirect(
   seed_redirect(
     &harness.pool,
     &harness.pool,
     &harness.config.keys,
     &harness.config.keys,
@@ -326,7 +305,7 @@ async fn dcr_register_valid_201() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_round_trip() -> TestResult {
 async fn dcr_register_round_trip() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   seed_redirect(
   seed_redirect(
     &harness.pool,
     &harness.pool,
     &harness.config.keys,
     &harness.config.keys,
@@ -414,7 +393,7 @@ async fn dcr_register_round_trip() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_duplicate_mtls_identity_is_400() -> TestResult {
 async fn dcr_register_duplicate_mtls_identity_is_400() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   seed_redirect(
   seed_redirect(
     &harness.pool,
     &harness.pool,
     &harness.config.keys,
     &harness.config.keys,
@@ -488,7 +467,7 @@ async fn dcr_register_duplicate_mtls_identity_is_400() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_missing_iat_401() -> TestResult {
 async fn dcr_register_missing_iat_401() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   let request = dpop_request("https://app.example.test/cb", None);
   let request = dpop_request("https://app.example.test/cb", None);
   let (mut stores, ctx) = parts_of(&mut harness);
   let (mut stores, ctx) = parts_of(&mut harness);
   match register_client(&mut stores, &ctx, None, &request).await {
   match register_client(&mut stores, &ctx, None, &request).await {
@@ -501,7 +480,7 @@ async fn dcr_register_missing_iat_401() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_invalid_iat_401() -> TestResult {
 async fn dcr_register_invalid_iat_401() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   let request = dpop_request("https://app.example.test/cb", None);
   let request = dpop_request("https://app.example.test/cb", None);
   let (mut stores, ctx) = parts_of(&mut harness);
   let (mut stores, ctx) = parts_of(&mut harness);
   match register_client(&mut stores, &ctx, Some("no-such-token"), &request).await {
   match register_client(&mut stores, &ctx, Some("no-such-token"), &request).await {
@@ -514,7 +493,7 @@ async fn dcr_register_invalid_iat_401() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_bad_redirect_rejected() -> TestResult {
 async fn dcr_register_bad_redirect_rejected() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let request = dpop_request("https://evil.example.test/cb", None);
   let request = dpop_request("https://evil.example.test/cb", None);
   let (mut stores, ctx) = parts_of(&mut harness);
   let (mut stores, ctx) = parts_of(&mut harness);
@@ -528,7 +507,7 @@ async fn dcr_register_bad_redirect_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_bad_auth_method_rejected() -> TestResult {
 async fn dcr_register_bad_auth_method_rejected() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   seed_redirect(
   seed_redirect(
     &harness.pool,
     &harness.pool,
     &harness.config.keys,
     &harness.config.keys,
@@ -549,7 +528,7 @@ async fn dcr_register_bad_auth_method_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_bad_grant_rejected() -> TestResult {
 async fn dcr_register_bad_grant_rejected() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   seed_redirect(
   seed_redirect(
     &harness.pool,
     &harness.pool,
     &harness.config.keys,
     &harness.config.keys,
@@ -570,7 +549,7 @@ async fn dcr_register_bad_grant_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_bad_scope_rejected() -> TestResult {
 async fn dcr_register_bad_scope_rejected() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   seed_redirect(
   seed_redirect(
     &harness.pool,
     &harness.pool,
     &harness.config.keys,
     &harness.config.keys,
@@ -589,7 +568,7 @@ async fn dcr_register_bad_scope_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_confidential_without_binding_rejected() -> TestResult {
 async fn dcr_register_confidential_without_binding_rejected() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   seed_redirect(
   seed_redirect(
     &harness.pool,
     &harness.pool,
     &harness.config.keys,
     &harness.config.keys,
@@ -629,7 +608,7 @@ async fn dcr_register_confidential_without_binding_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_open_anonymous_201() -> TestResult {
 async fn dcr_register_open_anonymous_201() -> TestResult {
-  let (_guard, mut harness) = harness(true, "catalog_loopback").await?;
+  let mut harness = harness(true, "catalog_loopback").await?;
   let request = RegisterRequest {
   let request = RegisterRequest {
     redirect_uris: Some(vec!["http://127.0.0.1:6420/cb".to_owned()]),
     redirect_uris: Some(vec!["http://127.0.0.1:6420/cb".to_owned()]),
     client_name: Some("native app".to_owned()),
     client_name: Some("native app".to_owned()),
@@ -671,7 +650,7 @@ async fn dcr_register_open_anonymous_201() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_open_policy_rejects_plaintext_non_loopback() -> TestResult {
 async fn dcr_register_open_policy_rejects_plaintext_non_loopback() -> TestResult {
-  let (_guard, mut harness) = harness(false, "open").await?;
+  let mut harness = harness(false, "open").await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let request = dpop_request("http://evil.example.com/cb", None);
   let request = dpop_request("http://evil.example.com/cb", None);
   let (mut stores, ctx) = parts_of(&mut harness);
   let (mut stores, ctx) = parts_of(&mut harness);
@@ -685,7 +664,7 @@ async fn dcr_register_open_policy_rejects_plaintext_non_loopback() -> TestResult
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_open_policy_rejects_custom_scheme() -> TestResult {
 async fn dcr_register_open_policy_rejects_custom_scheme() -> TestResult {
-  let (_guard, mut harness) = harness(false, "open").await?;
+  let mut harness = harness(false, "open").await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let request = dpop_request("myapp://cb", None);
   let request = dpop_request("myapp://cb", None);
   let (mut stores, ctx) = parts_of(&mut harness);
   let (mut stores, ctx) = parts_of(&mut harness);
@@ -704,7 +683,7 @@ async fn dcr_catalog_policy_private_scheme_dot_rule() -> TestResult {
   // registers via its catalog hit, while the dotless scheme is rejected by
   // registers via its catalog hit, while the dotless scheme is rejected by
   // `is_valid_redirect_scheme` even though it is also seeded in the catalog
   // `is_valid_redirect_scheme` even though it is also seeded in the catalog
   // (so the failure cannot be a catalog miss).
   // (so the failure cannot be a catalog miss).
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   seed_redirect(&harness.pool, &harness.config.keys, "com.example.app:/cb").await?;
   seed_redirect(&harness.pool, &harness.config.keys, "com.example.app:/cb").await?;
   seed_redirect(&harness.pool, &harness.config.keys, "myapp:/cb").await?;
   seed_redirect(&harness.pool, &harness.config.keys, "myapp:/cb").await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let iat = issue_iat(&mut harness, 3).await?;
@@ -727,7 +706,7 @@ async fn dcr_catalog_policy_private_scheme_dot_rule() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_open_policy_rejects_fragment() -> TestResult {
 async fn dcr_register_open_policy_rejects_fragment() -> TestResult {
-  let (_guard, mut harness) = harness(false, "open").await?;
+  let mut harness = harness(false, "open").await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let request = dpop_request("https://app.example.com/cb?x=1#frag", None);
   let request = dpop_request("https://app.example.com/cb?x=1#frag", None);
   let (mut stores, ctx) = parts_of(&mut harness);
   let (mut stores, ctx) = parts_of(&mut harness);
@@ -741,7 +720,7 @@ async fn dcr_register_open_policy_rejects_fragment() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_open_policy_accepts_https() -> TestResult {
 async fn dcr_register_open_policy_accepts_https() -> TestResult {
-  let (_guard, mut harness) = harness(false, "open").await?;
+  let mut harness = harness(false, "open").await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let request = dpop_request("https://app.example.com/cb", None);
   let request = dpop_request("https://app.example.com/cb", None);
   let response = {
   let response = {
@@ -764,7 +743,7 @@ async fn dcr_register_open_policy_accepts_https() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_open_policy_accepts_loopback() -> TestResult {
 async fn dcr_register_open_policy_accepts_loopback() -> TestResult {
-  let (_guard, mut harness) = harness(false, "open").await?;
+  let mut harness = harness(false, "open").await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let request = dpop_request("http://127.0.0.1:1234/cb", None);
   let request = dpop_request("http://127.0.0.1:1234/cb", None);
   let response = {
   let response = {
@@ -787,7 +766,7 @@ async fn dcr_register_open_policy_accepts_loopback() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_mtls_client_201() -> TestResult {
 async fn dcr_register_mtls_client_201() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   seed_redirect(
   seed_redirect(
     &harness.pool,
     &harness.pool,
     &harness.config.keys,
     &harness.config.keys,
@@ -837,7 +816,7 @@ async fn dcr_register_mtls_client_201() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_jwks_xor_rejected() -> TestResult {
 async fn dcr_register_jwks_xor_rejected() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   seed_redirect(
   seed_redirect(
     &harness.pool,
     &harness.pool,
     &harness.config.keys,
     &harness.config.keys,

+ 32 - 51
src/protocol/device/tests.rs

@@ -1,22 +1,14 @@
 //! `test(device_authorize)` suite: issuance, approval, misses, brute-force cap.
 //! `test(device_authorize)` suite: issuance, approval, misses, brute-force cap.
 //!
 //!
-//! Each test runs against a real Redis container plus a migrated Postgres
+//! Each test runs against the shared compose Redis plus a migrated Postgres
 //! database on the shared test server, through the public [`super`] API.
 //! database on the shared test server, through the public [`super`] API.
 
 
-use std::{
-  sync::atomic::{AtomicU64, Ordering},
-  time::Duration,
-};
+use std::sync::atomic::{AtomicU64, Ordering};
 
 
 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
 use secrecy::SecretString;
 use secrecy::SecretString;
 use serde_json::json;
 use serde_json::json;
 use sqlx::PgPool;
 use sqlx::PgPool;
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 use uuid::Uuid;
 use uuid::Uuid;
 
 
@@ -32,7 +24,6 @@ use crate::{
   jose::jwk::thumbprint,
   jose::jwk::thumbprint,
   state::RedisConn,
   state::RedisConn,
   store::{self, Store},
   store::{self, Store},
-  test_guard::ContainerGuard,
 };
 };
 
 
 /// Test body result (no `unwrap`/`expect` under the deny lints).
 /// Test body result (no `unwrap`/`expect` under the deny lints).
@@ -41,9 +32,6 @@ type TestResult = Result<(), TestError>;
 /// Test scaffolding failures.
 /// Test scaffolding failures.
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 enum TestError {
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   /// A Redis command failed.
   #[error("redis error: {0}")]
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
@@ -89,35 +77,31 @@ struct Harness {
   config: Config,
   config: Config,
 }
 }
 
 
-async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
+async fn harness() -> Result<Harness, TestError> {
   let (db, pool) = crate::db::test_support::migrated_pool()
   let (db, pool) = crate::db::test_support::migrated_pool()
     .await
     .await
     .map_err(|error| TestError::Unexpected(format!("pg: {error}")))?;
     .map_err(|error| TestError::Unexpected(format!("pg: {error}")))?;
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .with_cmd(["redis-server", "--appendonly", "yes"])
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  let redis_url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(redis_url.as_str())?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("redis: {error}")))?;
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
   let store = Store::from_parts(
   let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    state_prefix.clone(),
+    ratelimit_prefix.clone(),
     crate::config::DurabilityMode::WaitAof,
     crate::config::DurabilityMode::WaitAof,
   );
   );
+  let redis_url = crate::test_support::test_redis_url();
   let fixture = json!({
   let fixture = json!({
     "issuer": "https://auth.example.test",
     "issuer": "https://auth.example.test",
     "environment": "development",
     "environment": "development",
     "tls": {"cert_path": "/tmp/device-test-cert.pem", "key_path": "/tmp/device-test-key.pem"},
     "tls": {"cert_path": "/tmp/device-test-cert.pem", "key_path": "/tmp/device-test-key.pem"},
     "database": {"url": "postgres://127.0.0.1:1/device"},
     "database": {"url": "postgres://127.0.0.1:1/device"},
-    "redis": {"url": redis_url, "durability": "waitaof"},
+    "redis": {
+      "url": redis_url,
+      "durability": "waitaof",
+      "token_key_prefix": state_prefix,
+      "ratelimit_key_prefix": ratelimit_prefix,
+    },
     "token_pepper": "device-test-pepper-0123456789abcdef",
     "token_pepper": "device-test-pepper-0123456789abcdef",
     "totp_encryption_key": "device-test-totp-key-0123456789abcdef",
     "totp_encryption_key": "device-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "device-test-pii-key-0123456789abcdef",
     "pii_encryption_key": "device-test-pii-key-0123456789abcdef",
@@ -127,16 +111,13 @@ async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
   let source = ::config::Config::try_from(&fixture)
   let source = ::config::Config::try_from(&fixture)
     .map_err(|error| TestError::Unexpected(format!("config source: {error}")))?;
     .map_err(|error| TestError::Unexpected(format!("config source: {error}")))?;
   let config = Config::from_source(source)?;
   let config = Config::from_source(source)?;
-  Ok((
-    guarded,
-    Harness {
-      _db: db,
-      pool,
-      conn: RedisConn::Single(manager),
-      store,
-      config,
-    },
-  ))
+  Ok(Harness {
+    _db: db,
+    pool,
+    conn: RedisConn::Single(manager),
+    store,
+    config,
+  })
 }
 }
 
 
 async fn insert_client(
 async fn insert_client(
@@ -255,7 +236,7 @@ async fn issue(
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_device_authorize_public_client_issues_code_and_interval() -> TestResult {
 async fn test_device_authorize_public_client_issues_code_and_interval() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("public");
   let client_id = unique("public");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -356,7 +337,7 @@ async fn test_device_authorize_public_client_issues_code_and_interval() -> TestR
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_device_authorize_confidential_client_secret_post() -> TestResult {
 async fn test_device_authorize_confidential_client_secret_post() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("conf");
   let client_id = unique("conf");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -415,7 +396,7 @@ async fn test_device_authorize_confidential_client_secret_post() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_device_authorize_public_client_without_dpop_rejected() -> TestResult {
 async fn test_device_authorize_public_client_without_dpop_rejected() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("nodpop");
   let client_id = unique("nodpop");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -451,7 +432,7 @@ async fn test_device_authorize_public_client_without_dpop_rejected() -> TestResu
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_device_authorize_over_registered_scope_rejected() -> TestResult {
 async fn test_device_authorize_over_registered_scope_rejected() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("scope");
   let client_id = unique("scope");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -490,7 +471,7 @@ async fn test_device_authorize_over_registered_scope_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_device_authorize_approval_transitions_state() -> TestResult {
 async fn test_device_authorize_approval_transitions_state() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("approve");
   let client_id = unique("approve");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -562,7 +543,7 @@ async fn test_device_authorize_approval_transitions_state() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_device_authorize_unknown_and_expired_user_code_rejected() -> TestResult {
 async fn test_device_authorize_unknown_and_expired_user_code_rejected() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
 
 
   // Unknown codes resolve to nothing.
   // Unknown codes resolve to nothing.
   let miss =
   let miss =
@@ -618,7 +599,7 @@ async fn test_device_authorize_unknown_and_expired_user_code_rejected() -> TestR
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_device_authorize_eleventh_bad_guess_rate_limited() -> TestResult {
 async fn test_device_authorize_eleventh_bad_guess_rate_limited() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let ip = unique("ip");
   let ip = unique("ip");
 
 
   for attempt in 1..=10 {
   for attempt in 1..=10 {
@@ -649,7 +630,7 @@ async fn test_device_authorize_eleventh_bad_guess_rate_limited() -> TestResult {
 /// user types stays code-free (RFC 8628 §3.3).
 /// user types stays code-free (RFC 8628 §3.3).
 #[tokio::test]
 #[tokio::test]
 async fn test_device_authorize_verification_uri_complete_carries_user_code() -> TestResult {
 async fn test_device_authorize_verification_uri_complete_carries_user_code() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("complete");
   let client_id = unique("complete");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -720,7 +701,7 @@ async fn test_device_authorize_verification_uri_complete_carries_user_code() ->
 /// optional, so case and separators do not change identity.
 /// optional, so case and separators do not change identity.
 #[tokio::test]
 #[tokio::test]
 async fn test_device_authorize_user_code_misses_are_generic_and_normalized() -> TestResult {
 async fn test_device_authorize_user_code_misses_are_generic_and_normalized() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
 
 
   for raw in [
   for raw in [
     "",
     "",

+ 47 - 63
src/protocol/introspect/tests.rs

@@ -1,15 +1,13 @@
 //! `test(introspect)` suite: active shape, inactive misses, 401, ownership.
 //! `test(introspect)` suite: active shape, inactive misses, 401, ownership.
 //!
 //!
-//! Each test runs against a real Redis container plus a migrated Postgres
-//! database on the shared test server, through the public [`super`] API and a
-//! one-route router for the HTTP status assertions.
-
-use std::{
-  sync::{
-    Arc,
-    atomic::{AtomicU64, Ordering},
-  },
-  time::Duration,
+//! Each test runs against the shared compose Redis (a per-test key-prefix
+//! pair isolates parallel runs) plus a migrated Postgres database on the shared
+//! test server, through the public [`super`] API and a one-route router for the
+//! HTTP status assertions.
+
+use std::sync::{
+  Arc,
+  atomic::{AtomicU64, Ordering},
 };
 };
 
 
 use axum::{
 use axum::{
@@ -23,11 +21,6 @@ use http_body_util::BodyExt as _;
 use secrecy::SecretString;
 use secrecy::SecretString;
 use serde_json::{Value, json};
 use serde_json::{Value, json};
 use sqlx::PgPool;
 use sqlx::PgPool;
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 use tower::ServiceExt as _;
 use tower::ServiceExt as _;
 use uuid::Uuid;
 use uuid::Uuid;
@@ -51,7 +44,7 @@ use crate::{
   },
   },
   state::{AppState, Clock, RedisConn},
   state::{AppState, Clock, RedisConn},
   store::Store,
   store::Store,
-  test_guard::{ContainerGuard, Lease},
+  test_guard::Lease,
 };
 };
 
 
 /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
 /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
@@ -77,25 +70,14 @@ struct Harness {
   config: Arc<Config>,
   config: Arc<Config>,
 }
 }
 
 
-async fn harness() -> TestResult<(ContainerGuard, Harness)> {
+async fn harness() -> TestResult<Harness> {
   let (db, pool) = migrated_pool().await?;
   let (db, pool) = migrated_pool().await?;
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .with_cmd(["redis-server", "--appendonly", "yes"])
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  let redis_url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(redis_url.as_str())?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+  let redis_url = crate::test_support::test_redis_url();
+  let manager = crate::test_support::redis_manager().await?;
+  let (token_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
   let store = Store::from_parts(
   let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    token_prefix.clone(),
+    ratelimit_prefix.clone(),
     crate::config::DurabilityMode::WaitAof,
     crate::config::DurabilityMode::WaitAof,
   );
   );
   let fixture = json!({
   let fixture = json!({
@@ -103,23 +85,25 @@ async fn harness() -> TestResult<(ContainerGuard, Harness)> {
     "environment": "development",
     "environment": "development",
     "tls": {"cert_path": "/tmp/introspect-test-cert.pem", "key_path": "/tmp/introspect-test-key.pem"},
     "tls": {"cert_path": "/tmp/introspect-test-cert.pem", "key_path": "/tmp/introspect-test-key.pem"},
     "database": {"url": "postgres://127.0.0.1:1/introspect"},
     "database": {"url": "postgres://127.0.0.1:1/introspect"},
-    "redis": {"url": redis_url, "durability": "waitaof"},
+    "redis": {
+      "url": redis_url,
+      "durability": "waitaof",
+      "token_key_prefix": token_prefix,
+      "ratelimit_key_prefix": ratelimit_prefix,
+    },
     "token_pepper": "introspect-test-pepper-0123456789abcdef",
     "token_pepper": "introspect-test-pepper-0123456789abcdef",
     "totp_encryption_key": "introspect-test-totp-key-0123456789abcdef",
     "totp_encryption_key": "introspect-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "introspect-test-pii-key-0123456789abcdef",
     "pii_encryption_key": "introspect-test-pii-key-0123456789abcdef",
   });
   });
   let source = ::config::Config::try_from(&fixture)?;
   let source = ::config::Config::try_from(&fixture)?;
   let config = Config::from_source(source)?;
   let config = Config::from_source(source)?;
-  Ok((
-    guarded,
-    Harness {
-      _db: db,
-      pool,
-      conn: RedisConn::Single(manager),
-      store,
-      config: Arc::new(config),
-    },
-  ))
+  Ok(Harness {
+    _db: db,
+    pool,
+    conn: RedisConn::Single(manager),
+    store,
+    config: Arc::new(config),
+  })
 }
 }
 
 
 async fn insert_client(
 async fn insert_client(
@@ -284,7 +268,7 @@ fn router(h: &Harness) -> Router {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_active_access_token_reports_shape() -> TestResult {
 async fn test_introspect_active_access_token_reports_shape() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("owner");
   let client_id = unique("owner");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -336,7 +320,7 @@ async fn test_introspect_active_access_token_reports_shape() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_unknown_token_is_inactive_200() -> TestResult {
 async fn test_introspect_unknown_token_is_inactive_200() -> TestResult {
-  let (_container, h) = harness().await?;
+  let h = harness().await?;
   let client_id = unique("unknown");
   let client_id = unique("unknown");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -362,7 +346,7 @@ async fn test_introspect_unknown_token_is_inactive_200() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_unauthenticated_is_401() -> TestResult {
 async fn test_introspect_unauthenticated_is_401() -> TestResult {
-  let (_container, h) = harness().await?;
+  let h = harness().await?;
   let (status, body) = post_form(router(&h), None, "token=whatever").await?;
   let (status, body) = post_form(router(&h), None, "token=whatever").await?;
 
 
   assert_eq!(status, StatusCode::UNAUTHORIZED);
   assert_eq!(status, StatusCode::UNAUTHORIZED);
@@ -375,7 +359,7 @@ async fn test_introspect_unauthenticated_is_401() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
 async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let owner = unique("owner");
   let owner = unique("owner");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -412,7 +396,7 @@ async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_revoked_token_is_inactive() -> TestResult {
 async fn test_introspect_revoked_token_is_inactive() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("revoked");
   let client_id = unique("revoked");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -445,7 +429,7 @@ async fn test_introspect_revoked_token_is_inactive() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_missing_token_form_is_400_invalid_request() -> TestResult {
 async fn test_introspect_missing_token_form_is_400_invalid_request() -> TestResult {
-  let (_container, h) = harness().await?;
+  let h = harness().await?;
   let client_id = unique("missing-token");
   let client_id = unique("missing-token");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -481,7 +465,7 @@ async fn test_introspect_missing_token_form_is_400_invalid_request() -> TestResu
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_empty_token_is_400_invalid_request() -> TestResult {
 async fn test_introspect_empty_token_is_400_invalid_request() -> TestResult {
-  let (_container, h) = harness().await?;
+  let h = harness().await?;
   let client_id = unique("empty-token");
   let client_id = unique("empty-token");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -517,7 +501,7 @@ async fn test_introspect_empty_token_is_400_invalid_request() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_refresh_token_reports_active_shape() -> TestResult {
 async fn test_introspect_refresh_token_reports_active_shape() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("refresh-shape");
   let client_id = unique("refresh-shape");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -558,7 +542,7 @@ async fn test_introspect_refresh_token_reports_active_shape() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_dpop_bound_token_reports_jkt_only() -> TestResult {
 async fn test_introspect_dpop_bound_token_reports_jkt_only() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("dpop-bound");
   let client_id = unique("dpop-bound");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -609,7 +593,7 @@ async fn test_introspect_dpop_bound_token_reports_jkt_only() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_mtls_bound_token_reports_x5t_only() -> TestResult {
 async fn test_introspect_mtls_bound_token_reports_x5t_only() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("mtls-bound");
   let client_id = unique("mtls-bound");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -661,7 +645,7 @@ async fn test_introspect_mtls_bound_token_reports_x5t_only() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_unknown_hint_is_advisory() -> TestResult {
 async fn test_introspect_unknown_hint_is_advisory() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("unknown-hint");
   let client_id = unique("unknown-hint");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -695,7 +679,7 @@ async fn test_introspect_unknown_hint_is_advisory() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_wrong_hint_still_resolves_refresh() -> TestResult {
 async fn test_introspect_wrong_hint_still_resolves_refresh() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("wrong-hint");
   let client_id = unique("wrong-hint");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -726,7 +710,7 @@ async fn test_introspect_wrong_hint_still_resolves_refresh() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_active_response_leaks_no_token_or_secret() -> TestResult {
 async fn test_introspect_active_response_leaks_no_token_or_secret() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("no-leak");
   let client_id = unique("no-leak");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -769,7 +753,7 @@ async fn test_introspect_active_response_leaks_no_token_or_secret() -> TestResul
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_after_revoke_endpoint_is_inactive() -> TestResult {
 async fn test_introspect_after_revoke_endpoint_is_inactive() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("revoke-then-introspect");
   let client_id = unique("revoke-then-introspect");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
@@ -899,7 +883,7 @@ async fn introspect_client(h: &mut Harness, label: &str, secret: &str) -> TestRe
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_disabled_email_subject_is_inactive() -> TestResult {
 async fn test_introspect_disabled_email_subject_is_inactive() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = introspect_client(&mut h, "disabled-email", "secret-de").await?;
   let client_id = introspect_client(&mut h, "disabled-email", "secret-de").await?;
   let user = provision_user(&h, "disabled-introspect@example.test").await?;
   let user = provision_user(&h, "disabled-introspect@example.test").await?;
   let minted = mint_subject(&mut h, &client_id, &user.email).await?;
   let minted = mint_subject(&mut h, &client_id, &user.email).await?;
@@ -920,7 +904,7 @@ async fn test_introspect_disabled_email_subject_is_inactive() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_disabled_uuid_subject_is_inactive() -> TestResult {
 async fn test_introspect_disabled_uuid_subject_is_inactive() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = introspect_client(&mut h, "disabled-uuid", "secret-du").await?;
   let client_id = introspect_client(&mut h, "disabled-uuid", "secret-du").await?;
   let user = provision_user(&h, "disabled-uuid@example.test").await?;
   let user = provision_user(&h, "disabled-uuid@example.test").await?;
   // Device-code tokens carry the user UUID string as subject.
   // Device-code tokens carry the user UUID string as subject.
@@ -942,7 +926,7 @@ async fn test_introspect_disabled_uuid_subject_is_inactive() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_enabled_email_subject_stays_active() -> TestResult {
 async fn test_introspect_enabled_email_subject_stays_active() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = introspect_client(&mut h, "enabled-email", "secret-ee").await?;
   let client_id = introspect_client(&mut h, "enabled-email", "secret-ee").await?;
   let user = provision_user(&h, "enabled-introspect@example.test").await?;
   let user = provision_user(&h, "enabled-introspect@example.test").await?;
   let minted = mint_subject(&mut h, &client_id, &user.email).await?;
   let minted = mint_subject(&mut h, &client_id, &user.email).await?;
@@ -962,7 +946,7 @@ async fn test_introspect_enabled_email_subject_stays_active() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_reenabled_subject_is_active_again() -> TestResult {
 async fn test_introspect_reenabled_subject_is_active_again() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = introspect_client(&mut h, "reenabled", "secret-re").await?;
   let client_id = introspect_client(&mut h, "reenabled", "secret-re").await?;
   let user = provision_user(&h, "reenabled-introspect@example.test").await?;
   let user = provision_user(&h, "reenabled-introspect@example.test").await?;
   let minted = mint_subject(&mut h, &client_id, &user.email).await?;
   let minted = mint_subject(&mut h, &client_id, &user.email).await?;
@@ -998,7 +982,7 @@ async fn test_introspect_reenabled_subject_is_active_again() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_introspect_empty_and_unknown_subjects_stay_active() -> TestResult {
 async fn test_introspect_empty_and_unknown_subjects_stay_active() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = introspect_client(&mut h, "non-user-sub", "secret-nu").await?;
   let client_id = introspect_client(&mut h, "non-user-sub", "secret-nu").await?;
   // Client-credentials tokens carry an empty subject.
   // Client-credentials tokens carry an empty subject.
   let empty = mint_subject(&mut h, &client_id, "").await?;
   let empty = mint_subject(&mut h, &client_id, "").await?;

+ 11 - 26
src/protocol/par/tests.rs

@@ -1,4 +1,4 @@
-//! `test(par)` container suite: PAR push, one-time client-bound consume,
+//! `test(par)` suite: PAR push, one-time client-bound consume,
 //! cross-client/replay/expiry rejection, and auth/rule enforcement.
 //! cross-client/replay/expiry rejection, and auth/rule enforcement.
 
 
 mod consume;
 mod consume;
@@ -7,11 +7,6 @@ mod rules;
 use std::{collections::HashMap, time::Duration};
 use std::{collections::HashMap, time::Duration};
 
 
 use sqlx::PgPool;
 use sqlx::PgPool;
-use testcontainers::{
-  GenericImage,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 use uuid::Uuid;
 use uuid::Uuid;
 
 
@@ -25,16 +20,13 @@ use crate::{
   protocol::client_auth::ClientAuthRequest,
   protocol::client_auth::ClientAuthRequest,
   state::RedisConn,
   state::RedisConn,
   store::Store,
   store::Store,
-  test_guard::{ContainerGuard, Lease},
+  test_guard::Lease,
 };
 };
 use secrecy::SecretString;
 use secrecy::SecretString;
 
 
 /// Test scaffolding failures (no `unwrap`/`expect` under the deny lints).
 /// Test scaffolding failures (no `unwrap`/`expect` under the deny lints).
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 enum TestError {
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   /// A Redis command failed.
   #[error("redis error: {0}")]
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
@@ -62,26 +54,19 @@ struct Harness {
   keys: KeyRing,
   keys: KeyRing,
 }
 }
 
 
-/// Fresh Postgres database plus a throwaway Redis for one test.
-async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
+/// Fresh Postgres database plus the shared compose Redis for one test.
+///
+/// The Redis keyspace is isolated by a per-test unique `{state}`/`{rl}` prefix
+/// pair, so parallel tests never observe each other's keys.
+async fn harness() -> Result<Harness, TestError> {
   let (_db, pool) = crate::db::test_support::migrated_pool()
   let (_db, pool) = crate::db::test_support::migrated_pool()
     .await
     .await
     .map_err(|error| TestError::Unexpected(format!("{error}")))?;
     .map_err(|error| TestError::Unexpected(format!("{error}")))?;
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  let url = format!("redis://127.0.0.1:{port}");
+  let url = crate::test_support::test_redis_url();
   let client = redis::Client::open(url.as_str())?;
   let client = redis::Client::open(url.as_str())?;
   let manager = redis::aio::ConnectionManager::new(client).await?;
   let manager = redis::aio::ConnectionManager::new(client).await?;
-  let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
-    DurabilityMode::WaitAof,
-  );
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let store = Store::from_parts(state_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
   let h = Harness {
   let h = Harness {
     _db,
     _db,
     pool,
     pool,
@@ -95,7 +80,7 @@ async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
     )
     )
     .map_err(|error| TestError::Crypto(format!("{error:?}")))?,
     .map_err(|error| TestError::Crypto(format!("{error:?}")))?,
   };
   };
-  Ok((guarded, h))
+  Ok(h)
 }
 }
 
 
 /// Borrow datastore handles out of a harness.
 /// Borrow datastore handles out of a harness.

+ 5 - 5
src/protocol/par/tests/consume.rs

@@ -47,7 +47,7 @@ async fn push_public(
 
 
 #[tokio::test]
 #[tokio::test]
 async fn par_push_stores_one_time_record() -> Result<(), TestError> {
 async fn par_push_stores_one_time_record() -> Result<(), TestError> {
-  let (_guard, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = "par-public-client";
   let client_id = "par-public-client";
   let redirect_uri = "https://client.example.test/cb";
   let redirect_uri = "https://client.example.test/cb";
   insert_client(
   insert_client(
@@ -97,7 +97,7 @@ async fn par_push_stores_one_time_record() -> Result<(), TestError> {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn par_authorize_binds_client_and_rejects_replay() -> Result<(), TestError> {
 async fn par_authorize_binds_client_and_rejects_replay() -> Result<(), TestError> {
-  let (_guard, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_a = "par-client-a";
   let client_a = "par-client-a";
   let client_b = "par-client-b";
   let client_b = "par-client-b";
   let redirect_uri = "https://client.example.test/cb";
   let redirect_uri = "https://client.example.test/cb";
@@ -190,7 +190,7 @@ async fn par_authorize_binds_client_and_rejects_replay() -> Result<(), TestError
 
 
 #[tokio::test]
 #[tokio::test]
 async fn par_foreign_presentation_does_not_consume_victim_record() -> Result<(), TestError> {
 async fn par_foreign_presentation_does_not_consume_victim_record() -> Result<(), TestError> {
-  let (_guard, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_a = "par-victim-client";
   let client_a = "par-victim-client";
   let client_b = "par-foreign-client";
   let client_b = "par-foreign-client";
   let redirect_uri = "https://client.example.test/cb";
   let redirect_uri = "https://client.example.test/cb";
@@ -267,7 +267,7 @@ async fn par_foreign_presentation_does_not_consume_victim_record() -> Result<(),
 
 
 #[tokio::test]
 #[tokio::test]
 async fn par_consume_bound_transport_error_recovery() -> Result<(), TestError> {
 async fn par_consume_bound_transport_error_recovery() -> Result<(), TestError> {
-  let (_guard, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = "par-recovery-client";
   let client_id = "par-recovery-client";
   let record = par::ParRecord {
   let record = par::ParRecord {
     client_id: client_id.to_owned(),
     client_id: client_id.to_owned(),
@@ -330,7 +330,7 @@ async fn par_consume_bound_transport_error_recovery() -> Result<(), TestError> {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn par_expired_request_uri_is_rejected() -> Result<(), TestError> {
 async fn par_expired_request_uri_is_rejected() -> Result<(), TestError> {
-  let (_guard, mut h) = harness().await?;
+  let mut h = harness().await?;
   let record = par::ParRecord {
   let record = par::ParRecord {
     client_id: "par-expiry-client".to_owned(),
     client_id: "par-expiry-client".to_owned(),
     request: "{\"response_type\":\"code\"}".to_owned(),
     request: "{\"response_type\":\"code\"}".to_owned(),

+ 3 - 3
src/protocol/par/tests/rules.rs

@@ -17,7 +17,7 @@ use super::TestError;
 
 
 #[tokio::test]
 #[tokio::test]
 async fn par_rejects_unauthenticated_client() -> Result<(), TestError> {
 async fn par_rejects_unauthenticated_client() -> Result<(), TestError> {
-  let (_guard, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = "par-conf-client";
   let client_id = "par-conf-client";
   let redirect_uri = "https://client.example.test/cb";
   let redirect_uri = "https://client.example.test/cb";
   insert_client(
   insert_client(
@@ -82,7 +82,7 @@ async fn par_rejects_unauthenticated_client() -> Result<(), TestError> {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn par_rejects_invalid_request_rules() -> Result<(), TestError> {
 async fn par_rejects_invalid_request_rules() -> Result<(), TestError> {
-  let (_guard, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = "par-rules-client";
   let client_id = "par-rules-client";
   let redirect_uri = "https://client.example.test/cb";
   let redirect_uri = "https://client.example.test/cb";
   insert_client(
   insert_client(
@@ -152,7 +152,7 @@ async fn par_rejects_invalid_request_rules() -> Result<(), TestError> {
 /// `uri_enc` fails as 503 `PiiDecrypt` (never a flattened client error).
 /// `uri_enc` fails as 503 `PiiDecrypt` (never a flattened client error).
 #[tokio::test]
 #[tokio::test]
 async fn par_redirect_mismatch_rejected() -> Result<(), TestError> {
 async fn par_redirect_mismatch_rejected() -> Result<(), TestError> {
-  let (_guard, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = "par-mismatch-client";
   let client_id = "par-mismatch-client";
   let redirect_uri = "https://client.example.test/cb";
   let redirect_uri = "https://client.example.test/cb";
   insert_client(
   insert_client(

+ 17 - 42
src/protocol/purge.rs

@@ -298,16 +298,7 @@ fn store_mapped(error: redis::RedisError) -> AppError {
 
 
 #[cfg(test)]
 #[cfg(test)]
 mod tests {
 mod tests {
-  use std::{
-    sync::atomic::{AtomicU64, Ordering},
-    time::Duration,
-  };
-
-  use testcontainers::{
-    GenericImage, ImageExt,
-    core::{IntoContainerPort, WaitFor},
-    runners::AsyncRunner,
-  };
+  use std::time::Duration;
 
 
   use super::{PurgedSubject, purge_sibling_sessions, purge_subject};
   use super::{PurgedSubject, purge_sibling_sessions, purge_subject};
   use crate::{
   use crate::{
@@ -315,13 +306,10 @@ mod tests {
     error::{AppError, OAuthErrorCode},
     error::{AppError, OAuthErrorCode},
     state::RedisConn,
     state::RedisConn,
     store::Store,
     store::Store,
-    test_guard::ContainerGuard,
   };
   };
 
 
   #[derive(Debug, thiserror::Error)]
   #[derive(Debug, thiserror::Error)]
   enum TestError {
   enum TestError {
-    #[error("container error: {0}")]
-    Containers(#[from] testcontainers::TestcontainersError),
     #[error("redis error: {0}")]
     #[error("redis error: {0}")]
     Redis(#[from] redis::RedisError),
     Redis(#[from] redis::RedisError),
     #[error("store error: {0}")]
     #[error("store error: {0}")]
@@ -334,30 +322,17 @@ mod tests {
 
 
   type TestResult = Result<(), TestError>;
   type TestResult = Result<(), TestError>;
 
 
-  static NONCE: AtomicU64 = AtomicU64::new(1);
-
-  async fn harness() -> Result<(ContainerGuard, RedisConn, Store), TestError> {
-    let n = NONCE.fetch_add(1, Ordering::Relaxed);
-    let container = GenericImage::new("redis", "8-alpine")
-      .with_exposed_port(6379.tcp())
-      .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-      .with_cmd(["redis-server", "--appendonly", "yes"])
-      .start()
-      .await?;
-    let guarded = ContainerGuard::new(container);
-    let port = guarded.get_host_port_ipv4(6379).await?;
-    let url = format!("redis://127.0.0.1:{port}");
-    let client = redis::Client::open(url.as_str())?;
-    let manager_config = redis::aio::ConnectionManagerConfig::new()
-      .set_response_timeout(Some(Duration::from_secs(10)))
-      .set_connection_timeout(Some(Duration::from_secs(5)));
-    let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
-    let store = Store::from_parts(
-      format!("purge-test-{n}:{{state}}:"),
-      "oauth:{rl}:".to_owned(),
-      DurabilityMode::WaitAof,
-    );
-    Ok((guarded, RedisConn::Single(manager), store))
+  /// Shared compose Redis plus a per-test uuid prefix pair.
+  ///
+  /// Unique prefixes are mandatory: `purge_subject` `SCAN`s its prefix, and a
+  /// per-process counter collides across parallel nextest processes.
+  async fn harness() -> Result<(RedisConn, Store), TestError> {
+    let manager = crate::test_support::redis_manager()
+      .await
+      .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+    let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+    let store = Store::from_parts(state_prefix, rl_prefix, DurabilityMode::WaitAof);
+    Ok((RedisConn::Single(manager), store))
   }
   }
 
 
   async fn hset(conn: &mut RedisConn, key: &str, pairs: &[(&str, &str)]) -> TestResult {
   async fn hset(conn: &mut RedisConn, key: &str, pairs: &[(&str, &str)]) -> TestResult {
@@ -407,7 +382,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn purge_subject_deletes_all_subject_state() -> TestResult {
   async fn purge_subject_deletes_all_subject_state() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let email = "alice@example.test";
     let email = "alice@example.test";
     let user_id = uuid::Uuid::new_v4().to_string();
     let user_id = uuid::Uuid::new_v4().to_string();
     let decoy = "bob@example.test";
     let decoy = "bob@example.test";
@@ -616,7 +591,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn purge_subject_with_no_matching_state_returns_zero() -> TestResult {
   async fn purge_subject_with_no_matching_state_returns_zero() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let summary = purge_subject(&mut conn, &store, "nobody@example.test", "no-id").await?;
     let summary = purge_subject(&mut conn, &store, "nobody@example.test", "no-id").await?;
     if summary != PurgedSubject::default() {
     if summary != PurgedSubject::default() {
       return Err(TestError::Unexpected(format!(
       return Err(TestError::Unexpected(format!(
@@ -656,7 +631,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn purge_sibling_sessions_deletes_meta_sidecar_and_keeps_current() -> TestResult {
   async fn purge_sibling_sessions_deletes_meta_sidecar_and_keeps_current() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let email = "sibling-meta@example.test";
     let email = "sibling-meta@example.test";
     let user_id = uuid::Uuid::new_v4().to_string();
     let user_id = uuid::Uuid::new_v4().to_string();
     let current_key = store.session_key("sess-current");
     let current_key = store.session_key("sess-current");
@@ -692,7 +667,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn purge_sibling_sessions_leaves_other_subjects_tokens_and_grants() -> TestResult {
   async fn purge_sibling_sessions_leaves_other_subjects_tokens_and_grants() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let email = "sibling-scope@example.test";
     let email = "sibling-scope@example.test";
     let user_id = uuid::Uuid::new_v4().to_string();
     let user_id = uuid::Uuid::new_v4().to_string();
     let other = "stranger@example.test";
     let other = "stranger@example.test";
@@ -745,7 +720,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn purge_sibling_sessions_matches_subject_by_user_id() -> TestResult {
   async fn purge_sibling_sessions_matches_subject_by_user_id() -> TestResult {
-    let (_guard, mut conn, store) = harness().await?;
+    let (mut conn, store) = harness().await?;
     let email = "sibling-id@example.test";
     let email = "sibling-id@example.test";
     let user_id = uuid::Uuid::new_v4().to_string();
     let user_id = uuid::Uuid::new_v4().to_string();
     let current_key = store.session_key("sess-id-current");
     let current_key = store.session_key("sess-id-current");

+ 25 - 43
src/protocol/revoke/tests.rs

@@ -1,9 +1,10 @@
 //! `test(revoke)` suite: ownership-scoped revocation and grant cascade.
 //! `test(revoke)` suite: ownership-scoped revocation and grant cascade.
 //!
 //!
-//! A shared Redis 8 container (AOF on) backs the token/grant stores; unique
-//! grant/client labels keep the keyspace collision-free. Authentication-scoped
-//! behaviour that needs no database is exercised against a lazy Postgres pool
-//! that is never dialed.
+//! The shared compose Redis (`compose.test.yaml`) backs the token/grant stores;
+//! a per-test unique `{state}`/`{rl}` prefix pair keeps the keyspace
+//! collision-free, and unique grant/client labels keep lookups distinct.
+//! Authentication-scoped behaviour that needs no database is exercised against
+//! a lazy Postgres pool that is never dialed.
 
 
 mod auth;
 mod auth;
 mod cascade;
 mod cascade;
@@ -18,11 +19,6 @@ use std::{
 };
 };
 
 
 use secrecy::SecretString;
 use secrecy::SecretString;
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort as _, WaitFor},
-  runners::AsyncRunner as _,
-};
 
 
 use crate::{
 use crate::{
   config::{DurabilityMode, TtlConfig},
   config::{DurabilityMode, TtlConfig},
@@ -33,7 +29,6 @@ use crate::{
   },
   },
   state::{AppState, Clock, RedisConn},
   state::{AppState, Clock, RedisConn},
   store::{Store, StoreError},
   store::{Store, StoreError},
-  test_guard::{ContainerGuard, Lease, Shared},
 };
 };
 
 
 /// Test body result (no `unwrap`/`expect` under the deny lints).
 /// Test body result (no `unwrap`/`expect` under the deny lints).
@@ -42,9 +37,6 @@ type TestResult = Result<(), TestError>;
 /// Errors raised by the revocation test scaffolding itself.
 /// Errors raised by the revocation test scaffolding itself.
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 enum TestError {
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   /// A Redis command failed.
   #[error("redis error")]
   #[error("redis error")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
@@ -74,39 +66,29 @@ enum TestError {
   Unexpected(String),
   Unexpected(String),
 }
 }
 
 
-/// One shared Redis 8 container for the whole suite.
-static REDIS: Shared<ContainerGuard> = Shared::new();
-
-/// Fresh connection plus store handle against the shared container.
-async fn test_redis() -> Result<(Lease<ContainerGuard>, RedisConn, Store), TestError> {
-  let container = REDIS
-    .lease::<TestError>(async {
-      let started = GenericImage::new("redis", "8-alpine")
-        .with_exposed_port(6379.tcp())
-        .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-        .with_cmd(["redis-server", "--appendonly", "yes"])
-        .start()
-        .await?;
-      Ok(ContainerGuard::new(started))
-    })
-    .await?;
-  let port = container.get_host_port_ipv4(6379).await?;
-  let url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(url.as_str())?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
-  Ok((container, RedisConn::Single(manager), store()))
+/// Fresh connection plus a scoped store handle against the shared compose
+/// Redis, paired with the exact `{state}`/`{rl}` prefixes that scope the store.
+async fn test_redis() -> Result<(RedisConn, Store, (String, String)), TestError> {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let store = Store::from_parts(
+    state_prefix.clone(),
+    ratelimit_prefix.clone(),
+    DurabilityMode::WaitAof,
+  );
+  Ok((
+    RedisConn::Single(manager),
+    store,
+    (state_prefix, ratelimit_prefix),
+  ))
 }
 }
 
 
-/// A store handle with the canonical test key prefixes.
+/// A store handle with a fresh per-test unique key-prefix pair.
 fn store() -> Store {
 fn store() -> Store {
-  Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
-    DurabilityMode::WaitAof,
-  )
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  Store::from_parts(state_prefix, ratelimit_prefix, DurabilityMode::WaitAof)
 }
 }
 
 
 /// A lazy Redis connection that is never actually dialed.
 /// A lazy Redis connection that is never actually dialed.

+ 7 - 7
src/protocol/revoke/tests/cascade.rs

@@ -15,7 +15,7 @@ use crate::{
 /// Revoking an access token removes it but leaves its sibling refresh token.
 /// Revoking an access token removes it but leaves its sibling refresh token.
 #[tokio::test]
 #[tokio::test]
 async fn revoke_access_token_removes_only_it() -> TestResult {
 async fn revoke_access_token_removes_only_it() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store, _prefixes) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("client");
   let client = unique("client");
   let minted = service
   let minted = service
@@ -67,7 +67,7 @@ async fn revoke_access_token_removes_only_it() -> TestResult {
 /// Revoking a refresh token cascades to the whole grant family.
 /// Revoking a refresh token cascades to the whole grant family.
 #[tokio::test]
 #[tokio::test]
 async fn revoke_refresh_token_cascades_to_grant_chain() -> TestResult {
 async fn revoke_refresh_token_cascades_to_grant_chain() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store, _prefixes) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("client");
   let client = unique("client");
   let grant = unique("grant");
   let grant = unique("grant");
@@ -120,7 +120,7 @@ async fn revoke_refresh_token_cascades_to_grant_chain() -> TestResult {
 /// An unknown token is a silent `200` no-op.
 /// An unknown token is a silent `200` no-op.
 #[tokio::test]
 #[tokio::test]
 async fn revoke_unknown_token_is_noop_200() -> TestResult {
 async fn revoke_unknown_token_is_noop_200() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store, _prefixes) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let outcome = revoke_owned(
   let outcome = revoke_owned(
     &mut conn,
     &mut conn,
@@ -147,7 +147,7 @@ async fn revoke_unknown_token_is_noop_200() -> TestResult {
 /// Another client's token is a no-op and is never revoked (no cross-client).
 /// Another client's token is a no-op and is never revoked (no cross-client).
 #[tokio::test]
 #[tokio::test]
 async fn revoke_foreign_token_is_noop_and_untouched() -> TestResult {
 async fn revoke_foreign_token_is_noop_and_untouched() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store, _prefixes) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let owner = unique("owner");
   let owner = unique("owner");
   let attacker = unique("attacker");
   let attacker = unique("attacker");
@@ -191,7 +191,7 @@ async fn revoke_foreign_token_is_noop_and_untouched() -> TestResult {
 /// An unsupported `token_type_hint` is `400 unsupported_token_type`.
 /// An unsupported `token_type_hint` is `400 unsupported_token_type`.
 #[tokio::test]
 #[tokio::test]
 async fn revoke_unsupported_hint_is_rejected() -> TestResult {
 async fn revoke_unsupported_hint_is_rejected() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store, _prefixes) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let outcome = revoke_owned(
   let outcome = revoke_owned(
     &mut conn,
     &mut conn,
@@ -231,7 +231,7 @@ async fn revoke_unsupported_hint_is_rejected() -> TestResult {
 /// itself, so the sibling refresh token survives).
 /// itself, so the sibling refresh token survives).
 #[tokio::test]
 #[tokio::test]
 async fn revoke_refresh_hint_on_access_token_still_revokes_it() -> TestResult {
 async fn revoke_refresh_hint_on_access_token_still_revokes_it() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store, _prefixes) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("client");
   let client = unique("client");
   let minted = service
   let minted = service
@@ -284,7 +284,7 @@ async fn revoke_refresh_hint_on_access_token_still_revokes_it() -> TestResult {
 /// refresh token stays resolvable, and its access sibling is untouched too.
 /// refresh token stays resolvable, and its access sibling is untouched too.
 #[tokio::test]
 #[tokio::test]
 async fn revoke_foreign_refresh_token_is_noop_and_untouched() -> TestResult {
 async fn revoke_foreign_refresh_token_is_noop_and_untouched() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store, _prefixes) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let owner = unique("owner");
   let owner = unique("owner");
   let attacker = unique("attacker");
   let attacker = unique("attacker");

+ 23 - 11
src/protocol/revoke/tests/mtls.rs

@@ -61,14 +61,26 @@ async fn insert_mtls_client(pool: &sqlx::PgPool, client_id: &str, thumbprint: &s
   Ok(())
   Ok(())
 }
 }
 
 
-/// Handler-level [`AppState`] over a real pool and Redis handle.
-fn app_state(pool: sqlx::PgPool, redis: RedisConn) -> Result<AppState, TestError> {
+/// Handler-level [`AppState`] over a real pool and Redis handle, with the
+/// per-test `{state}`/`{rl}` prefixes threaded into the config so the handler's
+/// own store resolves the same keyspace the test minted into.
+fn app_state(
+  pool: sqlx::PgPool,
+  redis: RedisConn,
+  token_prefix: String,
+  ratelimit_prefix: String,
+) -> Result<AppState, TestError> {
   let fixture = serde_json::json!({
   let fixture = serde_json::json!({
     "issuer": "https://auth.example.test",
     "issuer": "https://auth.example.test",
     "environment": "development",
     "environment": "development",
     "tls": {"cert_path": "/tmp/revoke-test-cert.pem", "key_path": "/tmp/revoke-test-key.pem"},
     "tls": {"cert_path": "/tmp/revoke-test-cert.pem", "key_path": "/tmp/revoke-test-key.pem"},
     "database": {"url": "postgres://127.0.0.1:1/revoke"},
     "database": {"url": "postgres://127.0.0.1:1/revoke"},
-    "redis": {"url": "redis://127.0.0.1:1", "durability": "waitaof"},
+    "redis": {
+      "url": "redis://127.0.0.1:1",
+      "durability": "waitaof",
+      "token_key_prefix": token_prefix,
+      "ratelimit_key_prefix": ratelimit_prefix,
+    },
     "token_pepper": "revoke-test-pepper-0123456789abcdef",
     "token_pepper": "revoke-test-pepper-0123456789abcdef",
     "totp_encryption_key": "revoke-test-totp-key-0123456789abcdef",
     "totp_encryption_key": "revoke-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "revoke-test-pii-key-0123456789abcdef",
     "pii_encryption_key": "revoke-test-pii-key-0123456789abcdef",
@@ -151,7 +163,7 @@ async fn revoke_mtls_chain_does_not_match_claimed_client() -> TestResult {
   let (_db, pool) = crate::db::test_support::migrated_pool()
   let (_db, pool) = crate::db::test_support::migrated_pool()
     .await
     .await
     .map_err(|error| TestError::Unexpected(format!("postgres: {error}")))?;
     .map_err(|error| TestError::Unexpected(format!("postgres: {error}")))?;
-  let (_guard, mut conn, store_handle) = test_redis().await?;
+  let (mut conn, store_handle, (token_prefix, ratelimit_prefix)) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let victim_leaf = cert_der(CLIENT_SELF_PEM)?;
   let victim_leaf = cert_der(CLIENT_SELF_PEM)?;
   let victim_thumbprint = mtls::thumbprint_b64url(victim_leaf.as_ref());
   let victim_thumbprint = mtls::thumbprint_b64url(victim_leaf.as_ref());
@@ -172,7 +184,7 @@ async fn revoke_mtls_chain_does_not_match_claimed_client() -> TestResult {
   let access = minted.access_token.clone();
   let access = minted.access_token.clone();
   let peer = PeerCertificates(Arc::new(vec![attacker_leaf]));
   let peer = PeerCertificates(Arc::new(vec![attacker_leaf]));
   let mut verify_conn = conn.clone();
   let mut verify_conn = conn.clone();
-  let state = app_state(pool, conn)?;
+  let state = app_state(pool, conn, token_prefix, ratelimit_prefix)?;
   let (status, body) = revoke_with_certs_full(state, &access, &victim_id, peer).await?;
   let (status, body) = revoke_with_certs_full(state, &access, &victim_id, peer).await?;
   if status != StatusCode::BAD_REQUEST {
   if status != StatusCode::BAD_REQUEST {
     return Err(TestError::Unexpected(format!(
     return Err(TestError::Unexpected(format!(
@@ -203,7 +215,7 @@ async fn revoke_mtls_disabled_client_rejected() -> TestResult {
   let (_db, pool) = crate::db::test_support::migrated_pool()
   let (_db, pool) = crate::db::test_support::migrated_pool()
     .await
     .await
     .map_err(|error| TestError::Unexpected(format!("postgres: {error}")))?;
     .map_err(|error| TestError::Unexpected(format!("postgres: {error}")))?;
-  let (_guard, mut conn, store_handle) = test_redis().await?;
+  let (mut conn, store_handle, (token_prefix, ratelimit_prefix)) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let leaf = cert_der(CLIENT_SELF_PEM)?;
   let leaf = cert_der(CLIENT_SELF_PEM)?;
   let thumbprint = mtls::thumbprint_b64url(leaf.as_ref());
   let thumbprint = mtls::thumbprint_b64url(leaf.as_ref());
@@ -225,7 +237,7 @@ async fn revoke_mtls_disabled_client_rejected() -> TestResult {
   }
   }
   let peer = PeerCertificates(Arc::new(vec![leaf]));
   let peer = PeerCertificates(Arc::new(vec![leaf]));
   let mut verify_conn = conn.clone();
   let mut verify_conn = conn.clone();
-  let state = app_state(pool, conn)?;
+  let state = app_state(pool, conn, token_prefix, ratelimit_prefix)?;
   let (status, body) = revoke_with_certs_full(state, &access, &client_id, peer).await?;
   let (status, body) = revoke_with_certs_full(state, &access, &client_id, peer).await?;
   if status != StatusCode::BAD_REQUEST {
   if status != StatusCode::BAD_REQUEST {
     return Err(TestError::Unexpected(format!(
     return Err(TestError::Unexpected(format!(
@@ -256,7 +268,7 @@ async fn revoke_mtls_client_self_revokes() -> TestResult {
   let (_db, pool) = crate::db::test_support::migrated_pool()
   let (_db, pool) = crate::db::test_support::migrated_pool()
     .await
     .await
     .map_err(|error| TestError::Unexpected(format!("postgres: {error}")))?;
     .map_err(|error| TestError::Unexpected(format!("postgres: {error}")))?;
-  let (_guard, mut conn, store_handle) = test_redis().await?;
+  let (mut conn, store_handle, (token_prefix, ratelimit_prefix)) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let leaf = cert_der(CLIENT_SELF_PEM)?;
   let leaf = cert_der(CLIENT_SELF_PEM)?;
   let thumbprint = mtls::thumbprint_b64url(leaf.as_ref());
   let thumbprint = mtls::thumbprint_b64url(leaf.as_ref());
@@ -273,7 +285,7 @@ async fn revoke_mtls_client_self_revokes() -> TestResult {
   let access = minted.access_token.clone();
   let access = minted.access_token.clone();
   let peer = PeerCertificates(Arc::new(vec![leaf]));
   let peer = PeerCertificates(Arc::new(vec![leaf]));
   let mut verify_conn = conn.clone();
   let mut verify_conn = conn.clone();
-  let state = app_state(pool, conn)?;
+  let state = app_state(pool, conn, token_prefix, ratelimit_prefix)?;
   let status = revoke_with_certs(state, &access, &client_id, peer).await?;
   let status = revoke_with_certs(state, &access, &client_id, peer).await?;
   if status != StatusCode::OK {
   if status != StatusCode::OK {
     return Err(TestError::Unexpected(format!(
     return Err(TestError::Unexpected(format!(
@@ -299,7 +311,7 @@ async fn revoke_mtls_unknown_certificate_rejected() -> TestResult {
   let (_db, pool) = crate::db::test_support::migrated_pool()
   let (_db, pool) = crate::db::test_support::migrated_pool()
     .await
     .await
     .map_err(|error| TestError::Unexpected(format!("postgres: {error}")))?;
     .map_err(|error| TestError::Unexpected(format!("postgres: {error}")))?;
-  let (_guard, mut conn, store_handle) = test_redis().await?;
+  let (mut conn, store_handle, (token_prefix, ratelimit_prefix)) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let leaf = cert_der(CLIENT_SELF_PEM)?;
   let leaf = cert_der(CLIENT_SELF_PEM)?;
   let thumbprint = mtls::thumbprint_b64url(leaf.as_ref());
   let thumbprint = mtls::thumbprint_b64url(leaf.as_ref());
@@ -317,7 +329,7 @@ async fn revoke_mtls_unknown_certificate_rejected() -> TestResult {
   let rogue = cert_der(CLIENT_ROGUE_PEM)?;
   let rogue = cert_der(CLIENT_ROGUE_PEM)?;
   let peer = PeerCertificates(Arc::new(vec![rogue]));
   let peer = PeerCertificates(Arc::new(vec![rogue]));
   let mut verify_conn = conn.clone();
   let mut verify_conn = conn.clone();
-  let state = app_state(pool, conn)?;
+  let state = app_state(pool, conn, token_prefix, ratelimit_prefix)?;
   let status = revoke_with_certs(state, &access, &client_id, peer).await?;
   let status = revoke_with_certs(state, &access, &client_id, peer).await?;
   if status != StatusCode::UNAUTHORIZED && status != StatusCode::BAD_REQUEST {
   if status != StatusCode::UNAUTHORIZED && status != StatusCode::BAD_REQUEST {
     return Err(TestError::Unexpected(format!(
     return Err(TestError::Unexpected(format!(

+ 26 - 51
src/protocol/token/grants/authorization_code.rs

@@ -424,7 +424,8 @@ async fn authenticate_confidential(
 mod tests {
 mod tests {
   //! `test(grant_code)` suite: happy paths, single-use, and binding checks.
   //! `test(grant_code)` suite: happy paths, single-use, and binding checks.
   //!
   //!
-  //! Live Postgres (client registry) plus a per-test Redis 8 container.
+  //! Live Postgres (client registry) plus the shared compose Redis, isolated
+  //! per test by a unique `{state}`/`{rl}` prefix pair.
   //! Codes are issued through [`crate::protocol::code::CodeIssuer`] so the
   //! Codes are issued through [`crate::protocol::code::CodeIssuer`] so the
   //! keyed-hash path is exercised end to end; every negative test issues a
   //! keyed-hash path is exercised end to end; every negative test issues a
   //! fresh code because redemption consumes exactly once.
   //! fresh code because redemption consumes exactly once.
@@ -435,11 +436,6 @@ mod tests {
   };
   };
 
 
   use secrecy::SecretString;
   use secrecy::SecretString;
-  use testcontainers::{
-    GenericImage, ImageExt as _,
-    core::{IntoContainerPort as _, WaitFor},
-    runners::AsyncRunner as _,
-  };
 
 
   use super::{CodeGrantInputs, CodeGrantState, CodeTokenResponse, GRANT_TYPE_REFRESH_TOKEN};
   use super::{CodeGrantInputs, CodeGrantState, CodeTokenResponse, GRANT_TYPE_REFRESH_TOKEN};
   use crate::{
   use crate::{
@@ -458,7 +454,6 @@ mod tests {
     },
     },
     state::RedisConn,
     state::RedisConn,
     store::Store,
     store::Store,
-    test_guard::ContainerGuard,
   };
   };
 
 
   /// Test body result (no `unwrap`/`expect` under the deny lints).
   /// Test body result (no `unwrap`/`expect` under the deny lints).
@@ -467,9 +462,6 @@ mod tests {
   /// Errors raised by the authorization-code test scaffolding itself.
   /// Errors raised by the authorization-code test scaffolding itself.
   #[derive(Debug, thiserror::Error)]
   #[derive(Debug, thiserror::Error)]
   enum TestError {
   enum TestError {
-    /// A testcontainers operation failed.
-    #[error("container error: {0}")]
-    Containers(#[from] testcontainers::TestcontainersError),
     /// A Redis command failed.
     /// A Redis command failed.
     #[error("redis error: {0}")]
     #[error("redis error: {0}")]
     Redis(#[from] redis::RedisError),
     Redis(#[from] redis::RedisError),
@@ -535,24 +527,14 @@ mod tests {
     ttl: TtlConfig,
     ttl: TtlConfig,
   }
   }
 
 
-  async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
+  async fn harness() -> Result<Harness, TestError> {
     let (db, pool) = crate::db::test_support::migrated_pool()
     let (db, pool) = crate::db::test_support::migrated_pool()
       .await
       .await
       .map_err(|error| TestError::Unexpected(format!("pg: {error}")))?;
       .map_err(|error| TestError::Unexpected(format!("pg: {error}")))?;
-    let container = GenericImage::new("redis", "8-alpine")
-      .with_exposed_port(6379.tcp())
-      .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-      .with_cmd(["redis-server", "--appendonly", "yes"])
-      .start()
-      .await?;
-    let guarded = ContainerGuard::new(container);
-    let port = guarded.get_host_port_ipv4(6379).await?;
-    let url = format!("redis://127.0.0.1:{port}");
-    let client = redis::Client::open(url.as_str())?;
-    let manager_config = redis::aio::ConnectionManagerConfig::new()
-      .set_response_timeout(Some(Duration::from_secs(10)))
-      .set_connection_timeout(Some(Duration::from_secs(5)));
-    let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+    let manager = crate::test_support::redis_manager()
+      .await
+      .map_err(|error| TestError::Unexpected(format!("redis: {error}")))?;
+    let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
     let ring = KeyRing::from_parts(
     let ring = KeyRing::from_parts(
       vec![(
       vec![(
         KeyId::new("v1"),
         KeyId::new("v1"),
@@ -561,21 +543,14 @@ mod tests {
       vec![(KeyId::new("v1"), SecretString::from("grant-code-test-totp"))],
       vec![(KeyId::new("v1"), SecretString::from("grant-code-test-totp"))],
       vec![(KeyId::new("v1"), SecretString::from("grant-code-test-pii"))],
       vec![(KeyId::new("v1"), SecretString::from("grant-code-test-pii"))],
     )?;
     )?;
-    Ok((
-      guarded,
-      Harness {
-        _db: db,
-        pool,
-        conn: RedisConn::Single(manager),
-        store: Store::from_parts(
-          "oauth:{state}:".to_owned(),
-          "oauth:{rl}:".to_owned(),
-          DurabilityMode::WaitAof,
-        ),
-        ring,
-        ttl: TtlConfig::default(),
-      },
-    ))
+    Ok(Harness {
+      _db: db,
+      pool,
+      conn: RedisConn::Single(manager),
+      store: Store::from_parts(state_prefix, rl_prefix, DurabilityMode::WaitAof),
+      ring,
+      ttl: TtlConfig::default(),
+    })
   }
   }
 
 
   /// Register a client with the given method, secret, grants, and scope.
   /// Register a client with the given method, secret, grants, and scope.
@@ -719,7 +694,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn test_grant_code_confidential_happy_issues_pair() -> TestResult {
   async fn test_grant_code_confidential_happy_issues_pair() -> TestResult {
-    let (_container, mut h) = harness().await?;
+    let mut h = harness().await?;
     let client_id = unique("conf");
     let client_id = unique("conf");
     insert_client(
     insert_client(
       &h,
       &h,
@@ -792,7 +767,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn test_grant_code_public_dpop_happy_then_replay_rejected() -> TestResult {
   async fn test_grant_code_public_dpop_happy_then_replay_rejected() -> TestResult {
-    let (_container, mut h) = harness().await?;
+    let mut h = harness().await?;
     let client_id = unique("public");
     let client_id = unique("public");
     insert_client(
     insert_client(
       &h,
       &h,
@@ -867,7 +842,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn test_grant_code_wrong_redirect_rejected() -> TestResult {
   async fn test_grant_code_wrong_redirect_rejected() -> TestResult {
-    let (_container, mut h) = harness().await?;
+    let mut h = harness().await?;
     let client_id = unique("conf");
     let client_id = unique("conf");
     insert_client(
     insert_client(
       &h,
       &h,
@@ -924,7 +899,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn test_grant_code_missing_redirect_accepted() -> TestResult {
   async fn test_grant_code_missing_redirect_accepted() -> TestResult {
-    let (_container, mut h) = harness().await?;
+    let mut h = harness().await?;
     let client_id = unique("no-redirect");
     let client_id = unique("no-redirect");
     insert_client(
     insert_client(
       &h,
       &h,
@@ -962,7 +937,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn test_grant_code_wrong_client_rejected() -> TestResult {
   async fn test_grant_code_wrong_client_rejected() -> TestResult {
-    let (_container, mut h) = harness().await?;
+    let mut h = harness().await?;
     let client_a = unique("a");
     let client_a = unique("a");
     let client_b = unique("b");
     let client_b = unique("b");
     for client_id in [&client_a, &client_b] {
     for client_id in [&client_a, &client_b] {
@@ -1005,7 +980,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn test_grant_code_foreign_client_does_not_consume_victim_code() -> TestResult {
   async fn test_grant_code_foreign_client_does_not_consume_victim_code() -> TestResult {
-    let (_container, mut h) = harness().await?;
+    let mut h = harness().await?;
     let client_a = unique("victim");
     let client_a = unique("victim");
     let client_b = unique("foreign");
     let client_b = unique("foreign");
     for client_id in [&client_a, &client_b] {
     for client_id in [&client_a, &client_b] {
@@ -1072,7 +1047,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn test_grant_code_wrong_verifier_rejected() -> TestResult {
   async fn test_grant_code_wrong_verifier_rejected() -> TestResult {
-    let (_container, mut h) = harness().await?;
+    let mut h = harness().await?;
     let client_id = unique("conf");
     let client_id = unique("conf");
     insert_client(
     insert_client(
       &h,
       &h,
@@ -1120,7 +1095,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn test_grant_code_dpop_mismatch_rejected() -> TestResult {
   async fn test_grant_code_dpop_mismatch_rejected() -> TestResult {
-    let (_container, mut h) = harness().await?;
+    let mut h = harness().await?;
     let client_id = unique("public");
     let client_id = unique("public");
     insert_client(
     insert_client(
       &h,
       &h,
@@ -1165,7 +1140,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn test_grant_code_unknown_code_rejected() -> TestResult {
   async fn test_grant_code_unknown_code_rejected() -> TestResult {
-    let (_container, mut h) = harness().await?;
+    let mut h = harness().await?;
     let client_id = unique("conf");
     let client_id = unique("conf");
     insert_client(
     insert_client(
       &h,
       &h,
@@ -1204,7 +1179,7 @@ mod tests {
 
 
   #[tokio::test]
   #[tokio::test]
   async fn test_grant_code_no_refresh_when_grant_disallowed() -> TestResult {
   async fn test_grant_code_no_refresh_when_grant_disallowed() -> TestResult {
-    let (_container, mut h) = harness().await?;
+    let mut h = harness().await?;
     let client_id = unique("norefresh");
     let client_id = unique("norefresh");
     insert_client(
     insert_client(
       &h,
       &h,
@@ -1253,7 +1228,7 @@ mod tests {
   async fn test_grant_code_public_empty_cnf_is_invalid_grant() -> TestResult {
   async fn test_grant_code_public_empty_cnf_is_invalid_grant() -> TestResult {
     use secrecy::ExposeSecret as _;
     use secrecy::ExposeSecret as _;
 
 
-    let (_container, mut h) = harness().await?;
+    let mut h = harness().await?;
     let client_id = unique("public-empty-cnf");
     let client_id = unique("public-empty-cnf");
     insert_client(
     insert_client(
       &h,
       &h,

+ 11 - 44
src/protocol/token/grants/client_credentials/tests.rs

@@ -1,22 +1,14 @@
 //! `test(grant_client_credentials)` suite: confidential-only, scope, bindings.
 //! `test(grant_client_credentials)` suite: confidential-only, scope, bindings.
 //!
 //!
-//! Shared Redis 8 container plus per-test connections; unique client
-//! labels keep the shared keyspace collision-free.
+//! The shared compose Redis plus a per-test unique key-prefix pair; unique
+//! client labels keep the shared keyspace collision-free.
 
 
 mod binding;
 mod binding;
 mod issue;
 mod issue;
 
 
-use std::{
-  sync::atomic::{AtomicU64, Ordering},
-  time::Duration,
-};
+use std::sync::atomic::{AtomicU64, Ordering};
 
 
 use secrecy::SecretString;
 use secrecy::SecretString;
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort as _, WaitFor},
-  runners::AsyncRunner as _,
-};
 
 
 use super::{ClientCredentialsInput, ClientCredentialsTokens};
 use super::{ClientCredentialsInput, ClientCredentialsTokens};
 use crate::{
 use crate::{
@@ -29,7 +21,6 @@ use crate::{
   },
   },
   state::RedisConn,
   state::RedisConn,
   store::{Store, StoreError},
   store::{Store, StoreError},
-  test_guard::{ContainerGuard, Lease, Shared},
 };
 };
 
 
 /// Test body result (no `unwrap`/`expect` under the deny lints).
 /// Test body result (no `unwrap`/`expect` under the deny lints).
@@ -38,9 +29,6 @@ type TestResult = Result<(), TestError>;
 /// Errors raised by the client-credentials test scaffolding itself.
 /// Errors raised by the client-credentials test scaffolding itself.
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 enum TestError {
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   /// A Redis command failed.
   #[error("redis error")]
   #[error("redis error")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
@@ -61,35 +49,14 @@ enum TestError {
   Unexpected(String),
   Unexpected(String),
 }
 }
 
 
-/// One shared Redis 8 container for the whole suite.
-static REDIS: Shared<ContainerGuard> = Shared::new();
-
-/// Fresh connection plus store handle against the shared container.
-async fn test_redis() -> Result<(Lease<ContainerGuard>, RedisConn, Store), TestError> {
-  let container = REDIS
-    .lease::<TestError>(async {
-      let started = GenericImage::new("redis", "8-alpine")
-        .with_exposed_port(6379.tcp())
-        .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-        .with_cmd(["redis-server", "--appendonly", "yes"])
-        .start()
-        .await?;
-      Ok(ContainerGuard::new(started))
-    })
-    .await?;
-  let port = container.get_host_port_ipv4(6379).await?;
-  let url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(url.as_str())?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
-  let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
-    DurabilityMode::WaitAof,
-  );
-  Ok((container, RedisConn::Single(manager), store))
+/// Fresh connection plus store handle against the shared compose Redis.
+async fn test_redis() -> Result<(RedisConn, Store), TestError> {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let store = Store::from_parts(state_prefix, rl_prefix, DurabilityMode::WaitAof);
+  Ok((RedisConn::Single(manager), store))
 }
 }
 
 
 static TEST_NONCE: AtomicU64 = AtomicU64::new(400_000);
 static TEST_NONCE: AtomicU64 = AtomicU64::new(400_000);

+ 7 - 7
src/protocol/token/grants/client_credentials/tests/binding.rs

@@ -10,7 +10,7 @@ use crate::protocol::token::ClientKind;
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_client_credentials_unbound_bearer_ok() -> TestResult {
 async fn grant_client_credentials_unbound_bearer_ok() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("m2m");
   let client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
   let registered = ScopeSet::parse("api:read")?;
@@ -36,7 +36,7 @@ async fn grant_client_credentials_unbound_bearer_ok() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_client_credentials_dpop_bound_no_proof_rejected() -> TestResult {
 async fn grant_client_credentials_dpop_bound_no_proof_rejected() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("m2m");
   let client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
   let registered = ScopeSet::parse("api:read")?;
@@ -75,7 +75,7 @@ async fn grant_client_credentials_dpop_bound_no_proof_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_client_credentials_dpop_bound_stale_proof_rejected() -> TestResult {
 async fn grant_client_credentials_dpop_bound_stale_proof_rejected() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("m2m");
   let client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
   let registered = ScopeSet::parse("api:read")?;
@@ -114,7 +114,7 @@ async fn grant_client_credentials_dpop_bound_stale_proof_rejected() -> TestResul
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_client_credentials_dpop_bound_fresh_proof_ok() -> TestResult {
 async fn grant_client_credentials_dpop_bound_fresh_proof_ok() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("m2m");
   let client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
   let registered = ScopeSet::parse("api:read")?;
@@ -145,7 +145,7 @@ async fn grant_client_credentials_dpop_bound_fresh_proof_ok() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_client_credentials_mtls_bound_no_proof_rejected() -> TestResult {
 async fn grant_client_credentials_mtls_bound_no_proof_rejected() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("m2m");
   let client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
   let registered = ScopeSet::parse("api:read")?;
@@ -184,7 +184,7 @@ async fn grant_client_credentials_mtls_bound_no_proof_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_client_credentials_mtls_bound_stale_proof_rejected() -> TestResult {
 async fn grant_client_credentials_mtls_bound_stale_proof_rejected() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("m2m");
   let client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
   let registered = ScopeSet::parse("api:read")?;
@@ -223,7 +223,7 @@ async fn grant_client_credentials_mtls_bound_stale_proof_rejected() -> TestResul
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_client_credentials_mtls_bound_fresh_proof_ok() -> TestResult {
 async fn grant_client_credentials_mtls_bound_fresh_proof_ok() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("m2m");
   let client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
   let registered = ScopeSet::parse("api:read")?;

+ 5 - 5
src/protocol/token/grants/client_credentials/tests/issue.rs

@@ -10,7 +10,7 @@ use crate::protocol::token::ClientKind;
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_client_credentials_valid_issue() -> TestResult {
 async fn grant_client_credentials_valid_issue() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("m2m");
   let client = unique("m2m");
   let registered = ScopeSet::parse("api:read api:write")?;
   let registered = ScopeSet::parse("api:read api:write")?;
@@ -72,7 +72,7 @@ async fn grant_client_credentials_valid_issue() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_client_credentials_default_scope_narrows_to_registry() -> TestResult {
 async fn grant_client_credentials_default_scope_narrows_to_registry() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("m2m");
   let client = unique("m2m");
   let registered = ScopeSet::parse("api:read api:write")?;
   let registered = ScopeSet::parse("api:read api:write")?;
@@ -98,7 +98,7 @@ async fn grant_client_credentials_default_scope_narrows_to_registry() -> TestRes
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_client_credentials_public_client_rejected() -> TestResult {
 async fn grant_client_credentials_public_client_rejected() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("public");
   let client = unique("public");
   let registered = ScopeSet::parse("api:read")?;
   let registered = ScopeSet::parse("api:read")?;
@@ -137,7 +137,7 @@ async fn grant_client_credentials_public_client_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_client_credentials_over_scope_rejected() -> TestResult {
 async fn grant_client_credentials_over_scope_rejected() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("m2m");
   let client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
   let registered = ScopeSet::parse("api:read")?;
@@ -176,7 +176,7 @@ async fn grant_client_credentials_over_scope_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_client_credentials_no_refresh_token() -> TestResult {
 async fn grant_client_credentials_no_refresh_token() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let client = unique("m2m");
   let client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
   let registered = ScopeSet::parse("api:read")?;

+ 24 - 42
src/protocol/token/grants/device_code/tests.rs

@@ -1,22 +1,14 @@
-//! `test(grant_device)` harness: live Postgres + Redis plus builders.
+//! `test(grant_device)` harness: shared compose Postgres + Redis plus builders.
 //!
 //!
 //! Child suites share these helpers through `super::*`. Every acceptance
 //! Child suites share these helpers through `super::*`. Every acceptance
 //! test drives the public [`super::super::poll_device`] API; DPoP proofs are
 //! test drives the public [`super::super::poll_device`] API; DPoP proofs are
 //! minted per call because each `jti` is single-use.
 //! minted per call because each `jti` is single-use.
 
 
-use std::{
-  sync::atomic::{AtomicU64, Ordering},
-  time::Duration,
-};
+use std::sync::atomic::{AtomicU64, Ordering};
 
 
 use sqlx::PgPool;
 use sqlx::PgPool;
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 
 
-use crate::{config::Config, state::RedisConn, store::Store, test_guard::ContainerGuard};
+use crate::{config::Config, state::RedisConn, store::Store};
 
 
 mod backoff;
 mod backoff;
 mod expired;
 mod expired;
@@ -34,9 +26,6 @@ pub(super) type TestResult = Result<(), TestError>;
 /// Test scaffolding failures.
 /// Test scaffolding failures.
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 pub(super) enum TestError {
 pub(super) enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   /// A Redis command failed.
   #[error("redis error: {0}")]
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
@@ -85,35 +74,31 @@ pub(super) struct Harness {
   pub(super) config: Config,
   pub(super) config: Config,
 }
 }
 
 
-pub(super) async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
+pub(super) async fn harness() -> Result<Harness, TestError> {
   let (db, pool) = crate::db::test_support::migrated_pool()
   let (db, pool) = crate::db::test_support::migrated_pool()
     .await
     .await
     .map_err(|error| TestError::Unexpected(format!("pg: {error}")))?;
     .map_err(|error| TestError::Unexpected(format!("pg: {error}")))?;
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .with_cmd(["redis-server", "--appendonly", "yes"])
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  let redis_url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(redis_url.as_str())?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
   let store = Store::from_parts(
   let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    state_prefix.clone(),
+    rl_prefix.clone(),
     crate::config::DurabilityMode::WaitAof,
     crate::config::DurabilityMode::WaitAof,
   );
   );
+  let redis_url = crate::test_support::test_redis_url();
   let fixture = serde_json::json!({
   let fixture = serde_json::json!({
     "issuer": "https://auth.example.test",
     "issuer": "https://auth.example.test",
     "environment": "development",
     "environment": "development",
     "tls": {"cert_path": "/tmp/grant-device-test-cert.pem", "key_path": "/tmp/grant-device-test-key.pem"},
     "tls": {"cert_path": "/tmp/grant-device-test-cert.pem", "key_path": "/tmp/grant-device-test-key.pem"},
     "database": {"url": "postgres://127.0.0.1:1/grant_device"},
     "database": {"url": "postgres://127.0.0.1:1/grant_device"},
-    "redis": {"url": redis_url, "durability": "waitaof"},
+    "redis": {
+      "url": redis_url,
+      "durability": "waitaof",
+      "token_key_prefix": state_prefix,
+      "ratelimit_key_prefix": rl_prefix,
+    },
     "token_pepper": "grant-device-test-pepper-0123456789abcdef",
     "token_pepper": "grant-device-test-pepper-0123456789abcdef",
     "totp_encryption_key": "grant-device-test-totp-key-0123456789abcdef",
     "totp_encryption_key": "grant-device-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "grant-device-test-pii-key-0123456789abcdef",
     "pii_encryption_key": "grant-device-test-pii-key-0123456789abcdef",
@@ -123,14 +108,11 @@ pub(super) async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
   let source = ::config::Config::try_from(&fixture)
   let source = ::config::Config::try_from(&fixture)
     .map_err(|error| TestError::Unexpected(format!("config source: {error}")))?;
     .map_err(|error| TestError::Unexpected(format!("config source: {error}")))?;
   let config = Config::from_source(source)?;
   let config = Config::from_source(source)?;
-  Ok((
-    guarded,
-    Harness {
-      _db: db,
-      pool,
-      conn: RedisConn::Single(manager),
-      store,
-      config,
-    },
-  ))
+  Ok(Harness {
+    _db: db,
+    pool,
+    conn: RedisConn::Single(manager),
+    store,
+    config,
+  })
 }
 }

+ 4 - 4
src/protocol/token/grants/device_code/tests/backoff.rs

@@ -13,7 +13,7 @@ use crate::{
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_fast_poll_is_slow_down() -> TestResult {
 async fn test_grant_device_fast_poll_is_slow_down() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("public");
   let client_id = unique("public");
   setup::insert_client(
   setup::insert_client(
     &h.pool,
     &h.pool,
@@ -110,7 +110,7 @@ async fn test_grant_device_fast_poll_is_slow_down() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_denied_is_access_denied() -> TestResult {
 async fn test_grant_device_denied_is_access_denied() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("public");
   let client_id = unique("public");
   setup::insert_client(
   setup::insert_client(
     &h.pool,
     &h.pool,
@@ -168,7 +168,7 @@ async fn test_grant_device_denied_is_access_denied() -> TestResult {
 /// a spurious `slow_down`; `slow_down` resumes only inside the window.
 /// a spurious `slow_down`; `slow_down` resumes only inside the window.
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_interval_boundary_not_slow_down() -> TestResult {
 async fn test_grant_device_interval_boundary_not_slow_down() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("public");
   let client_id = unique("public");
   setup::insert_client(
   setup::insert_client(
     &h.pool,
     &h.pool,
@@ -308,7 +308,7 @@ async fn test_grant_device_interval_boundary_not_slow_down() -> TestResult {
 /// stays `access_denied`, never `authorization_pending` and never a mint.
 /// stays `access_denied`, never `authorization_pending` and never a mint.
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_denied_then_poll_stays_access_denied() -> TestResult {
 async fn test_grant_device_denied_then_poll_stays_access_denied() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("public");
   let client_id = unique("public");
   setup::insert_client(
   setup::insert_client(
     &h.pool,
     &h.pool,

+ 1 - 1
src/protocol/token/grants/device_code/tests/expired.rs

@@ -11,7 +11,7 @@ use crate::{
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_expired_token_after_ttl() -> TestResult {
 async fn test_grant_device_expired_token_after_ttl() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("public");
   let client_id = unique("public");
   setup::insert_client(
   setup::insert_client(
     &h.pool,
     &h.pool,

+ 4 - 4
src/protocol/token/grants/device_code/tests/matrix.rs

@@ -14,7 +14,7 @@ use crate::{
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_public_without_dpop_rejected() -> TestResult {
 async fn test_grant_device_public_without_dpop_rejected() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("public");
   let client_id = unique("public");
   setup::insert_client(
   setup::insert_client(
     &h.pool,
     &h.pool,
@@ -66,7 +66,7 @@ async fn test_grant_device_public_without_dpop_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_confidential_secret_redeems_bearer() -> TestResult {
 async fn test_grant_device_confidential_secret_redeems_bearer() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("conf");
   let client_id = unique("conf");
   setup::insert_client(
   setup::insert_client(
     &h.pool,
     &h.pool,
@@ -134,7 +134,7 @@ async fn test_grant_device_confidential_secret_redeems_bearer() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_dpop_bound_confidential_requires_fresh_proof() -> TestResult {
 async fn test_grant_device_dpop_bound_confidential_requires_fresh_proof() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("bound-conf");
   let client_id = unique("bound-conf");
   setup::insert_client_bound(
   setup::insert_client_bound(
     &h.pool,
     &h.pool,
@@ -228,7 +228,7 @@ async fn test_grant_device_dpop_bound_confidential_requires_fresh_proof() -> Tes
 /// code, so the legitimate client still redeems it.
 /// code, so the legitimate client still redeems it.
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_cross_client_poll_is_invalid_grant() -> TestResult {
 async fn test_grant_device_cross_client_poll_is_invalid_grant() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let owner_id = unique("owner");
   let owner_id = unique("owner");
   let intruder_id = unique("intruder");
   let intruder_id = unique("intruder");
   setup::insert_client(
   setup::insert_client(

+ 2 - 2
src/protocol/token/grants/device_code/tests/poll.rs

@@ -20,7 +20,7 @@ use crate::{
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_pending_then_approved_issues_bound_tokens() -> TestResult {
 async fn test_grant_device_pending_then_approved_issues_bound_tokens() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("public");
   let client_id = unique("public");
   setup::insert_client(
   setup::insert_client(
     &h.pool,
     &h.pool,
@@ -156,7 +156,7 @@ async fn test_grant_device_pending_then_approved_issues_bound_tokens() -> TestRe
 /// §3.5 pacing, which the boundary suite pins separately.
 /// §3.5 pacing, which the boundary suite pins separately.
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_concurrent_poll_mints_once() -> TestResult {
 async fn test_grant_device_concurrent_poll_mints_once() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("public");
   let client_id = unique("public");
   setup::insert_client(
   setup::insert_client(
     &h.pool,
     &h.pool,

+ 1 - 1
src/protocol/token/grants/device_code/tests/race.rs

@@ -16,7 +16,7 @@ use crate::store::device::poll_key;
 /// loser while still advancing the stamp.
 /// loser while still advancing the stamp.
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_concurrent_interval_admits_only_one() -> TestResult {
 async fn test_grant_device_concurrent_interval_admits_only_one() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let device_hash = unique("device-race");
   let device_hash = unique("device-race");
   let now = now_unix();
   let now = now_unix();
   let interval = Duration::from_secs(DEVICE_INTERVAL_SECS);
   let interval = Duration::from_secs(DEVICE_INTERVAL_SECS);

+ 2 - 2
src/protocol/token/grants/device_code/tests/scope.rs

@@ -10,7 +10,7 @@ use crate::{
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_unentitled_scope_rejected() -> TestResult {
 async fn test_grant_device_unentitled_scope_rejected() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("public");
   let client_id = unique("public");
   setup::insert_client(
   setup::insert_client(
     &h.pool,
     &h.pool,
@@ -67,7 +67,7 @@ async fn test_grant_device_unentitled_scope_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn test_grant_device_scope_reduced_to_user_entitlement() -> TestResult {
 async fn test_grant_device_scope_reduced_to_user_entitlement() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let client_id = unique("public");
   let client_id = unique("public");
   setup::insert_client(
   setup::insert_client(
     &h.pool,
     &h.pool,

+ 11 - 44
src/protocol/token/grants/token_exchange/tests.rs

@@ -1,23 +1,15 @@
 //! `test(grant_token_exchange)` suite: policy, proofs, scope pinning.
 //! `test(grant_token_exchange)` suite: policy, proofs, scope pinning.
 //!
 //!
-//! Shared Redis 8 container plus per-test connections; unique client
-//! labels keep the shared keyspace collision-free.
+//! Connects to the shared compose Redis with a per-test unique key prefix
+//! pair; unique client labels keep the keyspace collision-free.
 
 
 mod exchange_ok;
 mod exchange_ok;
 mod policy;
 mod policy;
 mod proof;
 mod proof;
 
 
-use std::{
-  sync::atomic::{AtomicU64, Ordering},
-  time::Duration,
-};
+use std::sync::atomic::{AtomicU64, Ordering};
 
 
 use secrecy::SecretString;
 use secrecy::SecretString;
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort as _, WaitFor},
-  runners::AsyncRunner as _,
-};
 
 
 use super::{ExchangeInput, TOKEN_TYPE_ACCESS_TOKEN};
 use super::{ExchangeInput, TOKEN_TYPE_ACCESS_TOKEN};
 use crate::{
 use crate::{
@@ -30,7 +22,6 @@ use crate::{
   },
   },
   state::RedisConn,
   state::RedisConn,
   store::{Store, StoreError},
   store::{Store, StoreError},
-  test_guard::{ContainerGuard, Lease, Shared},
 };
 };
 
 
 /// Test body result (no `unwrap`/`expect` under the deny lints).
 /// Test body result (no `unwrap`/`expect` under the deny lints).
@@ -39,9 +30,6 @@ type TestResult = Result<(), TestError>;
 /// Errors raised by the token-exchange test scaffolding itself.
 /// Errors raised by the token-exchange test scaffolding itself.
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 enum TestError {
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   /// A Redis command failed.
   #[error("redis error")]
   #[error("redis error")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
@@ -62,35 +50,14 @@ enum TestError {
   Unexpected(String),
   Unexpected(String),
 }
 }
 
 
-/// One shared Redis 8 container for the whole suite.
-static REDIS: Shared<ContainerGuard> = Shared::new();
-
-/// Fresh connection plus store handle against the shared container.
-async fn test_redis() -> Result<(Lease<ContainerGuard>, RedisConn, Store), TestError> {
-  let container = REDIS
-    .lease::<TestError>(async {
-      let started = GenericImage::new("redis", "8-alpine")
-        .with_exposed_port(6379.tcp())
-        .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-        .with_cmd(["redis-server", "--appendonly", "yes"])
-        .start()
-        .await?;
-      Ok(ContainerGuard::new(started))
-    })
-    .await?;
-  let port = container.get_host_port_ipv4(6379).await?;
-  let url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(url.as_str())?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
-  let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
-    DurabilityMode::WaitAof,
-  );
-  Ok((container, RedisConn::Single(manager), store))
+/// Fresh connection plus store handle against the shared compose Redis.
+async fn test_redis() -> Result<(RedisConn, Store), TestError> {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let store = Store::from_parts(state_prefix, rl_prefix, DurabilityMode::WaitAof);
+  Ok((RedisConn::Single(manager), store))
 }
 }
 
 
 static TEST_NONCE: AtomicU64 = AtomicU64::new(90_000);
 static TEST_NONCE: AtomicU64 = AtomicU64::new(90_000);

+ 1 - 1
src/protocol/token/grants/token_exchange/tests/exchange_ok.rs

@@ -8,7 +8,7 @@ use crate::protocol::scope::ScopeSet;
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_token_exchange_allowlisted_succeeds() -> TestResult {
 async fn grant_token_exchange_allowlisted_succeeds() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let caller = unique("caller");
   let caller = unique("caller");
   let subject_client = unique("subject-client");
   let subject_client = unique("subject-client");

+ 3 - 3
src/protocol/token/grants/token_exchange/tests/policy.rs

@@ -12,7 +12,7 @@ use crate::protocol::token::SenderProof;
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_token_exchange_unlisted_pair_rejected() -> TestResult {
 async fn grant_token_exchange_unlisted_pair_rejected() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let subject_client = unique("subject-client");
   let subject_client = unique("subject-client");
   let subject_raw = mint_subject(
   let subject_raw = mint_subject(
@@ -63,7 +63,7 @@ async fn grant_token_exchange_unlisted_pair_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_token_exchange_unsupported_types_rejected() -> TestResult {
 async fn grant_token_exchange_unsupported_types_rejected() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let caller = unique("caller");
   let caller = unique("caller");
   let subject_client = unique("subject-client");
   let subject_client = unique("subject-client");
@@ -114,7 +114,7 @@ async fn grant_token_exchange_unsupported_types_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_token_exchange_scope_widening_rejected() -> TestResult {
 async fn grant_token_exchange_scope_widening_rejected() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let caller = unique("caller");
   let caller = unique("caller");
   let subject_client = unique("subject-client");
   let subject_client = unique("subject-client");

+ 3 - 3
src/protocol/token/grants/token_exchange/tests/proof.rs

@@ -12,7 +12,7 @@ use crate::protocol::token::SenderProof;
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_token_exchange_missing_proof_rejected() -> TestResult {
 async fn grant_token_exchange_missing_proof_rejected() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let caller = unique("caller");
   let caller = unique("caller");
   let subject_client = unique("subject-client");
   let subject_client = unique("subject-client");
@@ -63,7 +63,7 @@ async fn grant_token_exchange_missing_proof_rejected() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_token_exchange_subject_binding_mismatch_rejected() -> TestResult {
 async fn grant_token_exchange_subject_binding_mismatch_rejected() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let caller = unique("caller");
   let caller = unique("caller");
   let subject_client = unique("subject-client");
   let subject_client = unique("subject-client");
@@ -114,7 +114,7 @@ async fn grant_token_exchange_subject_binding_mismatch_rejected() -> TestResult
 
 
 #[tokio::test]
 #[tokio::test]
 async fn grant_token_exchange_chained_rejected() -> TestResult {
 async fn grant_token_exchange_chained_rejected() -> TestResult {
-  let (_lease, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service()?;
   let service = service()?;
   let caller = unique("caller");
   let caller = unique("caller");
   let subject_client = unique("subject-client");
   let subject_client = unique("subject-client");

+ 14 - 46
src/protocol/token/tests.rs

@@ -1,7 +1,8 @@
 //! `test(token_service)` suite: mint, hash-store, sender-constraint matrix.
 //! `test(token_service)` suite: mint, hash-store, sender-constraint matrix.
 //!
 //!
-//! Shared Redis 8 container (AOF on) plus per-test connections; unique
-//! grant/client labels keep the shared keyspace collision-free.
+//! The shared compose Redis (`compose.test.yaml`) backs the token store; a
+//! per-test unique `{state}`/`{rl}` prefix pair keeps the keyspace
+//! collision-free, and unique grant/client labels keep lookups distinct.
 
 
 mod matrix;
 mod matrix;
 mod mint_find;
 mod mint_find;
@@ -9,17 +10,9 @@ mod peppers;
 mod rotate;
 mod rotate;
 mod shape;
 mod shape;
 
 
-use std::{
-  sync::atomic::{AtomicU64, Ordering},
-  time::Duration,
-};
+use std::sync::atomic::{AtomicU64, Ordering};
 
 
 use secrecy::SecretString;
 use secrecy::SecretString;
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort as _, WaitFor},
-  runners::AsyncRunner as _,
-};
 
 
 use super::{ClientKind, Cnf, MintRequest, SenderProof, TokenService, TokenServiceError};
 use super::{ClientKind, Cnf, MintRequest, SenderProof, TokenService, TokenServiceError};
 use crate::{
 use crate::{
@@ -27,7 +20,6 @@ use crate::{
   keys::{KeyId, KeyRing, KeyRingError},
   keys::{KeyId, KeyRing, KeyRingError},
   state::RedisConn,
   state::RedisConn,
   store::{Store, StoreError},
   store::{Store, StoreError},
-  test_guard::{ContainerGuard, Lease, Shared},
 };
 };
 
 
 /// Test body result (no `unwrap`/`expect` under the deny lints).
 /// Test body result (no `unwrap`/`expect` under the deny lints).
@@ -36,9 +28,6 @@ type TestResult = Result<(), TestError>;
 /// Errors raised by the token-service test scaffolding itself.
 /// Errors raised by the token-service test scaffolding itself.
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 enum TestError {
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   /// A Redis command failed.
   #[error("redis error")]
   #[error("redis error")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
@@ -56,37 +45,16 @@ enum TestError {
   Unexpected(String),
   Unexpected(String),
 }
 }
 
 
-/// One shared Redis 8 container for the whole suite.
-static REDIS: Shared<ContainerGuard> = Shared::new();
-
-/// Fresh connection plus store handle against the shared container.
-///
-/// The returned lease keeps the container alive; bind it for the test.
-async fn test_redis() -> Result<(Lease<ContainerGuard>, RedisConn, Store), TestError> {
-  let container = REDIS
-    .lease::<TestError>(async {
-      let started = GenericImage::new("redis", "8-alpine")
-        .with_exposed_port(6379.tcp())
-        .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-        .with_cmd(["redis-server", "--appendonly", "yes"])
-        .start()
-        .await?;
-      Ok(ContainerGuard::new(started))
-    })
-    .await?;
-  let port = container.get_host_port_ipv4(6379).await?;
-  let url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(url.as_str())?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
-  let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
-    DurabilityMode::WaitAof,
-  );
-  Ok((container, RedisConn::Single(manager), store))
+/// Fresh connection plus a scoped store handle against the shared compose
+/// Redis. The `{state}`/`{rl}` prefixes are unique per test, so parallel tests
+/// never observe each other's keys.
+async fn test_redis() -> Result<(RedisConn, Store), TestError> {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let store = Store::from_parts(state_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
+  Ok((RedisConn::Single(manager), store))
 }
 }
 
 
 static TEST_NONCE: AtomicU64 = AtomicU64::new(50_000);
 static TEST_NONCE: AtomicU64 = AtomicU64::new(50_000);

+ 1 - 1
src/protocol/token/tests/matrix.rs

@@ -9,7 +9,7 @@ use super::{
 /// Public mint without a `jkt` is rejected; confidential mint is not.
 /// Public mint without a `jkt` is rejected; confidential mint is not.
 #[tokio::test]
 #[tokio::test]
 async fn token_service_public_mint_requires_jkt() -> TestResult {
 async fn token_service_public_mint_requires_jkt() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service(&[("v1", "token-service-pepper-one")])?;
   let service = service(&[("v1", "token-service-pepper-one")])?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");

+ 3 - 3
src/protocol/token/tests/mint_find.rs

@@ -19,7 +19,7 @@ async fn pttl_ms(conn: &mut crate::state::RedisConn, key: &str) -> Result<i64, T
 /// Mint, find, TTL, and single-token revoke round-trip.
 /// Mint, find, TTL, and single-token revoke round-trip.
 #[tokio::test]
 #[tokio::test]
 async fn token_service_mint_find_revoke() -> TestResult {
 async fn token_service_mint_find_revoke() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service(&[("v1", "token-service-pepper-one")])?;
   let service = service(&[("v1", "token-service-pepper-one")])?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
@@ -146,7 +146,7 @@ async fn token_service_mint_find_revoke() -> TestResult {
 /// The grant set holds both hashes; a grant revoke kills the family.
 /// The grant set holds both hashes; a grant revoke kills the family.
 #[tokio::test]
 #[tokio::test]
 async fn token_service_grant_set_holds_hashes_and_revoke_grant() -> TestResult {
 async fn token_service_grant_set_holds_hashes_and_revoke_grant() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service(&[("v1", "token-service-pepper-one")])?;
   let service = service(&[("v1", "token-service-pepper-one")])?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
@@ -197,7 +197,7 @@ async fn token_service_grant_set_holds_hashes_and_revoke_grant() -> TestResult {
 /// the full string label and the minted token still resolves under it.
 /// the full string label and the minted token still resolves under it.
 #[tokio::test]
 #[tokio::test]
 async fn token_service_non_numeric_kid_preserved_end_to_end() -> TestResult {
 async fn token_service_non_numeric_kid_preserved_end_to_end() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service(&[("pepper-2026", "token-service-pepper-one")])?;
   let service = service(&[("pepper-2026", "token-service-pepper-one")])?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");

+ 1 - 1
src/protocol/token/tests/peppers.rs

@@ -9,7 +9,7 @@ use super::{
 /// Lookup retries retained peppers newest-first.
 /// Lookup retries retained peppers newest-first.
 #[tokio::test]
 #[tokio::test]
 async fn token_service_find_retries_retained_peppers() -> TestResult {
 async fn token_service_find_retries_retained_peppers() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
 
 

+ 1 - 1
src/protocol/token/tests/rotate.rs

@@ -12,7 +12,7 @@ use crate::store::tokens as low;
 /// A rotation replayed by `rotation_request_id` returns the SAME tokens.
 /// A rotation replayed by `rotation_request_id` returns the SAME tokens.
 #[tokio::test]
 #[tokio::test]
 async fn token_service_rotation_replay_returns_same_tokens() -> TestResult {
 async fn token_service_rotation_replay_returns_same_tokens() -> TestResult {
-  let (_redis, mut conn, store) = test_redis().await?;
+  let (mut conn, store) = test_redis().await?;
   let service = service(&[("v1", "token-service-pepper-one")])?;
   let service = service(&[("v1", "token-service-pepper-one")])?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");

+ 3 - 0
src/state/test_support.rs

@@ -176,6 +176,9 @@ pub enum TestError {
   /// The layered config source or validation failed.
   /// The layered config source or validation failed.
   #[error("config source error: {0}")]
   #[error("config source error: {0}")]
   Source(#[from] ::config::ConfigError),
   Source(#[from] ::config::ConfigError),
+  /// The shared compose test scaffolding failed to provision a service.
+  #[error("shared test services: {0}")]
+  Shared(#[from] crate::test_support::TestError),
   /// A `docker pause/unpause` call failed.
   /// A `docker pause/unpause` call failed.
   #[error("docker {0} failed")]
   #[error("docker {0} failed")]
   Docker(String),
   Docker(String),

+ 6 - 18
src/state/tests_guard.rs

@@ -7,15 +7,11 @@
 
 
 use std::sync::Arc;
 use std::sync::Arc;
 
 
-use sqlx::postgres::PgPoolOptions;
-
 use super::{
 use super::{
   AppState,
   AppState,
-  test_support::{
-    TestError, TestResult, get_health, postgres_container, redis_single_container,
-    single_redis_block, test_config,
-  },
+  test_support::{TestError, TestResult, get_health, single_redis_block, test_config},
 };
 };
+use crate::test_support::{fresh_database_pool, test_redis_url};
 
 
 /// Seed one legacy row per PII table: every blanked plaintext column non-NULL.
 /// Seed one legacy row per PII table: every blanked plaintext column non-NULL.
 async fn seed_legacy(pool: &sqlx::PgPool) -> Result<(), TestError> {
 async fn seed_legacy(pool: &sqlx::PgPool) -> Result<(), TestError> {
@@ -106,12 +102,8 @@ const BLANKS: [&str; 9] = [
 /// Encrypt-only with any legacy plaintext PII still present is not ready.
 /// Encrypt-only with any legacy plaintext PII still present is not ready.
 #[tokio::test]
 #[tokio::test]
 async fn startup_guard_blocks_encrypt_only_with_legacy_plaintext() -> TestResult {
 async fn startup_guard_blocks_encrypt_only_with_legacy_plaintext() -> TestResult {
-  let (_pg, pg_url) = postgres_container().await?;
-  let (_redis, redis_url) = redis_single_container(None).await?;
-  let admin = PgPoolOptions::new()
-    .max_connections(1)
-    .connect(&pg_url)
-    .await?;
+  let (pg_url, admin) = fresh_database_pool().await?;
+  let redis_url = test_redis_url();
   super::MIGRATOR.run(&admin).await?;
   super::MIGRATOR.run(&admin).await?;
   seed_legacy(&admin).await?;
   seed_legacy(&admin).await?;
   // Helper level: encrypt-only sees the legacy plaintext; dual-write exempts.
   // Helper level: encrypt-only sees the legacy plaintext; dual-write exempts.
@@ -139,12 +131,8 @@ async fn startup_guard_blocks_encrypt_only_with_legacy_plaintext() -> TestResult
 /// A fresh install (no rows) and a fully backfilled DB are ready.
 /// A fresh install (no rows) and a fully backfilled DB are ready.
 #[tokio::test]
 #[tokio::test]
 async fn startup_guard_ok_when_backfilled() -> TestResult {
 async fn startup_guard_ok_when_backfilled() -> TestResult {
-  let (_pg, pg_url) = postgres_container().await?;
-  let (_redis, redis_url) = redis_single_container(None).await?;
-  let admin = PgPoolOptions::new()
-    .max_connections(1)
-    .connect(&pg_url)
-    .await?;
+  let (pg_url, admin) = fresh_database_pool().await?;
+  let redis_url = test_redis_url();
   super::MIGRATOR.run(&admin).await?;
   super::MIGRATOR.run(&admin).await?;
   // Fresh install: no rows at all.
   // Fresh install: no rows at all.
   assert!(!super::probe_transition_unsafe(&admin, false).await?);
   assert!(!super::probe_transition_unsafe(&admin, false).await?);

+ 12 - 34
src/store/pending.rs

@@ -245,11 +245,6 @@ mod tests {
   };
   };
 
 
   use axum::http::StatusCode;
   use axum::http::StatusCode;
-  use testcontainers::{
-    GenericImage, ImageExt,
-    core::{IntoContainerPort, WaitFor},
-    runners::AsyncRunner,
-  };
 
 
   use super::{PendingAuth, PendingError, consume, get, new_request_id, pending_key, put};
   use super::{PendingAuth, PendingError, consume, get, new_request_id, pending_key, put};
   use crate::{
   use crate::{
@@ -257,7 +252,6 @@ mod tests {
     error::{AppError, OAuthErrorCode},
     error::{AppError, OAuthErrorCode},
     state::RedisConn,
     state::RedisConn,
     store::{Store, StoreError},
     store::{Store, StoreError},
-    test_guard::ContainerGuard,
   };
   };
 
 
   /// Test body result (no `unwrap`/`expect` under the deny lints).
   /// Test body result (no `unwrap`/`expect` under the deny lints).
@@ -266,9 +260,6 @@ mod tests {
   /// Errors raised by the pending-auth test scaffolding itself.
   /// Errors raised by the pending-auth test scaffolding itself.
   #[derive(Debug, thiserror::Error)]
   #[derive(Debug, thiserror::Error)]
   enum TestError {
   enum TestError {
-    /// A testcontainers operation failed.
-    #[error("container error: {0}")]
-    Containers(#[from] testcontainers::TestcontainersError),
     /// The Redis configuration was rejected.
     /// The Redis configuration was rejected.
     #[error("config error: {0}")]
     #[error("config error: {0}")]
     Config(#[from] ConfigError),
     Config(#[from] ConfigError),
@@ -310,31 +301,20 @@ mod tests {
     }
     }
   }
   }
 
 
-  /// Start a single-node Redis 8 with AOF on.
-  async fn redis_container() -> Result<(ContainerGuard, String), TestError> {
-    let container = GenericImage::new("redis", "8-alpine")
-      .with_exposed_port(6379.tcp())
-      .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-      .with_cmd(vec!["redis-server", "--appendonly", "yes"])
-      .start()
-      .await?;
-    let guarded = ContainerGuard::new(container);
-    let port = guarded.get_host_port_ipv4(6379).await?;
-    Ok((guarded, format!("redis://127.0.0.1:{port}")))
-  }
-
-  /// Connect a [`RedisConn`] plus [`Store`] directly against a live container.
-  async fn direct_store(url: &str) -> Result<(RedisConn, Store), TestError> {
-    let client = redis::Client::open(url)?;
-    let manager_config = redis::aio::ConnectionManagerConfig::new()
-      .set_response_timeout(Some(Duration::from_secs(10)))
-      .set_connection_timeout(Some(Duration::from_secs(5)));
-    let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+  /// Connect a [`RedisConn`] plus [`Store`] to the shared test Redis, scoping
+  /// every key to the given per-test prefix pair.
+  async fn direct_store(prefixes: (String, String)) -> Result<(RedisConn, Store), TestError> {
+    let manager = crate::test_support::redis_manager()
+      .await
+      .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+    let (token_key_prefix, ratelimit_key_prefix) = prefixes;
     let raw = RawRedisConfig {
     let raw = RawRedisConfig {
-      url: Some(url.to_owned()),
+      url: Some(crate::test_support::test_redis_url()),
       durability: Some(DurabilityMode::WaitAof),
       durability: Some(DurabilityMode::WaitAof),
       response_timeout_secs: 10,
       response_timeout_secs: 10,
       connect_timeout_secs: 5,
       connect_timeout_secs: 5,
+      token_key_prefix: Some(token_key_prefix),
+      ratelimit_key_prefix: Some(ratelimit_key_prefix),
       ..Default::default()
       ..Default::default()
     };
     };
     let config = RedisConfig::from_raw(raw)?;
     let config = RedisConfig::from_raw(raw)?;
@@ -375,8 +355,7 @@ mod tests {
   /// store → load (non-destructive) → consume exactly once; replay rejected.
   /// store → load (non-destructive) → consume exactly once; replay rejected.
   #[tokio::test]
   #[tokio::test]
   async fn pending_auth_store_load_consume_exactly_once() -> TestResult {
   async fn pending_auth_store_load_consume_exactly_once() -> TestResult {
-    let (_container, url) = redis_container().await?;
-    let (mut conn, store) = direct_store(&url).await?;
+    let (mut conn, store) = direct_store(crate::test_support::unique_prefixes()).await?;
     let request_id = unique("req");
     let request_id = unique("req");
     let record = sample_record();
     let record = sample_record();
 
 
@@ -427,8 +406,7 @@ mod tests {
   /// Unknown and expired ids both render `invalid_request`.
   /// Unknown and expired ids both render `invalid_request`.
   #[tokio::test]
   #[tokio::test]
   async fn pending_auth_unknown_and_expired_are_invalid_request() -> TestResult {
   async fn pending_auth_unknown_and_expired_are_invalid_request() -> TestResult {
-    let (_container, url) = redis_container().await?;
-    let (mut conn, store) = direct_store(&url).await?;
+    let (mut conn, store) = direct_store(crate::test_support::unique_prefixes()).await?;
 
 
     // Unknown id: consume rejects, get is empty.
     // Unknown id: consume rejects, get is empty.
     let missing = unique("missing");
     let missing = unique("missing");

+ 178 - 82
src/store/tests_store.rs

@@ -1,8 +1,9 @@
 //! `test(store)` suite: atomic Lua, tombstones, grant index, durability.
 //! `test(store)` suite: atomic Lua, tombstones, grant index, durability.
 //!
 //!
-//! Every test here spins its own single-node Redis 8 container with AOF
-//! enabled (mirroring `state::test_support`), so persistence, barriers, and
-//! concurrent compare-and-set run against a real server.
+//! Most tests share the compose Redis (`crate::test_support`) with a fresh
+//! `unique_prefixes()` pair per test. Only the restart lifecycle test and the
+//! durability-barrier test keep their own single-node Redis 8 containers with
+//! AOF enabled, since they assert server-side persistence behavior.
 
 
 use std::{
 use std::{
   sync::atomic::{AtomicU64, Ordering},
   sync::atomic::{AtomicU64, Ordering},
@@ -21,7 +22,10 @@ use super::{
   sha256_hex, tokens,
   sha256_hex, tokens,
 };
 };
 use crate::{
 use crate::{
-  config::{ConfigError, DurabilityMode, RedisConfig, TtlConfig, redis::RawRedisConfig},
+  config::{
+    ConfigError, DurabilityMode, RedisConfig, TtlConfig,
+    redis::{DEFAULT_RATELIMIT_KEY_PREFIX, DEFAULT_TOKEN_KEY_PREFIX, RawRedisConfig},
+  },
   state::RedisConn,
   state::RedisConn,
   test_guard::ContainerGuard,
   test_guard::ContainerGuard,
 };
 };
@@ -85,17 +89,27 @@ async fn redis_container(extra: Vec<&str>) -> Result<(ContainerGuard, String), T
 async fn direct_store(
 async fn direct_store(
   url: &str,
   url: &str,
   durability: DurabilityMode,
   durability: DurabilityMode,
+  token_prefix: &str,
+  ratelimit_prefix: &str,
 ) -> Result<(RedisConn, Store), TestError> {
 ) -> Result<(RedisConn, Store), TestError> {
-  let client = redis::Client::open(url)?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+  let manager = if url == crate::test_support::test_redis_url() {
+    crate::test_support::redis_manager()
+      .await
+      .map_err(|error| TestError::Unexpected(error.to_string()))?
+  } else {
+    let client = redis::Client::open(url)?;
+    let manager_config = redis::aio::ConnectionManagerConfig::new()
+      .set_response_timeout(Some(Duration::from_secs(10)))
+      .set_connection_timeout(Some(Duration::from_secs(5)));
+    redis::aio::ConnectionManager::new_with_config(client, manager_config).await?
+  };
   let raw = RawRedisConfig {
   let raw = RawRedisConfig {
     url: Some(url.to_owned()),
     url: Some(url.to_owned()),
     durability: Some(durability),
     durability: Some(durability),
     response_timeout_secs: 10,
     response_timeout_secs: 10,
     connect_timeout_secs: 5,
     connect_timeout_secs: 5,
+    token_key_prefix: Some(token_prefix.to_owned()),
+    ratelimit_key_prefix: Some(ratelimit_prefix.to_owned()),
     ..Default::default()
     ..Default::default()
   };
   };
   let config = RedisConfig::from_raw(raw)?;
   let config = RedisConfig::from_raw(raw)?;
@@ -179,8 +193,10 @@ fn assert_ttl_within(pttl: i64, cap: Duration) -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn store_code_consume_exactly_once() -> TestResult {
 async fn store_code_consume_exactly_once() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let hash = unique("code");
   let hash = unique("code");
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
@@ -214,8 +230,10 @@ async fn store_code_consume_exactly_once() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn store_refresh_rotate_idempotent_replay() -> TestResult {
 async fn store_refresh_rotate_idempotent_replay() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
   let old = unique("old-refresh");
   let old = unique("old-refresh");
@@ -285,8 +303,10 @@ async fn store_refresh_rotate_idempotent_replay() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn store_refresh_concurrent_cas_one_rotated_one_reuse() -> TestResult {
 async fn store_refresh_concurrent_cas_one_rotated_one_reuse() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
   let old = unique("old-refresh");
   let old = unique("old-refresh");
@@ -369,8 +389,10 @@ async fn store_refresh_concurrent_cas_one_rotated_one_reuse() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn store_refresh_reuse_without_mapping_cascades() -> TestResult {
 async fn store_refresh_reuse_without_mapping_cascades() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
   let old = unique("old-refresh");
   let old = unique("old-refresh");
@@ -472,8 +494,10 @@ async fn store_refresh_reuse_without_mapping_cascades() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn store_grant_revoke_cascade() -> TestResult {
 async fn store_grant_revoke_cascade() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
   let refresh = unique("refresh");
   let refresh = unique("refresh");
@@ -541,8 +565,10 @@ async fn store_grant_revoke_cascade() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn store_tombstone_detectable_after_rotation() -> TestResult {
 async fn store_tombstone_detectable_after_rotation() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
   let old = unique("old-refresh");
   let old = unique("old-refresh");
@@ -615,7 +641,13 @@ async fn store_persistence_survives_restart() -> TestResult {
   let refresh_hash = unique("refresh");
   let refresh_hash = unique("refresh");
   let jti = unique("jti");
   let jti = unique("jti");
   {
   {
-    let (mut conn, store) = direct_store(&url, DurabilityMode::AppendFsyncAlways).await?;
+    let (mut conn, store) = direct_store(
+      &url,
+      DurabilityMode::AppendFsyncAlways,
+      DEFAULT_TOKEN_KEY_PREFIX,
+      DEFAULT_RATELIMIT_KEY_PREFIX,
+    )
+    .await?;
     codes::issue(
     codes::issue(
       &mut conn,
       &mut conn,
       &store,
       &store,
@@ -642,6 +674,8 @@ async fn store_persistence_survives_restart() -> TestResult {
   let (mut conn, store) = direct_store(
   let (mut conn, store) = direct_store(
     &format!("redis://127.0.0.1:{port}"),
     &format!("redis://127.0.0.1:{port}"),
     DurabilityMode::AppendFsyncAlways,
     DurabilityMode::AppendFsyncAlways,
+    DEFAULT_TOKEN_KEY_PREFIX,
+    DEFAULT_RATELIMIT_KEY_PREFIX,
   )
   )
   .await?;
   .await?;
 
 
@@ -675,7 +709,13 @@ async fn store_persistence_survives_restart() -> TestResult {
 async fn store_durability_barrier_modes() -> TestResult {
 async fn store_durability_barrier_modes() -> TestResult {
   let (_container, url) = redis_container(Vec::new()).await?;
   let (_container, url) = redis_container(Vec::new()).await?;
   // Single-node: WAITAOF barrier succeeds after a write.
   // Single-node: WAITAOF barrier succeeds after a write.
-  let (mut conn, waitaof) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (mut conn, waitaof) = direct_store(
+    &url,
+    DurabilityMode::WaitAof,
+    DEFAULT_TOKEN_KEY_PREFIX,
+    DEFAULT_RATELIMIT_KEY_PREFIX,
+  )
+  .await?;
   durability_barrier(&mut conn, &waitaof).await?;
   durability_barrier(&mut conn, &waitaof).await?;
   // `WAIT 1` on a replica-less node acknowledges zero: fail closed.
   // `WAIT 1` on a replica-less node acknowledges zero: fail closed.
   let wait = Store::from_parts(
   let wait = Store::from_parts(
@@ -701,15 +741,23 @@ async fn store_durability_barrier_modes() -> TestResult {
   }
   }
   // ...and succeeds where the server actually fsyncs always.
   // ...and succeeds where the server actually fsyncs always.
   let (_always_container, always_url) = redis_container(vec!["--appendfsync", "always"]).await?;
   let (_always_container, always_url) = redis_container(vec!["--appendfsync", "always"]).await?;
-  let (mut always_conn, _) = direct_store(&always_url, DurabilityMode::AppendFsyncAlways).await?;
+  let (mut always_conn, _) = direct_store(
+    &always_url,
+    DurabilityMode::AppendFsyncAlways,
+    DEFAULT_TOKEN_KEY_PREFIX,
+    DEFAULT_RATELIMIT_KEY_PREFIX,
+  )
+  .await?;
   durability_barrier(&mut always_conn, &always).await?;
   durability_barrier(&mut always_conn, &always).await?;
   Ok(())
   Ok(())
 }
 }
 
 
 #[tokio::test]
 #[tokio::test]
 async fn store_ratelimit_fixed_window() -> TestResult {
 async fn store_ratelimit_fixed_window() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let bucket = unique("bucket");
   let bucket = unique("bucket");
   let window = Duration::from_secs(60);
   let window = Duration::from_secs(60);
 
 
@@ -744,8 +792,10 @@ async fn store_ratelimit_fixed_window() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn store_replay_markers_first_claim_wins() -> TestResult {
 async fn store_replay_markers_first_claim_wins() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let jti = unique("jti");
   let jti = unique("jti");
   let assertion = unique("assertion");
   let assertion = unique("assertion");
 
 
@@ -778,8 +828,10 @@ async fn store_replay_markers_first_claim_wins() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn store_sessions_and_login_counters() -> TestResult {
 async fn store_sessions_and_login_counters() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let old_session = unique("session");
   let old_session = unique("session");
   let new_session = unique("session");
   let new_session = unique("session");
   let record = sessions::SessionRecord {
   let record = sessions::SessionRecord {
@@ -855,8 +907,10 @@ async fn store_sessions_and_login_counters() -> TestResult {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn store_device_par_and_dcr_round_trip() -> TestResult {
 async fn store_device_par_and_dcr_round_trip() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let client = unique("client");
   let client = unique("client");
 
 
   // Device flow: put, poll, approve, consume exactly once.
   // Device flow: put, poll, approve, consume exactly once.
@@ -1000,8 +1054,10 @@ async fn store_device_par_and_dcr_round_trip() -> TestResult {
 /// lifetime, capped at [`crate::protocol::code::CODE_TTL_CAP`] (60 s).
 /// lifetime, capped at [`crate::protocol::code::CODE_TTL_CAP`] (60 s).
 #[tokio::test]
 #[tokio::test]
 async fn store_code_ttl_within_configured_window() -> TestResult {
 async fn store_code_ttl_within_configured_window() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let ttl = TtlConfig::default();
   let ttl = TtlConfig::default();
   let hash = unique("code");
   let hash = unique("code");
 
 
@@ -1021,8 +1077,10 @@ async fn store_code_ttl_within_configured_window() -> TestResult {
 /// device-code record and its user-code mapping under `ttl.device` (600 s).
 /// device-code record and its user-code mapping under `ttl.device` (600 s).
 #[tokio::test]
 #[tokio::test]
 async fn store_par_device_and_user_code_ttl_within_configured_windows() -> TestResult {
 async fn store_par_device_and_user_code_ttl_within_configured_windows() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let ttl = TtlConfig::default();
   let ttl = TtlConfig::default();
   let client = unique("client");
   let client = unique("client");
 
 
@@ -1074,8 +1132,10 @@ async fn store_par_device_and_user_code_ttl_within_configured_windows() -> TestR
 /// `--ttl`, defaulting to the configured `ttl.dcr_initial_access` (24 h).
 /// `--ttl`, defaulting to the configured `ttl.dcr_initial_access` (24 h).
 #[tokio::test]
 #[tokio::test]
 async fn store_dcr_initial_access_ttl_within_configured_window() -> TestResult {
 async fn store_dcr_initial_access_ttl_within_configured_window() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let ttl = TtlConfig::default();
   let ttl = TtlConfig::default();
   let iat = unique("iat");
   let iat = unique("iat");
 
 
@@ -1101,8 +1161,10 @@ async fn store_dcr_initial_access_ttl_within_configured_window() -> TestResult {
 /// the refresh lifetime, and the per-grant token index lives with the grant.
 /// the refresh lifetime, and the per-grant token index lives with the grant.
 #[tokio::test]
 #[tokio::test]
 async fn store_rotation_grace_tombstone_and_grant_index_ttl() -> TestResult {
 async fn store_rotation_grace_tombstone_and_grant_index_ttl() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
   let old = unique("old-refresh");
   let old = unique("old-refresh");
@@ -1190,8 +1252,10 @@ async fn store_rotation_grace_tombstone_and_grant_index_ttl() -> TestResult {
 /// (12 h); a presession is the same record before login binds a subject.
 /// (12 h); a presession is the same record before login binds a subject.
 #[tokio::test]
 #[tokio::test]
 async fn store_session_and_presession_ttl_within_configured_window() -> TestResult {
 async fn store_session_and_presession_ttl_within_configured_window() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let session_ttl = TtlConfig::default().session;
   let session_ttl = TtlConfig::default().session;
 
 
   let session_id = unique("session");
   let session_id = unique("session");
@@ -1235,8 +1299,10 @@ async fn store_session_and_presession_ttl_within_configured_window() -> TestResu
 /// (`routes::PENDING_TTL`, 300 s).
 /// (`routes::PENDING_TTL`, 300 s).
 #[tokio::test]
 #[tokio::test]
 async fn store_pending_auth_ttl_within_request_window() -> TestResult {
 async fn store_pending_auth_ttl_within_request_window() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let request_id = unique("pending");
   let request_id = unique("pending");
 
 
   pending::put(
   pending::put(
@@ -1266,8 +1332,10 @@ async fn store_pending_auth_ttl_within_request_window() -> TestResult {
 /// (`ui::login::LOCKOUT_WINDOW`, 15 min) and keys a hash of the account label.
 /// (`ui::login::LOCKOUT_WINDOW`, 15 min) and keys a hash of the account label.
 #[tokio::test]
 #[tokio::test]
 async fn store_login_failure_counter_ttl_within_lockout_window() -> TestResult {
 async fn store_login_failure_counter_ttl_within_lockout_window() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let window = crate::ui::login::LOCKOUT_WINDOW;
   let window = crate::ui::login::LOCKOUT_WINDOW;
   let account = unique("alice@example.test");
   let account = unique("alice@example.test");
 
 
@@ -1280,8 +1348,10 @@ async fn store_login_failure_counter_ttl_within_lockout_window() -> TestResult {
 /// leaves the account counter and every other IP's bucket intact.
 /// leaves the account counter and every other IP's bucket intact.
 #[tokio::test]
 #[tokio::test]
 async fn store_ip_failure_clear_is_scoped_to_source() -> TestResult {
 async fn store_ip_failure_clear_is_scoped_to_source() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let window = crate::ui::login::LOCKOUT_WINDOW;
   let window = crate::ui::login::LOCKOUT_WINDOW;
   let account = unique("alice@example.test");
   let account = unique("alice@example.test");
 
 
@@ -1314,8 +1384,10 @@ async fn store_ip_failure_clear_is_scoped_to_source() -> TestResult {
 /// bounded by the assertion's remaining validity (at most 360 s).
 /// bounded by the assertion's remaining validity (at most 360 s).
 #[tokio::test]
 #[tokio::test]
 async fn store_replay_marker_ttl_within_proof_window() -> TestResult {
 async fn store_replay_marker_ttl_within_proof_window() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
 
 
   let dpop_ttl = crate::jose::dpop::DPOP_JTI_TTL;
   let dpop_ttl = crate::jose::dpop::DPOP_JTI_TTL;
   let jti = unique("jti");
   let jti = unique("jti");
@@ -1659,8 +1731,10 @@ async fn store_retry_budget_bounds_idempotent_reads() -> TestResult {
   use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
   use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
   static ATTEMPTS: AtomicU32 = AtomicU32::new(0);
   static ATTEMPTS: AtomicU32 = AtomicU32::new(0);
   ATTEMPTS.store(0, AtomicOrdering::Relaxed);
   ATTEMPTS.store(0, AtomicOrdering::Relaxed);
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut _conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut _conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let outcome: Result<(), redis::RedisError> = retry_op!(&mut _conn, store.retry_budget(), async {
   let outcome: Result<(), redis::RedisError> = retry_op!(&mut _conn, store.retry_budget(), async {
     ATTEMPTS.fetch_add(1, AtomicOrdering::Relaxed);
     ATTEMPTS.fetch_add(1, AtomicOrdering::Relaxed);
     Err::<(), redis::RedisError>(redis::RedisError::from((
     Err::<(), redis::RedisError>(redis::RedisError::from((
@@ -1686,8 +1760,10 @@ async fn store_retry_budget_bounds_idempotent_reads() -> TestResult {
 /// TTL, and a rewrite re-arms the expiry (never a persistent key).
 /// TTL, and a rewrite re-arms the expiry (never a persistent key).
 #[tokio::test]
 #[tokio::test]
 async fn store_hset_record_writes_fields_and_ttl() -> TestResult {
 async fn store_hset_record_writes_fields_and_ttl() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, _store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, _store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let key = unique("hset-record");
   let key = unique("hset-record");
   let ttl = Duration::from_secs(300);
   let ttl = Duration::from_secs(300);
   let pairs = vec![
   let pairs = vec![
@@ -1750,8 +1826,10 @@ async fn store_hset_record_writes_fields_and_ttl() -> TestResult {
 /// member records behind it.
 /// member records behind it.
 #[tokio::test]
 #[tokio::test]
 async fn store_grant_revoke_exhaustion_is_not_silent() -> TestResult {
 async fn store_grant_revoke_exhaustion_is_not_silent() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
   let seed = unique("seed-refresh");
   let seed = unique("seed-refresh");
@@ -1820,8 +1898,10 @@ async fn store_grant_revoke_exhaustion_is_not_silent() -> TestResult {
 /// admission).
 /// admission).
 #[tokio::test]
 #[tokio::test]
 async fn claim_poll_slot_corrupt_marker_fails_closed() -> TestResult {
 async fn claim_poll_slot_corrupt_marker_fails_closed() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let device_hash = unique("device");
   let device_hash = unique("device");
   let key = device::poll_key(&store, &device_hash);
   let key = device::poll_key(&store, &device_hash);
   let _: () = redis::cmd("SET")
   let _: () = redis::cmd("SET")
@@ -1928,8 +2008,10 @@ fn store_error_classification_lua_runtime_is_500() -> TestResult {
 /// the delete, i.e. a lost update.
 /// the delete, i.e. a lost update.
 #[tokio::test]
 #[tokio::test]
 async fn store_device_decide_consume_leaves_no_zombie() -> TestResult {
 async fn store_device_decide_consume_leaves_no_zombie() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let client = unique("client");
   let client = unique("client");
   for round in 0..100u32 {
   for round in 0..100u32 {
     let device_hash = format!("{}-{round:03}", unique("device-race"));
     let device_hash = format!("{}-{round:03}", unique("device-race"));
@@ -2006,8 +2088,10 @@ async fn store_device_decide_consume_leaves_no_zombie() -> TestResult {
 /// (fail closed, no cascade on a fenced write).
 /// (fail closed, no cascade on a fenced write).
 #[tokio::test]
 #[tokio::test]
 async fn store_grant_revoke_fence_rejects_rotation_into_revoked_grant() -> TestResult {
 async fn store_grant_revoke_fence_rejects_rotation_into_revoked_grant() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
   let old = unique("old-refresh");
   let old = unique("old-refresh");
@@ -2099,8 +2183,10 @@ async fn store_grant_revoke_fence_rejects_rotation_into_revoked_grant() -> TestR
 /// a mint that lands mid-cascade is deleted by a later pass.
 /// a mint that lands mid-cascade is deleted by a later pass.
 #[tokio::test]
 #[tokio::test]
 async fn store_grant_revoke_drains_mid_cascade_members() -> TestResult {
 async fn store_grant_revoke_drains_mid_cascade_members() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let grant = unique("grant");
   let grant = unique("grant");
   let client = unique("client");
   let client = unique("client");
 
 
@@ -2199,11 +2285,13 @@ async fn store_grant_revoke_drains_mid_cascade_members() -> TestResult {
 /// tombstoned old token still cascades (fail closed, no tokens).
 /// tombstoned old token still cascades (fail closed, no tokens).
 #[tokio::test]
 #[tokio::test]
 async fn store_rotation_barrier_failure_returns_replay_for_originating_id() -> TestResult {
 async fn store_rotation_barrier_failure_returns_replay_for_originating_id() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, durable) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, durable) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let fragile = Store::from_parts(
   let fragile = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    token_prefix.clone(),
+    rl_prefix.clone(),
     DurabilityMode::Wait,
     DurabilityMode::Wait,
   );
   );
   let grant = unique("grant");
   let grant = unique("grant");
@@ -2302,11 +2390,13 @@ async fn store_rotation_barrier_failure_returns_replay_for_originating_id() -> T
 /// claimant still loses while the marker stands.
 /// claimant still loses while the marker stands.
 #[tokio::test]
 #[tokio::test]
 async fn store_replay_marker_barrier_failure_does_not_burn_proof() -> TestResult {
 async fn store_replay_marker_barrier_failure_does_not_burn_proof() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, durable) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, durable) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let fragile = Store::from_parts(
   let fragile = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    token_prefix.clone(),
+    rl_prefix.clone(),
     DurabilityMode::Wait,
     DurabilityMode::Wait,
   );
   );
   let jti = unique("jti");
   let jti = unique("jti");
@@ -2342,8 +2432,10 @@ async fn store_replay_marker_barrier_failure_does_not_burn_proof() -> TestResult
 /// live expiry instead of becoming persistent (or erroring out).
 /// live expiry instead of becoming persistent (or erroring out).
 #[tokio::test]
 #[tokio::test]
 async fn store_hset_record_empty_pairs_keeps_ttl() -> TestResult {
 async fn store_hset_record_empty_pairs_keeps_ttl() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, _store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, _store) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let key = unique("hset-empty");
   let key = unique("hset-empty");
   let ttl = Duration::from_secs(300);
   let ttl = Duration::from_secs(300);
   let pairs = vec![("client_id".to_owned(), "client-1".to_owned())];
   let pairs = vec![("client_id".to_owned(), "client-1".to_owned())];
@@ -2367,11 +2459,13 @@ async fn store_hset_record_empty_pairs_keeps_ttl() -> TestResult {
 /// pre-mint must skip it (`Ok`) while the barriered write fails closed.
 /// pre-mint must skip it (`Ok`) while the barriered write fails closed.
 #[tokio::test]
 #[tokio::test]
 async fn store_put_access_pending_skips_barrier() -> TestResult {
 async fn store_put_access_pending_skips_barrier() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, _) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, _) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let fragile = Store::from_parts(
   let fragile = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    token_prefix.clone(),
+    rl_prefix.clone(),
     DurabilityMode::Wait,
     DurabilityMode::Wait,
   );
   );
   let grant = unique("grant");
   let grant = unique("grant");
@@ -2419,11 +2513,13 @@ async fn store_put_access_pending_skips_barrier() -> TestResult {
 /// delete that recovered pair, so no unbarriered token survives revocation.
 /// delete that recovered pair, so no unbarriered token survives revocation.
 #[tokio::test]
 #[tokio::test]
 async fn store_recovered_pair_revocable_after_unbarriered_rotation() -> TestResult {
 async fn store_recovered_pair_revocable_after_unbarriered_rotation() -> TestResult {
-  let (_container, url) = redis_container(Vec::new()).await?;
-  let (mut conn, durable) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let (token_prefix, rl_prefix) = crate::test_support::unique_prefixes();
+  let url = crate::test_support::test_redis_url();
+  let (mut conn, durable) =
+    direct_store(&url, DurabilityMode::WaitAof, &token_prefix, &rl_prefix).await?;
   let fragile = Store::from_parts(
   let fragile = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    token_prefix.clone(),
+    rl_prefix.clone(),
     DurabilityMode::Wait,
     DurabilityMode::Wait,
   );
   );
   let grant = unique("grant");
   let grant = unique("grant");

+ 7 - 24
src/store/tokens.rs

@@ -675,26 +675,19 @@ fn store_redis(error: StoreError) -> redis::RedisError {
 mod put_member_tests {
 mod put_member_tests {
   use std::time::Duration;
   use std::time::Duration;
 
 
-  use testcontainers::{
-    GenericImage, ImageExt,
-    core::{IntoContainerPort, WaitFor},
-    runners::AsyncRunner,
-  };
-
   use super::{AccessRecord, RefreshRecord, get_access, get_refresh, put_access, put_refresh};
   use super::{AccessRecord, RefreshRecord, get_access, get_refresh, put_access, put_refresh};
   use crate::{
   use crate::{
     config::{DurabilityMode, RedisConfig, redis::RawRedisConfig},
     config::{DurabilityMode, RedisConfig, redis::RawRedisConfig},
     state::RedisConn,
     state::RedisConn,
     store::{Store, StoreError},
     store::{Store, StoreError},
-    test_guard::ContainerGuard,
   };
   };
 
 
   type TestResult = Result<(), TestError>;
   type TestResult = Result<(), TestError>;
 
 
   #[derive(Debug, thiserror::Error)]
   #[derive(Debug, thiserror::Error)]
   enum TestError {
   enum TestError {
-    #[error("container error: {0}")]
-    Containers(#[from] testcontainers::TestcontainersError),
+    #[error("test support error: {0}")]
+    Support(#[from] crate::test_support::TestError),
     #[error("config error: {0}")]
     #[error("config error: {0}")]
     Config(#[from] crate::config::ConfigError),
     Config(#[from] crate::config::ConfigError),
     #[error("redis error: {0}")]
     #[error("redis error: {0}")]
@@ -713,26 +706,16 @@ mod put_member_tests {
   /// shortens the set TTL.
   /// shortens the set TTL.
   #[tokio::test]
   #[tokio::test]
   async fn put_member_writes_record_and_index_atomically() -> TestResult {
   async fn put_member_writes_record_and_index_atomically() -> TestResult {
-    let container = GenericImage::new("redis", "8-alpine")
-      .with_exposed_port(6379.tcp())
-      .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-      .with_cmd(vec!["redis-server", "--appendonly", "yes"])
-      .start()
-      .await?;
-    let guarded = ContainerGuard::new(container);
-    let port = guarded.get_host_port_ipv4(6379).await?;
-    let url = format!("redis://127.0.0.1:{port}");
-    let client = redis::Client::open(url.as_str())?;
-    let manager_config = redis::aio::ConnectionManagerConfig::new()
-      .set_response_timeout(Some(Duration::from_secs(10)))
-      .set_connection_timeout(Some(Duration::from_secs(5)));
-    let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+    let manager = crate::test_support::redis_manager().await?;
+    let (token_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
     let mut conn = RedisConn::Single(manager);
     let mut conn = RedisConn::Single(manager);
     let raw = RawRedisConfig {
     let raw = RawRedisConfig {
-      url: Some(url),
+      url: Some(crate::test_support::test_redis_url()),
       durability: Some(DurabilityMode::WaitAof),
       durability: Some(DurabilityMode::WaitAof),
       response_timeout_secs: 10,
       response_timeout_secs: 10,
       connect_timeout_secs: 5,
       connect_timeout_secs: 5,
+      token_key_prefix: Some(token_prefix),
+      ratelimit_key_prefix: Some(ratelimit_prefix),
       ..Default::default()
       ..Default::default()
     };
     };
     let store = Store::from_config(&RedisConfig::from_raw(raw)?);
     let store = Store::from_config(&RedisConfig::from_raw(raw)?);

+ 9 - 1
src/test_guard.rs

@@ -7,7 +7,8 @@
 //! runs and the container is orphaned. Relying on `Drop` alone therefore leaks
 //! runs and the container is orphaned. Relying on `Drop` alone therefore leaks
 //! one container per test process.
 //! one container per test process.
 //!
 //!
-//! This module provides the two primitives every harness uses instead:
+//! This module provides the two primitives the remaining container harnesses
+//! use instead:
 //!
 //!
 //! * [`ContainerGuard`] owns one `GenericImage` container and removes it
 //! * [`ContainerGuard`] owns one `GenericImage` container and removes it
 //!   synchronously in its `Drop`: the container is taken out of the guard,
 //!   synchronously in its `Drop`: the container is taken out of the guard,
@@ -32,6 +33,13 @@
 //! with `#[path = "../src/test_guard.rs"] mod test_guard;` (or
 //! with `#[path = "../src/test_guard.rs"] mod test_guard;` (or
 //! `"../../src/test_guard.rs"` from `tests/common/`), so every compilation
 //! `"../../src/test_guard.rs"` from `tests/common/`), so every compilation
 //! unit compiles its own copy from a single implementation.
 //! unit compiles its own copy from a single implementation.
+//!
+//! Most `src/**` unit tests no longer containerize: they use the compose-first
+//! helpers in `src/test_support.rs`. This module is retained only for the
+//! remaining testcontainers keep-list: the lifecycle/resilience suite, the
+//! topology suite, the `--appendfsync always` refresh-token suite, the two
+//! Redis-specific tests in `src/store/tests_store.rs`, and the
+//! private-container `dcr_manage_token_survives_redis_flush`.
 
 
 use std::{ops::Deref, sync::Arc};
 use std::{ops::Deref, sync::Arc};
 
 

+ 222 - 0
src/test_support.rs

@@ -0,0 +1,222 @@
+//! Compose-first test scaffolding for the `src/**` unit-test suites.
+//!
+//! Unit tests under `src/` connect to the shared external services started by
+//! `docker compose -f compose.test.yaml up -d` (Postgres on `127.0.0.1:5433`,
+//! Redis on `127.0.0.1:6380`) instead of booting a per-test container. Per-test
+//! isolation comes from a freshly created database per [`fresh_database_pool`]
+//! call and from a distinct [`unique_prefixes`] pair per test, so parallel
+//! tests never observe each other's rows or Redis keys.
+//!
+//! This module is context-agnostic on purpose: it references only external
+//! crate types ([`sqlx`], [`redis`], [`uuid`]) plus URLs, DDL, and key
+//! prefixes, never an internal module path, so the helper stays reachable
+//! without a single-source dependency on `tests/common/mod.rs`. It imports no
+//! container runtime either: the suites that genuinely need container control
+//! (lifecycle, topology, a non-default server config) keep using the test
+//! guard directly.
+//!
+//! Both URLs honour the same environment overrides as the integration suites
+//! (`D_OAUTH_TEST_POSTGRES_URL`, `D_OAUTH_TEST_REDIS_URL`) through the internal
+//! [`resolve_url`], so the override/default contract is testable without
+//! `std::env::set_var` (which is `unsafe` under edition 2024 and forbidden by
+//! the crate's `forbid(unsafe_code)`).
+
+use std::time::Duration;
+
+use redis::aio::{ConnectionManager, ConnectionManagerConfig};
+use sqlx::{Executor as _, PgPool, postgres::PgPoolOptions};
+
+/// Hint shown when the shared test services are unreachable.
+const COMPOSE_HINT: &str = "run `docker compose -f compose.test.yaml up -d`";
+
+/// Default URL of the shared test Postgres server (the `postgres` maintenance
+/// database), matching `compose.test.yaml` (`test-db`, port 5433).
+const DEFAULT_POSTGRES_ADMIN_URL: &str =
+  "postgres://postgres:oauth-test-only@127.0.0.1:5433/postgres?sslmode=disable";
+
+/// Default URL of the shared test Redis server, matching `compose.test.yaml`
+/// (`test-redis`, port 6380).
+const DEFAULT_REDIS_URL: &str = "redis://127.0.0.1:6380";
+
+/// Aggregate error so test bodies use `?` instead of panicking.
+///
+/// `Send + Sync + 'static`, so a caller returning
+/// `Box<dyn std::error::Error + Send + Sync>` can `?` a `Result<_, TestError>`.
+#[derive(Debug, thiserror::Error)]
+pub enum TestError {
+  /// The shared test service is unreachable or misconfigured.
+  #[error("test setup: {0}")]
+  Setup(String),
+  /// A sqlx operation failed.
+  #[error("database error: {0}")]
+  Database(#[from] sqlx::Error),
+}
+
+/// URL of the shared test Postgres server (the `postgres` maintenance DB).
+///
+/// Overridable via `D_OAUTH_TEST_POSTGRES_URL`; the default matches
+/// `compose.test.yaml` (`test-db`, port 5433).
+pub fn test_postgres_admin_url() -> String {
+  resolve_url(
+    std::env::var("D_OAUTH_TEST_POSTGRES_URL").ok().as_deref(),
+    DEFAULT_POSTGRES_ADMIN_URL,
+  )
+}
+
+/// URL of the shared test Redis server.
+///
+/// Overridable via `D_OAUTH_TEST_REDIS_URL`; the default matches
+/// `compose.test.yaml` (`test-redis`, port 6380).
+pub fn test_redis_url() -> String {
+  resolve_url(
+    std::env::var("D_OAUTH_TEST_REDIS_URL").ok().as_deref(),
+    DEFAULT_REDIS_URL,
+  )
+}
+
+/// Per-test unique Redis key prefixes (token + rate-limit keyspaces).
+///
+/// Each prefix carries its cluster hash tag (`{state}` / `{rl}`) so the
+/// server/store validation accepts them, plus a uuid so parallel tests never
+/// see each other's keys. Shared state is never flushed.
+pub fn unique_prefixes() -> (String, String) {
+  let tag = uuid::Uuid::new_v4().simple().to_string();
+  (
+    format!("oauth:{{state}}:t{tag}:"),
+    format!("oauth:{{rl}}:r{tag}:"),
+  )
+}
+
+/// Connect a pool on a FRESH uniquely-named database on the shared server.
+///
+/// Returns both the fresh database URL and a pool to it. The database is empty
+/// (no migrations are applied); callers apply exactly the migration SQL they
+/// assert on. A connection failure names the compose file to start.
+pub async fn fresh_database_pool() -> Result<(String, PgPool), TestError> {
+  let server_url = test_postgres_admin_url();
+  let admin = PgPoolOptions::new()
+    .max_connections(1)
+    .connect(&server_url)
+    .await
+    .map_err(|error| connect_hint(&server_url, &error))?;
+  let name = format!("tst_{}", uuid::Uuid::new_v4().simple());
+  // `CREATE DATABASE` cannot take a bind parameter, and `&Pool`'s `Executor`
+  // requires a `'static` query, so leak this one tiny test-only string.
+  let ddl: &'static str = Box::leak(format!("CREATE DATABASE \"{name}\"").into_boxed_str());
+  let created = admin
+    .execute(ddl)
+    .await
+    .map_err(|error| TestError::Setup(format!("failed to create test database {name}: {error}")));
+  admin.close().await;
+  created?;
+  let target = with_database(&server_url, &name);
+  let pool = PgPoolOptions::new()
+    .max_connections(4)
+    .connect(&target)
+    .await
+    .map_err(|error| connect_hint(&target, &error))?;
+  Ok((target, pool))
+}
+
+/// A `ConnectionManager` to the shared test Redis server.
+///
+/// Uses the suite-wide timeout convention: a 10 s response timeout (a blocking
+/// `WAITAOF` can wait a full fsync cycle) and a 5 s connection timeout. A
+/// connection failure names the compose file to start.
+pub async fn redis_manager() -> Result<ConnectionManager, TestError> {
+  let url = test_redis_url();
+  let client = redis::Client::open(url.as_str()).map_err(|error| {
+    TestError::Setup(format!(
+      "cannot open shared test Redis at {url}: {error}; {COMPOSE_HINT}"
+    ))
+  })?;
+  let config = ConnectionManagerConfig::new()
+    .set_response_timeout(Some(Duration::from_secs(10)))
+    .set_connection_timeout(Some(Duration::from_secs(5)));
+  let manager = ConnectionManager::new_with_config(client, config)
+    .await
+    .map_err(|error| {
+      TestError::Setup(format!(
+        "cannot reach shared test Redis at {url}: {error}; {COMPOSE_HINT}"
+      ))
+    })?;
+  Ok(manager)
+}
+
+/// Resolve a URL from an optional override, falling back to the given default.
+fn resolve_url(override_url: Option<&str>, default: &str) -> String {
+  override_url.unwrap_or(default).to_owned()
+}
+
+/// Replace the database (path) component of `server_url` with `name`,
+/// preserving any query string. Works with or without a `?`-query.
+fn with_database(server_url: &str, name: &str) -> String {
+  let (base, query) = match server_url.split_once('?') {
+    Some((base, query)) => (base, Some(query)),
+    None => (server_url, None),
+  };
+  let base = base.rsplit_once('/').map_or(base, |(prefix, _db)| prefix);
+  match query {
+    Some(query) => format!("{base}/{name}?{query}"),
+    None => format!("{base}/{name}"),
+  }
+}
+
+/// Helpful error for an unreachable shared Postgres (never a bare driver error).
+fn connect_hint(url: &str, error: &sqlx::Error) -> TestError {
+  TestError::Setup(format!(
+    "cannot reach shared test Postgres at {url}: {error}; {COMPOSE_HINT}"
+  ))
+}
+
+#[cfg(test)]
+mod tests {
+  use super::{
+    DEFAULT_POSTGRES_ADMIN_URL, DEFAULT_REDIS_URL, resolve_url, unique_prefixes, with_database,
+  };
+
+  #[test]
+  fn test_support_defaults_to_compose_ports() {
+    assert_eq!(
+      resolve_url(None, DEFAULT_POSTGRES_ADMIN_URL),
+      "postgres://postgres:oauth-test-only@127.0.0.1:5433/postgres?sslmode=disable"
+    );
+    assert_eq!(
+      resolve_url(Some("redis://example:9999"), DEFAULT_REDIS_URL),
+      "redis://example:9999"
+    );
+  }
+
+  #[test]
+  fn fresh_db_url_replaces_database() {
+    let name = "tst_abc123";
+    assert_eq!(
+      with_database(
+        "postgres://postgres:oauth-test-only@127.0.0.1:5433/postgres?sslmode=disable",
+        name
+      ),
+      "postgres://postgres:oauth-test-only@127.0.0.1:5433/tst_abc123?sslmode=disable"
+    );
+    assert_eq!(
+      with_database(
+        "postgres://postgres:oauth-test-only@127.0.0.1:5433/postgres",
+        name
+      ),
+      "postgres://postgres:oauth-test-only@127.0.0.1:5433/tst_abc123"
+    );
+  }
+
+  #[test]
+  fn unique_prefixes_are_distinct() {
+    let (state_a, rl_a) = unique_prefixes();
+    let (state_b, rl_b) = unique_prefixes();
+    assert!(state_a.starts_with("oauth:{state}:t"));
+    assert!(rl_a.starts_with("oauth:{rl}:r"));
+    assert!(state_b.starts_with("oauth:{state}:t"));
+    assert!(rl_b.starts_with("oauth:{rl}:r"));
+    let mut seen = vec![state_a, rl_a, state_b, rl_b];
+    seen.sort();
+    seen.dedup();
+    assert_eq!(seen.len(), 4);
+  }
+}

+ 4 - 28
src/ui/consent/tests.rs

@@ -12,11 +12,6 @@ use std::time::Duration;
 use axum::http::StatusCode;
 use axum::http::StatusCode;
 use secrecy::SecretString;
 use secrecy::SecretString;
 use sqlx::{PgPool, Row as _};
 use sqlx::{PgPool, Row as _};
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use uuid::Uuid;
 use uuid::Uuid;
 
 
 use super::{
 use super::{
@@ -45,7 +40,7 @@ use crate::{
     pending::{self, PendingAuth},
     pending::{self, PendingAuth},
     sessions,
     sessions,
   },
   },
-  test_guard::{ContainerGuard, Lease},
+  test_guard::Lease,
   ui::login::form_token,
   ui::login::form_token,
 };
 };
 
 
@@ -66,39 +61,20 @@ struct Harness {
   store: Store,
   store: Store,
   /// PII key ring for the encrypted user-email lookup (strict encrypt-only).
   /// PII key ring for the encrypted user-email lookup (strict encrypt-only).
   keys: KeyRing,
   keys: KeyRing,
-  /// Redis container guard (synchronous removal on drop).
-  _redis: ContainerGuard,
 }
 }
 
 
 /// Start Postgres (fresh DB) + Redis for one test.
 /// Start Postgres (fresh DB) + Redis for one test.
 async fn harness() -> Result<Harness, TestError> {
 async fn harness() -> Result<Harness, TestError> {
   let (db, pool) = crate::db::test_support::migrated_pool().await?;
   let (db, pool) = crate::db::test_support::migrated_pool().await?;
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .with_cmd(["redis-server", "--appendonly", "yes"])
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  let url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(url.as_str())?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
-  let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
-    DurabilityMode::WaitAof,
-  );
+  let manager = crate::test_support::redis_manager().await?;
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let store = Store::from_parts(state_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
   Ok(Harness {
   Ok(Harness {
     _db: db,
     _db: db,
     pool,
     pool,
     conn: RedisConn::Single(manager),
     conn: RedisConn::Single(manager),
     store,
     store,
     keys: test_ring()?,
     keys: test_ring()?,
-    _redis: guarded,
   })
   })
 }
 }
 
 

+ 14 - 30
src/ui/login/tests.rs

@@ -1,7 +1,8 @@
 //! `test(login)` harness: live Postgres + Redis, user provisioning, presessions.
 //! `test(login)` harness: live Postgres + Redis, user provisioning, presessions.
 //!
 //!
 //! Child suites (`auth`, `enroll`) drive the public [`super`] API through
 //! Child suites (`auth`, `enroll`) drive the public [`super`] API through
-//! these helpers; every test owns a fresh database and Redis container.
+//! these helpers; every test owns a fresh database and a unique Redis prefix
+//! pair on the shared compose Redis.
 
 
 use serde_json::json;
 use serde_json::json;
 use sqlx::PgPool;
 use sqlx::PgPool;
@@ -9,11 +10,6 @@ use std::{
   net::{IpAddr, Ipv4Addr, SocketAddr},
   net::{IpAddr, Ipv4Addr, SocketAddr},
   time::Duration,
   time::Duration,
 };
 };
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 use uuid::Uuid;
 use uuid::Uuid;
 
 
@@ -28,7 +24,6 @@ use crate::{
   state::RedisConn,
   state::RedisConn,
   store::Store,
   store::Store,
   store::pending::{self, PendingAuth},
   store::pending::{self, PendingAuth},
-  test_guard::ContainerGuard,
 };
 };
 
 
 pub(super) mod auth;
 pub(super) mod auth;
@@ -46,9 +41,6 @@ pub(super) type TestResult = Result<(), TestError>;
 /// Scaffolding failures for the login suites.
 /// Scaffolding failures for the login suites.
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
 pub(super) enum TestError {
 pub(super) enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   /// A Redis command failed.
   #[error("redis error: {0}")]
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
@@ -87,8 +79,6 @@ pub(super) struct Harness {
   pub(super) store: Store,
   pub(super) store: Store,
   /// Validated config (weak Argon2 for speed, test keys).
   /// Validated config (weak Argon2 for speed, test keys).
   pub(super) config: std::sync::Arc<Config>,
   pub(super) config: std::sync::Arc<Config>,
-  /// Redis container guard (synchronous removal on drop).
-  pub(super) _redis: ContainerGuard,
   /// Fixed clock midpoint of a TOTP step (deterministic codes).
   /// Fixed clock midpoint of a TOTP step (deterministic codes).
   pub(super) now: OffsetDateTime,
   pub(super) now: OffsetDateTime,
   /// The step counter `now` sits in.
   /// The step counter `now` sits in.
@@ -100,23 +90,13 @@ pub(super) async fn harness() -> Result<Harness, TestError> {
   let (db, pool) = crate::db::test_support::migrated_pool()
   let (db, pool) = crate::db::test_support::migrated_pool()
     .await
     .await
     .map_err(|error| TestError::Unexpected(format!("pg: {error}")))?;
     .map_err(|error| TestError::Unexpected(format!("pg: {error}")))?;
-  let container = GenericImage::new("redis", "8-alpine")
-    .with_exposed_port(6379.tcp())
-    .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
-    .with_cmd(["redis-server", "--appendonly", "yes"])
-    .start()
-    .await?;
-  let guarded = ContainerGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  let redis_url = format!("redis://127.0.0.1:{port}");
-  let client = redis::Client::open(redis_url.as_str())?;
-  let manager_config = redis::aio::ConnectionManagerConfig::new()
-    .set_response_timeout(Some(Duration::from_secs(10)))
-    .set_connection_timeout(Some(Duration::from_secs(5)));
-  let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
   let store = Store::from_parts(
   let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    state_prefix.clone(),
+    rl_prefix.clone(),
     crate::config::DurabilityMode::WaitAof,
     crate::config::DurabilityMode::WaitAof,
   );
   );
   let fixture = json!({
   let fixture = json!({
@@ -124,7 +104,12 @@ pub(super) async fn harness() -> Result<Harness, TestError> {
     "environment": "development",
     "environment": "development",
     "tls": {"cert_path": "/tmp/login-test-cert.pem", "key_path": "/tmp/login-test-key.pem"},
     "tls": {"cert_path": "/tmp/login-test-cert.pem", "key_path": "/tmp/login-test-key.pem"},
     "database": {"url": "postgres://127.0.0.1:1/login"},
     "database": {"url": "postgres://127.0.0.1:1/login"},
-    "redis": {"url": redis_url, "durability": "waitaof"},
+    "redis": {
+      "url": crate::test_support::test_redis_url(),
+      "durability": "waitaof",
+      "token_key_prefix": state_prefix,
+      "ratelimit_key_prefix": rl_prefix,
+    },
     "token_pepper": "login-test-pepper-0123456789abcdef",
     "token_pepper": "login-test-pepper-0123456789abcdef",
     "totp_encryption_key": "login-test-totp-key-0123456789abcdef",
     "totp_encryption_key": "login-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "login-test-pii-key-0123456789abcdef",
     "pii_encryption_key": "login-test-pii-key-0123456789abcdef",
@@ -149,7 +134,6 @@ pub(super) async fn harness() -> Result<Harness, TestError> {
     conn: RedisConn::Single(manager),
     conn: RedisConn::Single(manager),
     store,
     store,
     config: std::sync::Arc::new(config),
     config: std::sync::Arc::new(config),
-    _redis: guarded,
     now,
     now,
     counter,
     counter,
   })
   })

+ 14 - 3
src/ui/login/tests/enroll.rs

@@ -881,9 +881,13 @@ async fn change_password_redis_outage_leaves_password_unchanged() -> TestResult
     .arg("-@write")
     .arg("-@write")
     .query_async(&mut h.conn)
     .query_async(&mut h.conn)
     .await?;
     .await?;
-  let port = h._redis.get_host_port_ipv4(6379).await?;
-  let url = format!("redis://outage-user:{acl_password}@127.0.0.1:{port}");
-  let client = redis::Client::open(url.as_str())?;
+  let info: redis::ConnectionInfo = crate::test_support::test_redis_url().parse()?;
+  let settings = info
+    .redis_settings()
+    .clone()
+    .set_username("outage-user")
+    .set_password(acl_password);
+  let client = redis::Client::open(info.set_redis_settings(settings))?;
   let manager = redis::aio::ConnectionManager::new_lazy_with_config(
   let manager = redis::aio::ConnectionManager::new_lazy_with_config(
     client,
     client,
     redis::aio::ConnectionManagerConfig::new()
     redis::aio::ConnectionManagerConfig::new()
@@ -958,5 +962,12 @@ async fn change_password_redis_outage_leaves_password_unchanged() -> TestResult
   {
   {
     return Err(unexpected("a failed change must keep sibling sessions"));
     return Err(unexpected("a failed change must keep sibling sessions"));
   }
   }
+  // Best-effort: the ACL user lives on the shared Redis, and the result is
+  // ignored so cleanup never turns a passing test into a failing one.
+  let _: Result<(), redis::RedisError> = redis::cmd("ACL")
+    .arg("DELUSER")
+    .arg("outage-user")
+    .query_async(&mut h.conn)
+    .await;
   Ok(())
   Ok(())
 }
 }