浏览代码

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

Efren Yevale Varela 3 周之前
父节点
当前提交
0d522a39e8
共有 63 个文件被更改,包括 1287 次插入 和 1311 次删除
  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
 `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
-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:
 

+ 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.
 - A TLS certificate and key for the public listener.
 - 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 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
 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
-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
 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.
 # 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
 
 services:
   test-db:
     image: postgres:18-alpine
+    command: ["postgres", "-c", "max_connections=500", "-c", "max_parallel_workers_per_gather=0"]
+    shm_size: 1gb
     environment:
       POSTGRES_PASSWORD: oauth-test-only
     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`.
 - Docker, to run the shared test Postgres and Redis from
   `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:
   `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 down -v`. `cargo nextest run` is the
 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
 
@@ -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.
 
-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
 
@@ -212,9 +225,11 @@ Every behavior change follows red, green, refactor:
 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
 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
 

+ 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 testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use tracing_subscriber::layer::SubscriberExt as _;
 
-use super::context::{self, CliError, Context};
+use super::context::{CliError, Context};
 use crate::{
   config::Config,
   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.
 #[derive(Debug, thiserror::Error)]
 pub enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// Crate configuration failed to load.
   #[error("config error: {0}")]
   Config(#[from] crate::config::ConfigError),
@@ -72,34 +65,32 @@ pub enum TestError {
   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 {
-  /// Validated configuration pointing at the containers.
+  /// Validated configuration pointing at the fresh database and shared Redis.
   pub config: Arc<Config>,
-  _postgres: ContainerGuard,
-  _redis: ContainerGuard,
 }
 
 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
-/// test so the shared containers stay alive, and dropping the last lease
-/// removes them deterministically.
+/// test alongside the context.
 ///
 /// # 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> {
   HARNESS.lease(build()).await
 }
 
 /// 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.
 ///
 /// # Errors
@@ -158,56 +149,34 @@ pub fn captured(buffer: &Arc<Mutex<Vec<u8>>>) -> String {
 }
 
 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?;
-  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!({
     "issuer": "https://auth.example.test",
     "environment": "development",
     "tls": {"cert_path": "/tmp/cli-test-cert.pem", "key_path": "/tmp/cli-test-key.pem"},
     "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",
     "totp_encryption_key": "cli-test-totp-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 sqlx::Row as _;
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 
 use super::{
@@ -58,12 +53,18 @@ async fn bootstrap_setup(
   let (db, pool) = crate::db::test_support::migrated_pool()
     .await
     .map_err(|error| TestError::Harness(error.to_string()))?;
+  let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
   let fixture = serde_json::json!({
     "issuer": "https://auth.example.test",
     "environment": "development",
     "tls": {"cert_path": "/tmp/bootstrap-test-cert.pem", "key_path": "/tmp/bootstrap-test-key.pem"},
     "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",
     "totp_encryption_key": "bootstrap-test-totp-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]
 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 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.
 #[tokio::test]
 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!({
     "issuer": "https://auth.example.test",
     "environment": "development",
@@ -1365,9 +1360,6 @@ async fn user_unlock_redis_outage_fails_closed() -> Result<(), TestError> {
   });
   let source = ::config::Config::try_from(&fixture)?;
   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 email = test_support::unique_email("unlock-outage");
   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).
 #[tokio::test]
 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_end = user_prefix.len();
   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.
 ///
-/// 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)]
 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).
   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 {
-    _container: ContainerGuard,
+    /// Retained only for the `Lease<Db>` type the callers bind; the URL is
+    /// intentionally never dereferenced.
+    #[allow(dead_code)]
     url: String,
   }
 
   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> {
-    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?;
+    let (_fresh_url, pool) = crate::test_support::fresh_database_pool().await?;
     crate::state::MIGRATOR.run(&pool).await?;
     Ok((db, pool))
   }

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

@@ -302,24 +302,15 @@ pub async fn lockout_remaining(
 mod tests {
   use std::{
     net::{IpAddr, Ipv4Addr},
-    sync::atomic::{AtomicU64, Ordering},
     time::Duration,
   };
 
-  use testcontainers::{
-    GenericImage, ImageExt,
-    core::{IntoContainerPort, WaitFor},
-    runners::AsyncRunner,
-  };
-
   use super::{
     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,
     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 {
     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())
   }
 
-  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)]
   enum TestError {
-    #[error("container error: {0}")]
-    Containers(#[from] testcontainers::TestcontainersError),
     #[error("redis error: {0}")]
     Redis(#[from] redis::RedisError),
     #[error("store error: {0}")]
@@ -397,7 +371,7 @@ mod tests {
   /// bucket only; the same account from a different IP is not locked.
   #[tokio::test]
   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 label = "victim@example.test";
     for _ in 0..5 {
@@ -420,7 +394,7 @@ mod tests {
   /// attempt from one IP lands in that IP's single bounded bucket.
   #[tokio::test]
   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()))?;
     for _ in 0..25 {
       register_unknown_failure(&mut conn, &store, policy, Some(attacker_ip())).await?;
@@ -454,7 +428,7 @@ mod tests {
   /// per-IP composite bucket for the label.
   #[tokio::test]
   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))
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
     let label = "locked@example.test";
@@ -494,7 +468,7 @@ mod tests {
   /// every source stays below the per-IP threshold (distributed burst).
   #[tokio::test]
   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))
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
     let label = "global-cap@example.test";
@@ -536,7 +510,7 @@ mod tests {
   /// advances; no composite bucket is minted and the clear removes just it.
   #[tokio::test]
   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))
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
     let label = "peerless@example.test";
@@ -583,7 +557,7 @@ mod tests {
   /// the calling IP's composite bucket; another IP's locked bucket survives.
   #[tokio::test]
   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))
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
     let label = "scoped-clear@example.test";
@@ -625,7 +599,7 @@ mod tests {
   /// buckets (no cross-family normalization; each family locks alone).
   #[tokio::test]
   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))
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
     let label = "family-split@example.test";
@@ -664,7 +638,7 @@ mod tests {
   /// touches the label-less unknown buckets.
   #[tokio::test]
   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))
       .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
     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.
 #[tokio::test]
 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 now = now_unix();
   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.
 #[tokio::test]
 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 now = now_unix();
   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).
 #[tokio::test]
 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 now = now_unix();
   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.
 #[tokio::test]
 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 now = now_unix();
   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.
 #[tokio::test]
 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 now = now_unix();
   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.
 #[tokio::test]
 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 {
     proof: None,
     htm: TEST_HTM,
@@ -145,7 +145,7 @@ async fn dpop_missing_proof_rejected() -> TestResult {
 /// Only the exact `dpop+jwt` type is accepted.
 #[tokio::test]
 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 now = now_unix();
   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.
 #[tokio::test]
 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 now = now_unix();
   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.
 #[tokio::test]
 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 big = "p".repeat(super::MAX_PROOF_LEN + 1);
   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.
 #[tokio::test]
 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 now = now_unix();
   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.
 #[tokio::test]
 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 now = now_unix();
   let mut header = Header::new(Algorithm::HS256);
@@ -75,7 +75,7 @@ async fn dpop_hmac_alg_rejected() -> TestResult {
 /// embedding another fails the signature check.
 #[tokio::test]
 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 embedded = fresh_es256_key()?;
   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.
 #[tokio::test]
 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 now = now_unix();
   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.
 #[tokio::test]
 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 now = now_unix();
   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.
 #[tokio::test]
 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 now = now_unix();
   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.
 
-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 jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
 use serde_json::Value;
 use sha2::{Digest as _, Sha256};
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort as _, WaitFor},
-  runners::AsyncRunner as _,
-};
 use time::OffsetDateTime;
 
 use crate::{
@@ -22,7 +14,6 @@ use crate::{
   jose::jwk::JwkError,
   state::RedisConn,
   store::{Store, StoreError},
-  test_guard::{ContainerGuard, Lease, Shared},
 };
 
 /// 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.
 #[derive(Debug, thiserror::Error)]
 pub(super) enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   #[error("redis error")]
   Redis(#[from] redis::RedisError),
@@ -65,44 +53,20 @@ pub(super) const TEST_HTM: &str = "POST";
 /// URI every test proof is bound to.
 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);

+ 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`.
 #[tokio::test]
 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 now = now_unix();
   let jti = unique_jti();
@@ -53,7 +53,7 @@ fn dpop_rfc7638_thumbprint_vector() -> TestResult {
 /// `iat` values verify, one second beyond either side is rejected.
 #[tokio::test]
 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 now = now_unix();
   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.
 #[tokio::test]
 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 now = now_unix();
   let jti = unique_jti();
@@ -117,7 +117,7 @@ async fn dpop_replayed_jti_rejected() -> TestResult {
 /// the token matches.
 #[tokio::test]
 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 now = now_unix();
   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.
 #[tokio::test]
 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 now = now_unix();
   let jti = unique_jti();

+ 2 - 0
src/lib.rs

@@ -24,6 +24,8 @@ pub mod state;
 pub mod store;
 #[cfg(test)]
 pub(crate) mod test_guard;
+#[cfg(test)]
+pub(crate) mod test_support;
 pub mod tls;
 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.
 
 use std::error::Error;
@@ -10,16 +10,10 @@ use axum::body::Body;
 use axum::extract::ConnectInfo;
 use axum::http::{Request, Response, header};
 use serde_json::{Value, json};
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use tower::{Service, ServiceExt};
 
 use crate::config::{Config, ConfigError};
 use crate::state::{AppState, Clock, RedisConn};
-use crate::test_guard::ContainerGuard;
 
 /// Result alias keeping `?` usable across containers, Redis, config, and sqlx.
 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!({
     "issuer": "https://auth.example.test",
     "environment": "development",
     "tls": {"cert_path": "/tmp/ratelimit-test-cert.pem", "key_path": "/tmp/ratelimit-test-key.pem"},
     "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",
     "totp_encryption_key": "ratelimit-test-totp-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(
   config: Config,
   redis_url: &str,

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

@@ -1,7 +1,8 @@
 //! `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::time::Duration;
@@ -13,16 +14,17 @@ use tower::Layer;
 use super::RateLimitLayer;
 use super::identity::{client_bucket_raw, counter_key};
 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`.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 3, "token_per_client": 1000}),
   )?;
   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).
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1000, "token_per_client": 2}),
   )?;
   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.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 1, "per_ip": 1}),
   )?;
   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`.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 100, "token_per_client": 100}),
   )?;
   let state = live_state(config, &url).await?;
+  let prefix = state.config.redis.ratelimit_key_prefix.clone();
   let service = RateLimitLayer::new(state).layer(Passthrough);
 
   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?;
   assert_eq!(response.status(), StatusCode::OK);
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   assert!(
     keys.len() >= 2,
     "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 {
   let config = test_config(
     "redis://127.0.0.1:1",
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 10, "token_per_client": 10}),
   )?;
   let state = unavailable_state(config)?;
@@ -217,7 +224,11 @@ async fn ratelimit_token_fails_closed_on_store_outage() -> TestResult {
 /// while the store is down.
 #[tokio::test]
 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 service = RateLimitLayer::new(state).layer(Passthrough);
 
@@ -231,9 +242,10 @@ async fn ratelimit_leaves_unlisted_paths_untouched_on_store_outage() -> TestResu
 /// evade the limit.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
   )?;
   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"
   );
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   assert_eq!(
     keys,
     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.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
   )?;
   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"
   );
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   assert_eq!(
     keys,
     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.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
   )?;
   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"
   );
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   for expected in [
     counter_key(&prefix, "ip", "127.0.0.1"),
     counter_key(&prefix, "ip", "::1"),
@@ -422,9 +436,10 @@ async fn ratelimit_ipv6_peer_owns_a_distinct_bucket() -> TestResult {
 /// separately.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
   )?;
   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"
   );
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   for expected in [
     counter_key(&prefix, "ip", "127.0.0.1"),
     counter_key(
@@ -486,9 +501,10 @@ async fn ratelimit_per_ip_budget_blocks_fresh_client_from_same_ip() -> TestResul
 /// configured window.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
   )?;
   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.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": false, "window_secs": 60, "per_ip": 1, "token_per_client": 1}),
   )?;
   let state = live_state(config, &url).await?;
+  let prefix = state.config.redis.ratelimit_key_prefix.clone();
   let service = RateLimitLayer::new(state).layer(Passthrough);
 
   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!(
     keys.is_empty(),
     "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.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 1000, "token_per_client": 2}),
   )?;
   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.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 2, "token_per_client": 1000}),
   )?;
   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.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({
       "enabled": true,
       "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"
   );
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   for expected in [
     counter_key(&prefix, "ip", "198.51.100.7"),
     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.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({
       "enabled": true,
       "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"
   );
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   assert_eq!(
     keys,
     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.
 #[tokio::test]
 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(
     &url,
+    crate::test_support::unique_prefixes(),
     json!({"enabled": true, "window_secs": 60, "per_ip": 10, "token_per_client": 10}),
   )?;
   let state = live_state(config, &url).await?;
+  let prefix = state.config.redis.ratelimit_key_prefix.clone();
   let service = RateLimitLayer::new(state).layer(Passthrough);
 
   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"
   );
 
-  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  let keys = scan_keys(&url, &format!("{prefix}*")).await?;
   assert!(
     keys.is_empty(),
     "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).
+//!
+//! 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 secrets;
 
-use std::time::Duration;
-
 use base64::Engine as _;
 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
 use ring::rand::SystemRandom;
@@ -13,11 +16,6 @@ use ring::signature::{Ed25519KeyPair, KeyPair as _};
 use secrecy::SecretString;
 use serde::{Deserialize, Serialize};
 use sqlx::PgPool;
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use uuid::Uuid;
 
@@ -32,7 +30,7 @@ use crate::{
   keys::{KeyId, KeyRing},
   state::RedisConn,
   store::Store,
-  test_guard::{ContainerGuard, Lease},
+  test_guard::Lease,
 };
 
 /// This suite's error type (no `unwrap`/`expect` under the deny lints).
@@ -41,9 +39,6 @@ type TestResult = Result<(), TestError>;
 /// Test scaffolding failures.
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
@@ -62,8 +57,7 @@ enum TestError {
 }
 
 /// 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 {
   _db: Lease<Db>,
   pool: PgPool,
@@ -73,34 +67,21 @@ struct Harness {
   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()
     .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 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 {
     _db,
     pool,
@@ -120,7 +101,7 @@ async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
     )
     .map_err(|error| TestError::Crypto(format!("{error:?}")))?,
   };
-  Ok((guarded, h))
+  Ok(h)
 }
 
 /// 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]
 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 client_id = "client-auth-ct-wrong-aud";
   let key = ContainerKey::generate("k1")?;
@@ -35,7 +35,7 @@ async fn client_auth_assertion_wrong_aud_rejected() -> TestResult {
 
 #[tokio::test]
 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 client_id = "client-auth-ct-replay";
   let key = ContainerKey::generate("k1")?;
@@ -97,7 +97,7 @@ fn client_auth_assertion_aud_array_membership() -> TestResult {
 
 #[tokio::test]
 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 client_id = "client-auth-ct-ssrf";
   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`.
 #[tokio::test]
 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 client_id = "client-auth-ct-jwks-fetch";
   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]
 async fn client_auth_basic_valid() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-basic-valid";
   insert_client(
@@ -43,7 +43,7 @@ async fn client_auth_basic_valid() -> TestResult {
 
 #[tokio::test]
 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 client_id = "client-auth-basic-wrong";
   insert_client(
@@ -71,7 +71,7 @@ async fn client_auth_basic_wrong_secret_is_401() -> TestResult {
 
 #[tokio::test]
 async fn client_auth_post_valid() -> TestResult {
-  let (_container, mut h) = harness().await?;
+  let mut h = harness().await?;
   let endpoint = h.endpoint.clone();
   let client_id = "client-auth-post-valid";
   insert_client(
@@ -96,7 +96,7 @@ async fn client_auth_post_valid() -> TestResult {
 
 #[tokio::test]
 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 client_id = "client-auth-none-on-conf";
   insert_client(
@@ -125,7 +125,7 @@ async fn client_auth_none_on_confidential_rejected() -> TestResult {
 
 #[tokio::test]
 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 client_id = "client-auth-none-public";
   insert_client(
@@ -153,7 +153,7 @@ async fn client_auth_none_on_public_accepted() -> TestResult {
 
 #[tokio::test]
 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 client_id = "client-auth-expired-secret";
   insert_client(
@@ -175,7 +175,7 @@ async fn client_auth_expired_secret_rejected() -> TestResult {
 
 #[tokio::test]
 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 req = post_request("client-auth-no-such-client", SecretString::from("whatever"));
   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]
 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 client_id = "client-auth-multi-method";
   insert_client(

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

@@ -3,12 +3,8 @@
 
 use std::{collections::HashMap, time::Duration};
 
+use redis::aio::ConnectionManager;
 use secrecy::SecretString;
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 
 use super::{CODE_TTL_CAP, CodeClientKind, CodeIssueError, CodeIssueRequest, CodeIssuer};
 use crate::{
@@ -17,7 +13,6 @@ use crate::{
   protocol::scope::ScopeSet,
   state::RedisConn,
   store::{Store, StoreError, codes},
-  test_guard::ContainerGuard,
 };
 
 /// 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.
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// The Redis configuration was rejected.
   #[error("config error: {0}")]
   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 {
-    url: Some(url.to_owned()),
+    url: Some(crate::test_support::test_redis_url()),
     durability: Some(DurabilityMode::WaitAof),
     response_timeout_secs: 10,
     connect_timeout_secs: 5,
+    token_key_prefix: Some(token_prefix.to_owned()),
+    ratelimit_key_prefix: Some(ratelimit_prefix.to_owned()),
     ..Default::default()
   };
   let config = crate::config::RedisConfig::from_raw(raw)?;
@@ -134,9 +115,14 @@ fn query_pairs(location: &str) -> Result<HashMap<String, String>, TestError> {
   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 {
     if key.contains(needle) {
       return Ok(true);
@@ -159,8 +145,15 @@ async fn keyspace_contains(conn: &mut RedisConn, needle: &str) -> Result<bool, T
 
 #[tokio::test]
 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 approved = ScopeSet::parse("api:read")?;
   let entitled = ScopeSet::parse("api:read api:admin")?;
@@ -218,8 +211,15 @@ async fn code_issue_redirect_carries_code_state_iss() -> TestResult {
 
 #[tokio::test]
 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 approved = 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(),
     ));
   }
-  if keyspace_contains(&mut conn, &issued.code).await? {
+  if keyspace_contains(&mut conn, &state_prefix, &issued.code).await? {
     return Err(TestError::Unexpected(
       "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]
 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 approved = ScopeSet::parse("api:read api:admin")?;
   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(),
     ));
   }
-  if keyspace_contains(&mut conn, "client-abc").await? {
+  if keyspace_contains(&mut conn, &state_prefix, "client-abc").await? {
     return Err(TestError::Unexpected(
       "rejected issuance must store nothing".to_owned(),
     ));
@@ -314,8 +321,15 @@ async fn code_issue_rejects_unentitled_approval() -> TestResult {
 
 #[tokio::test]
 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 approved = ScopeSet::parse("api:read")?;
   let entitled = ScopeSet::parse("api:read api:admin")?;
@@ -415,8 +429,15 @@ fn code_issuer_debug_redacts_pepper() -> TestResult {
 
 #[tokio::test]
 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 approved = 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 sqlx::PgPool;
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use uuid::Uuid;
 
@@ -26,15 +21,12 @@ use crate::{
   error::{AppError, OAuthErrorCode},
   state::RedisConn,
   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).
 #[derive(Debug, thiserror::Error)]
 pub(super) enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
@@ -80,45 +72,59 @@ pub(super) struct Registered {
   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
-    .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()
     .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?;
+  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(
     "postgres://postgres:dummy@127.0.0.1:1/postgres?sslmode=disable",
     "redis://127.0.0.1:1",
     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.

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

@@ -9,7 +9,7 @@ use crate::db::client_repo;
 
 #[tokio::test]
 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 response = {
@@ -80,7 +80,7 @@ async fn dcr_manage_read_returns_metadata() -> TestResult {
 
 #[tokio::test]
 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 response = {
@@ -129,7 +129,7 @@ async fn dcr_manage_get() -> TestResult {
 
 #[tokio::test]
 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 (mut stores, ctx) = manage_parts(&mut harness);
@@ -143,7 +143,7 @@ async fn dcr_manage_read_missing_token_401() -> TestResult {
 
 #[tokio::test]
 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 (mut stores, ctx) = manage_parts(&mut harness);
@@ -164,7 +164,7 @@ async fn dcr_manage_read_unknown_client_401() -> TestResult {
 
 #[tokio::test]
 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 (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,
   // no redirects, global IPs only). A plaintext scheme, a loopback literal,
   // 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";
   seed_redirect(&harness.pool, &harness.config.keys, redirect).await?;
   let request_uris = serde_json::json!([
@@ -261,7 +261,7 @@ async fn dcr_manage_register_request_uris_stored_verbatim() -> TestResult {
 
 #[tokio::test]
 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 request = crate::protocol::dcr::RegisterRequest {

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

@@ -3,11 +3,17 @@
 
 use uuid::Uuid;
 
+use testcontainers::{
+  GenericImage, ImageExt as _,
+  core::{IntoContainerPort, WaitFor},
+  runners::AsyncRunner,
+};
+
 use super::delete::delete_client;
 use super::read_client;
 use super::support::{
   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::error::OAuthErrorCode;
@@ -15,7 +21,7 @@ use crate::protocol::token::{ClientKind, MintRequest, SenderProof, TokenService}
 
 #[tokio::test]
 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 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]
 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?;
 
   {
@@ -142,7 +148,7 @@ async fn dcr_manage_delete_deactivates() -> TestResult {
 
 #[tokio::test]
 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 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
   // 403. A successful DELETE invalidates the token, so replaying it answers
   // 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?;
 
   {
@@ -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
   // same subject DN must therefore stay a typed 400 invalid_client_metadata
   // 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 subject_dn = "CN=reserved-dn, O=Example";
   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
   // `active` predicate (docs/DATABASE.md "oauth_clients"), so deactivation
   // 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 raw = crate::crypto::tokens::mint_token()
@@ -483,7 +489,25 @@ async fn dcr_manage_delete_thumbprint_stays_reserved() -> TestResult {
 
 #[tokio::test]
 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?;
 
   {

+ 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
   // access token and invalidates the previous one. The old token must fail for
   // 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 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
   // schema, so revocation (a successful DELETE that deactivates the client)
   // 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 request = put_request(
     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
   // the same request or response." The PUT validation must reject the pair
   // 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 mut request = put_request(
     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",
   // docs/SECURITY.md "PAR and JAR"). Full-replace semantics must persist the
   // 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 request_uris = serde_json::json!([
     "http://app.example.test/req.jwt",
@@ -376,7 +376,7 @@ async fn dcr_manage_update_request_uris_stored_verbatim() -> TestResult {
 
 #[tokio::test]
 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?;
   seed_redirect(
     &harness.pool,
@@ -499,7 +499,7 @@ async fn dcr_manage_update_full_replace_rotates() -> TestResult {
 
 #[tokio::test]
 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?;
   seed_redirect(
     &harness.pool,
@@ -570,7 +570,7 @@ async fn dcr_manage_update_replaces_redirect_attachments() -> TestResult {
 
 #[tokio::test]
 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 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]
 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 request = put_request(
@@ -656,7 +656,7 @@ async fn dcr_manage_update_rename() -> TestResult {
 
 #[tokio::test]
 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 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 sqlx::PgPool;
-use testcontainers::{
-  GenericImage,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use uuid::Uuid;
 
@@ -24,7 +19,7 @@ use crate::{
   error::{AppError, OAuthErrorCode},
   state::RedisConn,
   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).
@@ -33,9 +28,6 @@ type TestResult = Result<(), TestError>;
 /// Test scaffolding failures.
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
@@ -68,24 +60,17 @@ struct Harness {
   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()
     .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"))
-    .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
   // connection above are used directly, so its URLs only need to pass
   // syntactic validation.
@@ -95,21 +80,15 @@ async fn harness(open: bool, policy: &str) -> Result<(ContainerGuard, Harness),
     open,
     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.
@@ -236,7 +215,7 @@ fn expect_error(error: AppError, code: OAuthErrorCode, status: u16) -> TestResul
 
 #[tokio::test]
 async fn dcr_register_valid_201() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   seed_redirect(
     &harness.pool,
     &harness.config.keys,
@@ -326,7 +305,7 @@ async fn dcr_register_valid_201() -> TestResult {
 
 #[tokio::test]
 async fn dcr_register_round_trip() -> TestResult {
-  let (_guard, mut harness) = harness(false, "catalog").await?;
+  let mut harness = harness(false, "catalog").await?;
   seed_redirect(
     &harness.pool,
     &harness.config.keys,
@@ -414,7 +393,7 @@ async fn dcr_register_round_trip() -> TestResult {
 
 #[tokio::test]
 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(
     &harness.pool,
     &harness.config.keys,
@@ -488,7 +467,7 @@ async fn dcr_register_duplicate_mtls_identity_is_400() -> TestResult {
 
 #[tokio::test]
 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 (mut stores, ctx) = parts_of(&mut harness);
   match register_client(&mut stores, &ctx, None, &request).await {
@@ -501,7 +480,7 @@ async fn dcr_register_missing_iat_401() -> TestResult {
 
 #[tokio::test]
 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 (mut stores, ctx) = parts_of(&mut harness);
   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]
 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 request = dpop_request("https://evil.example.test/cb", None);
   let (mut stores, ctx) = parts_of(&mut harness);
@@ -528,7 +507,7 @@ async fn dcr_register_bad_redirect_rejected() -> TestResult {
 
 #[tokio::test]
 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(
     &harness.pool,
     &harness.config.keys,
@@ -549,7 +528,7 @@ async fn dcr_register_bad_auth_method_rejected() -> TestResult {
 
 #[tokio::test]
 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(
     &harness.pool,
     &harness.config.keys,
@@ -570,7 +549,7 @@ async fn dcr_register_bad_grant_rejected() -> TestResult {
 
 #[tokio::test]
 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(
     &harness.pool,
     &harness.config.keys,
@@ -589,7 +568,7 @@ async fn dcr_register_bad_scope_rejected() -> TestResult {
 
 #[tokio::test]
 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(
     &harness.pool,
     &harness.config.keys,
@@ -629,7 +608,7 @@ async fn dcr_register_confidential_without_binding_rejected() -> TestResult {
 
 #[tokio::test]
 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 {
     redirect_uris: Some(vec!["http://127.0.0.1:6420/cb".to_owned()]),
     client_name: Some("native app".to_owned()),
@@ -671,7 +650,7 @@ async fn dcr_register_open_anonymous_201() -> TestResult {
 
 #[tokio::test]
 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 request = dpop_request("http://evil.example.com/cb", None);
   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]
 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 request = dpop_request("myapp://cb", None);
   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
   // `is_valid_redirect_scheme` even though it is also seeded in the catalog
   // (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, "myapp:/cb").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]
 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 request = dpop_request("https://app.example.com/cb?x=1#frag", None);
   let (mut stores, ctx) = parts_of(&mut harness);
@@ -741,7 +720,7 @@ async fn dcr_register_open_policy_rejects_fragment() -> TestResult {
 
 #[tokio::test]
 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 request = dpop_request("https://app.example.com/cb", None);
   let response = {
@@ -764,7 +743,7 @@ async fn dcr_register_open_policy_accepts_https() -> TestResult {
 
 #[tokio::test]
 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 request = dpop_request("http://127.0.0.1:1234/cb", None);
   let response = {
@@ -787,7 +766,7 @@ async fn dcr_register_open_policy_accepts_loopback() -> TestResult {
 
 #[tokio::test]
 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(
     &harness.pool,
     &harness.config.keys,
@@ -837,7 +816,7 @@ async fn dcr_register_mtls_client_201() -> TestResult {
 
 #[tokio::test]
 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(
     &harness.pool,
     &harness.config.keys,

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

@@ -1,22 +1,14 @@
 //! `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.
 
-use std::{
-  sync::atomic::{AtomicU64, Ordering},
-  time::Duration,
-};
+use std::sync::atomic::{AtomicU64, Ordering};
 
 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
 use secrecy::SecretString;
 use serde_json::json;
 use sqlx::PgPool;
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use uuid::Uuid;
 
@@ -32,7 +24,6 @@ use crate::{
   jose::jwk::thumbprint,
   state::RedisConn,
   store::{self, Store},
-  test_guard::ContainerGuard,
 };
 
 /// Test body result (no `unwrap`/`expect` under the deny lints).
@@ -41,9 +32,6 @@ type TestResult = Result<(), TestError>;
 /// Test scaffolding failures.
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
@@ -89,35 +77,31 @@ struct Harness {
   config: Config,
 }
 
-async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
+async fn harness() -> Result<Harness, TestError> {
   let (db, pool) = crate::db::test_support::migrated_pool()
     .await
     .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(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    state_prefix.clone(),
+    ratelimit_prefix.clone(),
     crate::config::DurabilityMode::WaitAof,
   );
+  let redis_url = crate::test_support::test_redis_url();
   let fixture = json!({
     "issuer": "https://auth.example.test",
     "environment": "development",
     "tls": {"cert_path": "/tmp/device-test-cert.pem", "key_path": "/tmp/device-test-key.pem"},
     "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",
     "totp_encryption_key": "device-test-totp-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)
     .map_err(|error| TestError::Unexpected(format!("config source: {error}")))?;
   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(
@@ -255,7 +236,7 @@ async fn issue(
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -356,7 +337,7 @@ async fn test_device_authorize_public_client_issues_code_and_interval() -> TestR
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -415,7 +396,7 @@ async fn test_device_authorize_confidential_client_secret_post() -> TestResult {
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -451,7 +432,7 @@ async fn test_device_authorize_public_client_without_dpop_rejected() -> TestResu
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -490,7 +471,7 @@ async fn test_device_authorize_over_registered_scope_rejected() -> TestResult {
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -562,7 +543,7 @@ async fn test_device_authorize_approval_transitions_state() -> TestResult {
 
 #[tokio::test]
 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.
   let miss =
@@ -618,7 +599,7 @@ async fn test_device_authorize_unknown_and_expired_user_code_rejected() -> TestR
 
 #[tokio::test]
 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");
 
   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).
 #[tokio::test]
 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");
   insert_client(
     &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.
 #[tokio::test]
 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 [
     "",

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

@@ -1,15 +1,13 @@
 //! `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::{
@@ -23,11 +21,6 @@ use http_body_util::BodyExt as _;
 use secrecy::SecretString;
 use serde_json::{Value, json};
 use sqlx::PgPool;
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use tower::ServiceExt as _;
 use uuid::Uuid;
@@ -51,7 +44,7 @@ use crate::{
   },
   state::{AppState, Clock, RedisConn},
   store::Store,
-  test_guard::{ContainerGuard, Lease},
+  test_guard::Lease,
 };
 
 /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
@@ -77,25 +70,14 @@ struct Harness {
   config: Arc<Config>,
 }
 
-async fn harness() -> TestResult<(ContainerGuard, Harness)> {
+async fn harness() -> TestResult<Harness> {
   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(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    token_prefix.clone(),
+    ratelimit_prefix.clone(),
     crate::config::DurabilityMode::WaitAof,
   );
   let fixture = json!({
@@ -103,23 +85,25 @@ async fn harness() -> TestResult<(ContainerGuard, Harness)> {
     "environment": "development",
     "tls": {"cert_path": "/tmp/introspect-test-cert.pem", "key_path": "/tmp/introspect-test-key.pem"},
     "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",
     "totp_encryption_key": "introspect-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "introspect-test-pii-key-0123456789abcdef",
   });
   let source = ::config::Config::try_from(&fixture)?;
   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(
@@ -284,7 +268,7 @@ fn router(h: &Harness) -> Router {
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -336,7 +320,7 @@ async fn test_introspect_active_access_token_reports_shape() -> TestResult {
 
 #[tokio::test]
 async fn test_introspect_unknown_token_is_inactive_200() -> TestResult {
-  let (_container, h) = harness().await?;
+  let h = harness().await?;
   let client_id = unique("unknown");
   insert_client(
     &h.pool,
@@ -362,7 +346,7 @@ async fn test_introspect_unknown_token_is_inactive_200() -> TestResult {
 
 #[tokio::test]
 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?;
 
   assert_eq!(status, StatusCode::UNAUTHORIZED);
@@ -375,7 +359,7 @@ async fn test_introspect_unauthenticated_is_401() -> TestResult {
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -412,7 +396,7 @@ async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -445,7 +429,7 @@ async fn test_introspect_revoked_token_is_inactive() -> TestResult {
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -481,7 +465,7 @@ async fn test_introspect_missing_token_form_is_400_invalid_request() -> TestResu
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -517,7 +501,7 @@ async fn test_introspect_empty_token_is_400_invalid_request() -> TestResult {
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -558,7 +542,7 @@ async fn test_introspect_refresh_token_reports_active_shape() -> TestResult {
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -609,7 +593,7 @@ async fn test_introspect_dpop_bound_token_reports_jkt_only() -> TestResult {
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -661,7 +645,7 @@ async fn test_introspect_mtls_bound_token_reports_x5t_only() -> TestResult {
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -695,7 +679,7 @@ async fn test_introspect_unknown_hint_is_advisory() -> TestResult {
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -726,7 +710,7 @@ async fn test_introspect_wrong_hint_still_resolves_refresh() -> TestResult {
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -769,7 +753,7 @@ async fn test_introspect_active_response_leaks_no_token_or_secret() -> TestResul
 
 #[tokio::test]
 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");
   insert_client(
     &h.pool,
@@ -899,7 +883,7 @@ async fn introspect_client(h: &mut Harness, label: &str, secret: &str) -> TestRe
 
 #[tokio::test]
 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 user = provision_user(&h, "disabled-introspect@example.test").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]
 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 user = provision_user(&h, "disabled-uuid@example.test").await?;
   // 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]
 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 user = provision_user(&h, "enabled-introspect@example.test").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]
 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 user = provision_user(&h, "reenabled-introspect@example.test").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]
 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?;
   // Client-credentials tokens carry an empty subject.
   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.
 
 mod consume;
@@ -7,11 +7,6 @@ mod rules;
 use std::{collections::HashMap, time::Duration};
 
 use sqlx::PgPool;
-use testcontainers::{
-  GenericImage,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use uuid::Uuid;
 
@@ -25,16 +20,13 @@ use crate::{
   protocol::client_auth::ClientAuthRequest,
   state::RedisConn,
   store::Store,
-  test_guard::{ContainerGuard, Lease},
+  test_guard::Lease,
 };
 use secrecy::SecretString;
 
 /// Test scaffolding failures (no `unwrap`/`expect` under the deny lints).
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
@@ -62,26 +54,19 @@ struct Harness {
   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()
     .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"))
-    .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 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 {
     _db,
     pool,
@@ -95,7 +80,7 @@ async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
     )
     .map_err(|error| TestError::Crypto(format!("{error:?}")))?,
   };
-  Ok((guarded, h))
+  Ok(h)
 }
 
 /// 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]
 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 redirect_uri = "https://client.example.test/cb";
   insert_client(
@@ -97,7 +97,7 @@ async fn par_push_stores_one_time_record() -> Result<(), TestError> {
 
 #[tokio::test]
 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_b = "par-client-b";
   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]
 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_b = "par-foreign-client";
   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]
 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 record = par::ParRecord {
     client_id: client_id.to_owned(),
@@ -330,7 +330,7 @@ async fn par_consume_bound_transport_error_recovery() -> Result<(), TestError> {
 
 #[tokio::test]
 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 {
     client_id: "par-expiry-client".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]
 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 redirect_uri = "https://client.example.test/cb";
   insert_client(
@@ -82,7 +82,7 @@ async fn par_rejects_unauthenticated_client() -> Result<(), TestError> {
 
 #[tokio::test]
 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 redirect_uri = "https://client.example.test/cb";
   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).
 #[tokio::test]
 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 redirect_uri = "https://client.example.test/cb";
   insert_client(

+ 17 - 42
src/protocol/purge.rs

@@ -298,16 +298,7 @@ fn store_mapped(error: redis::RedisError) -> AppError {
 
 #[cfg(test)]
 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 crate::{
@@ -315,13 +306,10 @@ mod tests {
     error::{AppError, OAuthErrorCode},
     state::RedisConn,
     store::Store,
-    test_guard::ContainerGuard,
   };
 
   #[derive(Debug, thiserror::Error)]
   enum TestError {
-    #[error("container error: {0}")]
-    Containers(#[from] testcontainers::TestcontainersError),
     #[error("redis error: {0}")]
     Redis(#[from] redis::RedisError),
     #[error("store error: {0}")]
@@ -334,30 +322,17 @@ mod tests {
 
   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 {
@@ -407,7 +382,7 @@ mod tests {
 
   #[tokio::test]
   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 user_id = uuid::Uuid::new_v4().to_string();
     let decoy = "bob@example.test";
@@ -616,7 +591,7 @@ mod tests {
 
   #[tokio::test]
   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?;
     if summary != PurgedSubject::default() {
       return Err(TestError::Unexpected(format!(
@@ -656,7 +631,7 @@ mod tests {
 
   #[tokio::test]
   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 user_id = uuid::Uuid::new_v4().to_string();
     let current_key = store.session_key("sess-current");
@@ -692,7 +667,7 @@ mod tests {
 
   #[tokio::test]
   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 user_id = uuid::Uuid::new_v4().to_string();
     let other = "stranger@example.test";
@@ -745,7 +720,7 @@ mod tests {
 
   #[tokio::test]
   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 user_id = uuid::Uuid::new_v4().to_string();
     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.
 //!
-//! 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 cascade;
@@ -18,11 +19,6 @@ use std::{
 };
 
 use secrecy::SecretString;
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort as _, WaitFor},
-  runners::AsyncRunner as _,
-};
 
 use crate::{
   config::{DurabilityMode, TtlConfig},
@@ -33,7 +29,6 @@ use crate::{
   },
   state::{AppState, Clock, RedisConn},
   store::{Store, StoreError},
-  test_guard::{ContainerGuard, Lease, Shared},
 };
 
 /// 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.
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   #[error("redis error")]
   Redis(#[from] redis::RedisError),
@@ -74,39 +66,29 @@ enum TestError {
   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 {
-  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.

+ 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.
 #[tokio::test]
 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 client = unique("client");
   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.
 #[tokio::test]
 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 client = unique("client");
   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.
 #[tokio::test]
 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 outcome = revoke_owned(
     &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).
 #[tokio::test]
 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 owner = unique("owner");
   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`.
 #[tokio::test]
 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 outcome = revoke_owned(
     &mut conn,
@@ -231,7 +231,7 @@ async fn revoke_unsupported_hint_is_rejected() -> TestResult {
 /// itself, so the sibling refresh token survives).
 #[tokio::test]
 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 client = unique("client");
   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.
 #[tokio::test]
 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 owner = unique("owner");
   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(())
 }
 
-/// 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!({
     "issuer": "https://auth.example.test",
     "environment": "development",
     "tls": {"cert_path": "/tmp/revoke-test-cert.pem", "key_path": "/tmp/revoke-test-key.pem"},
     "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",
     "totp_encryption_key": "revoke-test-totp-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()
     .await
     .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 victim_leaf = cert_der(CLIENT_SELF_PEM)?;
   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 peer = PeerCertificates(Arc::new(vec![attacker_leaf]));
   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?;
   if status != StatusCode::BAD_REQUEST {
     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()
     .await
     .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 leaf = cert_der(CLIENT_SELF_PEM)?;
   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 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?;
   if status != StatusCode::BAD_REQUEST {
     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()
     .await
     .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 leaf = cert_der(CLIENT_SELF_PEM)?;
   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 peer = PeerCertificates(Arc::new(vec![leaf]));
   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?;
   if status != StatusCode::OK {
     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()
     .await
     .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 leaf = cert_der(CLIENT_SELF_PEM)?;
   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 peer = PeerCertificates(Arc::new(vec![rogue]));
   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?;
   if status != StatusCode::UNAUTHORIZED && status != StatusCode::BAD_REQUEST {
     return Err(TestError::Unexpected(format!(

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

@@ -424,7 +424,8 @@ async fn authenticate_confidential(
 mod tests {
   //! `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
   //! keyed-hash path is exercised end to end; every negative test issues a
   //! fresh code because redemption consumes exactly once.
@@ -435,11 +436,6 @@ mod tests {
   };
 
   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 crate::{
@@ -458,7 +454,6 @@ mod tests {
     },
     state::RedisConn,
     store::Store,
-    test_guard::ContainerGuard,
   };
 
   /// 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.
   #[derive(Debug, thiserror::Error)]
   enum TestError {
-    /// A testcontainers operation failed.
-    #[error("container error: {0}")]
-    Containers(#[from] testcontainers::TestcontainersError),
     /// A Redis command failed.
     #[error("redis error: {0}")]
     Redis(#[from] redis::RedisError),
@@ -535,24 +527,14 @@ mod tests {
     ttl: TtlConfig,
   }
 
-  async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
+  async fn harness() -> Result<Harness, TestError> {
     let (db, pool) = crate::db::test_support::migrated_pool()
       .await
       .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(
       vec![(
         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-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.
@@ -719,7 +694,7 @@ mod tests {
 
   #[tokio::test]
   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");
     insert_client(
       &h,
@@ -792,7 +767,7 @@ mod tests {
 
   #[tokio::test]
   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");
     insert_client(
       &h,
@@ -867,7 +842,7 @@ mod tests {
 
   #[tokio::test]
   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");
     insert_client(
       &h,
@@ -924,7 +899,7 @@ mod tests {
 
   #[tokio::test]
   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");
     insert_client(
       &h,
@@ -962,7 +937,7 @@ mod tests {
 
   #[tokio::test]
   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_b = unique("b");
     for client_id in [&client_a, &client_b] {
@@ -1005,7 +980,7 @@ mod tests {
 
   #[tokio::test]
   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_b = unique("foreign");
     for client_id in [&client_a, &client_b] {
@@ -1072,7 +1047,7 @@ mod tests {
 
   #[tokio::test]
   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");
     insert_client(
       &h,
@@ -1120,7 +1095,7 @@ mod tests {
 
   #[tokio::test]
   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");
     insert_client(
       &h,
@@ -1165,7 +1140,7 @@ mod tests {
 
   #[tokio::test]
   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");
     insert_client(
       &h,
@@ -1204,7 +1179,7 @@ mod tests {
 
   #[tokio::test]
   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");
     insert_client(
       &h,
@@ -1253,7 +1228,7 @@ mod tests {
   async fn test_grant_code_public_empty_cnf_is_invalid_grant() -> TestResult {
     use secrecy::ExposeSecret as _;
 
-    let (_container, mut h) = harness().await?;
+    let mut h = harness().await?;
     let client_id = unique("public-empty-cnf");
     insert_client(
       &h,

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

@@ -1,22 +1,14 @@
 //! `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 issue;
 
-use std::{
-  sync::atomic::{AtomicU64, Ordering},
-  time::Duration,
-};
+use std::sync::atomic::{AtomicU64, Ordering};
 
 use secrecy::SecretString;
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort as _, WaitFor},
-  runners::AsyncRunner as _,
-};
 
 use super::{ClientCredentialsInput, ClientCredentialsTokens};
 use crate::{
@@ -29,7 +21,6 @@ use crate::{
   },
   state::RedisConn,
   store::{Store, StoreError},
-  test_guard::{ContainerGuard, Lease, Shared},
 };
 
 /// 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.
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   #[error("redis error")]
   Redis(#[from] redis::RedisError),
@@ -61,35 +49,14 @@ enum TestError {
   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);

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

@@ -10,7 +10,7 @@ use crate::protocol::token::ClientKind;
 
 #[tokio::test]
 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 client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
@@ -36,7 +36,7 @@ async fn grant_client_credentials_unbound_bearer_ok() -> TestResult {
 
 #[tokio::test]
 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 client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
@@ -75,7 +75,7 @@ async fn grant_client_credentials_dpop_bound_no_proof_rejected() -> TestResult {
 
 #[tokio::test]
 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 client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
@@ -114,7 +114,7 @@ async fn grant_client_credentials_dpop_bound_stale_proof_rejected() -> TestResul
 
 #[tokio::test]
 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 client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
@@ -145,7 +145,7 @@ async fn grant_client_credentials_dpop_bound_fresh_proof_ok() -> TestResult {
 
 #[tokio::test]
 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 client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
@@ -184,7 +184,7 @@ async fn grant_client_credentials_mtls_bound_no_proof_rejected() -> TestResult {
 
 #[tokio::test]
 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 client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
@@ -223,7 +223,7 @@ async fn grant_client_credentials_mtls_bound_stale_proof_rejected() -> TestResul
 
 #[tokio::test]
 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 client = unique("m2m");
   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]
 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 client = unique("m2m");
   let registered = ScopeSet::parse("api:read api:write")?;
@@ -72,7 +72,7 @@ async fn grant_client_credentials_valid_issue() -> TestResult {
 
 #[tokio::test]
 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 client = unique("m2m");
   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]
 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 client = unique("public");
   let registered = ScopeSet::parse("api:read")?;
@@ -137,7 +137,7 @@ async fn grant_client_credentials_public_client_rejected() -> TestResult {
 
 #[tokio::test]
 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 client = unique("m2m");
   let registered = ScopeSet::parse("api:read")?;
@@ -176,7 +176,7 @@ async fn grant_client_credentials_over_scope_rejected() -> TestResult {
 
 #[tokio::test]
 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 client = unique("m2m");
   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
 //! test drives the public [`super::super::poll_device`] API; DPoP proofs are
 //! 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 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 expired;
@@ -34,9 +26,6 @@ pub(super) type TestResult = Result<(), TestError>;
 /// Test scaffolding failures.
 #[derive(Debug, thiserror::Error)]
 pub(super) enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
@@ -85,35 +74,31 @@ pub(super) struct Harness {
   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()
     .await
     .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(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    state_prefix.clone(),
+    rl_prefix.clone(),
     crate::config::DurabilityMode::WaitAof,
   );
+  let redis_url = crate::test_support::test_redis_url();
   let fixture = serde_json::json!({
     "issuer": "https://auth.example.test",
     "environment": "development",
     "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"},
-    "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",
     "totp_encryption_key": "grant-device-test-totp-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)
     .map_err(|error| TestError::Unexpected(format!("config source: {error}")))?;
   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]
 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");
   setup::insert_client(
     &h.pool,
@@ -110,7 +110,7 @@ async fn test_grant_device_fast_poll_is_slow_down() -> TestResult {
 
 #[tokio::test]
 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");
   setup::insert_client(
     &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.
 #[tokio::test]
 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");
   setup::insert_client(
     &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.
 #[tokio::test]
 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");
   setup::insert_client(
     &h.pool,

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

@@ -11,7 +11,7 @@ use crate::{
 
 #[tokio::test]
 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");
   setup::insert_client(
     &h.pool,

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

@@ -14,7 +14,7 @@ use crate::{
 
 #[tokio::test]
 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");
   setup::insert_client(
     &h.pool,
@@ -66,7 +66,7 @@ async fn test_grant_device_public_without_dpop_rejected() -> TestResult {
 
 #[tokio::test]
 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");
   setup::insert_client(
     &h.pool,
@@ -134,7 +134,7 @@ async fn test_grant_device_confidential_secret_redeems_bearer() -> TestResult {
 
 #[tokio::test]
 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");
   setup::insert_client_bound(
     &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.
 #[tokio::test]
 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 intruder_id = unique("intruder");
   setup::insert_client(

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

@@ -20,7 +20,7 @@ use crate::{
 
 #[tokio::test]
 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");
   setup::insert_client(
     &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.
 #[tokio::test]
 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");
   setup::insert_client(
     &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.
 #[tokio::test]
 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 now = now_unix();
   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]
 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");
   setup::insert_client(
     &h.pool,
@@ -67,7 +67,7 @@ async fn test_grant_device_unentitled_scope_rejected() -> TestResult {
 
 #[tokio::test]
 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");
   setup::insert_client(
     &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.
 //!
-//! 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 policy;
 mod proof;
 
-use std::{
-  sync::atomic::{AtomicU64, Ordering},
-  time::Duration,
-};
+use std::sync::atomic::{AtomicU64, Ordering};
 
 use secrecy::SecretString;
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort as _, WaitFor},
-  runners::AsyncRunner as _,
-};
 
 use super::{ExchangeInput, TOKEN_TYPE_ACCESS_TOKEN};
 use crate::{
@@ -30,7 +22,6 @@ use crate::{
   },
   state::RedisConn,
   store::{Store, StoreError},
-  test_guard::{ContainerGuard, Lease, Shared},
 };
 
 /// 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.
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   #[error("redis error")]
   Redis(#[from] redis::RedisError),
@@ -62,35 +50,14 @@ enum TestError {
   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);

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

@@ -8,7 +8,7 @@ use crate::protocol::scope::ScopeSet;
 
 #[tokio::test]
 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 caller = unique("caller");
   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]
 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 subject_client = unique("subject-client");
   let subject_raw = mint_subject(
@@ -63,7 +63,7 @@ async fn grant_token_exchange_unlisted_pair_rejected() -> TestResult {
 
 #[tokio::test]
 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 caller = unique("caller");
   let subject_client = unique("subject-client");
@@ -114,7 +114,7 @@ async fn grant_token_exchange_unsupported_types_rejected() -> TestResult {
 
 #[tokio::test]
 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 caller = unique("caller");
   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]
 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 caller = unique("caller");
   let subject_client = unique("subject-client");
@@ -63,7 +63,7 @@ async fn grant_token_exchange_missing_proof_rejected() -> TestResult {
 
 #[tokio::test]
 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 caller = unique("caller");
   let subject_client = unique("subject-client");
@@ -114,7 +114,7 @@ async fn grant_token_exchange_subject_binding_mismatch_rejected() -> TestResult
 
 #[tokio::test]
 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 caller = unique("caller");
   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.
 //!
-//! 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 mint_find;
@@ -9,17 +10,9 @@ mod peppers;
 mod rotate;
 mod shape;
 
-use std::{
-  sync::atomic::{AtomicU64, Ordering},
-  time::Duration,
-};
+use std::sync::atomic::{AtomicU64, Ordering};
 
 use secrecy::SecretString;
-use testcontainers::{
-  GenericImage, ImageExt as _,
-  core::{IntoContainerPort as _, WaitFor},
-  runners::AsyncRunner as _,
-};
 
 use super::{ClientKind, Cnf, MintRequest, SenderProof, TokenService, TokenServiceError};
 use crate::{
@@ -27,7 +20,6 @@ use crate::{
   keys::{KeyId, KeyRing, KeyRingError},
   state::RedisConn,
   store::{Store, StoreError},
-  test_guard::{ContainerGuard, Lease, Shared},
 };
 
 /// 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.
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   #[error("redis error")]
   Redis(#[from] redis::RedisError),
@@ -56,37 +45,16 @@ enum TestError {
   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);

+ 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.
 #[tokio::test]
 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 grant = unique("grant");
   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.
 #[tokio::test]
 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 grant = unique("grant");
   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.
 #[tokio::test]
 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 grant = unique("grant");
   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.
 #[tokio::test]
 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 grant = unique("grant");
   let client = unique("client");

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

@@ -9,7 +9,7 @@ use super::{
 /// Lookup retries retained peppers newest-first.
 #[tokio::test]
 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 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.
 #[tokio::test]
 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 grant = unique("grant");
   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.
   #[error("config source error: {0}")]
   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.
   #[error("docker {0} failed")]
   Docker(String),

+ 6 - 18
src/state/tests_guard.rs

@@ -7,15 +7,11 @@
 
 use std::sync::Arc;
 
-use sqlx::postgres::PgPoolOptions;
-
 use super::{
   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.
 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.
 #[tokio::test]
 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?;
   seed_legacy(&admin).await?;
   // 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.
 #[tokio::test]
 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?;
   // Fresh install: no rows at all.
   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 testcontainers::{
-    GenericImage, ImageExt,
-    core::{IntoContainerPort, WaitFor},
-    runners::AsyncRunner,
-  };
 
   use super::{PendingAuth, PendingError, consume, get, new_request_id, pending_key, put};
   use crate::{
@@ -257,7 +252,6 @@ mod tests {
     error::{AppError, OAuthErrorCode},
     state::RedisConn,
     store::{Store, StoreError},
-    test_guard::ContainerGuard,
   };
 
   /// 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.
   #[derive(Debug, thiserror::Error)]
   enum TestError {
-    /// A testcontainers operation failed.
-    #[error("container error: {0}")]
-    Containers(#[from] testcontainers::TestcontainersError),
     /// The Redis configuration was rejected.
     #[error("config error: {0}")]
     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 {
-      url: Some(url.to_owned()),
+      url: Some(crate::test_support::test_redis_url()),
       durability: Some(DurabilityMode::WaitAof),
       response_timeout_secs: 10,
       connect_timeout_secs: 5,
+      token_key_prefix: Some(token_key_prefix),
+      ratelimit_key_prefix: Some(ratelimit_key_prefix),
       ..Default::default()
     };
     let config = RedisConfig::from_raw(raw)?;
@@ -375,8 +355,7 @@ mod tests {
   /// store → load (non-destructive) → consume exactly once; replay rejected.
   #[tokio::test]
   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 record = sample_record();
 
@@ -427,8 +406,7 @@ mod tests {
   /// Unknown and expired ids both render `invalid_request`.
   #[tokio::test]
   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.
     let missing = unique("missing");

+ 178 - 82
src/store/tests_store.rs

@@ -1,8 +1,9 @@
 //! `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::{
   sync::atomic::{AtomicU64, Ordering},
@@ -21,7 +22,10 @@ use super::{
   sha256_hex, tokens,
 };
 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,
   test_guard::ContainerGuard,
 };
@@ -85,17 +89,27 @@ async fn redis_container(extra: Vec<&str>) -> Result<(ContainerGuard, String), T
 async fn direct_store(
   url: &str,
   durability: DurabilityMode,
+  token_prefix: &str,
+  ratelimit_prefix: &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?;
+  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 {
     url: Some(url.to_owned()),
     durability: Some(durability),
     response_timeout_secs: 10,
     connect_timeout_secs: 5,
+    token_key_prefix: Some(token_prefix.to_owned()),
+    ratelimit_key_prefix: Some(ratelimit_prefix.to_owned()),
     ..Default::default()
   };
   let config = RedisConfig::from_raw(raw)?;
@@ -179,8 +193,10 @@ fn assert_ttl_within(pttl: i64, cap: Duration) -> TestResult {
 
 #[tokio::test]
 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 grant = unique("grant");
   let client = unique("client");
@@ -214,8 +230,10 @@ async fn store_code_consume_exactly_once() -> TestResult {
 
 #[tokio::test]
 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 client = unique("client");
   let old = unique("old-refresh");
@@ -285,8 +303,10 @@ async fn store_refresh_rotate_idempotent_replay() -> TestResult {
 
 #[tokio::test]
 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 client = unique("client");
   let old = unique("old-refresh");
@@ -369,8 +389,10 @@ async fn store_refresh_concurrent_cas_one_rotated_one_reuse() -> TestResult {
 
 #[tokio::test]
 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 client = unique("client");
   let old = unique("old-refresh");
@@ -472,8 +494,10 @@ async fn store_refresh_reuse_without_mapping_cascades() -> TestResult {
 
 #[tokio::test]
 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 client = unique("client");
   let refresh = unique("refresh");
@@ -541,8 +565,10 @@ async fn store_grant_revoke_cascade() -> TestResult {
 
 #[tokio::test]
 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 client = unique("client");
   let old = unique("old-refresh");
@@ -615,7 +641,13 @@ async fn store_persistence_survives_restart() -> TestResult {
   let refresh_hash = unique("refresh");
   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(
       &mut conn,
       &store,
@@ -642,6 +674,8 @@ async fn store_persistence_survives_restart() -> TestResult {
   let (mut conn, store) = direct_store(
     &format!("redis://127.0.0.1:{port}"),
     DurabilityMode::AppendFsyncAlways,
+    DEFAULT_TOKEN_KEY_PREFIX,
+    DEFAULT_RATELIMIT_KEY_PREFIX,
   )
   .await?;
 
@@ -675,7 +709,13 @@ async fn store_persistence_survives_restart() -> TestResult {
 async fn store_durability_barrier_modes() -> TestResult {
   let (_container, url) = redis_container(Vec::new()).await?;
   // 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?;
   // `WAIT 1` on a replica-less node acknowledges zero: fail closed.
   let wait = Store::from_parts(
@@ -701,15 +741,23 @@ async fn store_durability_barrier_modes() -> TestResult {
   }
   // ...and succeeds where the server actually fsyncs always.
   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?;
   Ok(())
 }
 
 #[tokio::test]
 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 window = Duration::from_secs(60);
 
@@ -744,8 +792,10 @@ async fn store_ratelimit_fixed_window() -> TestResult {
 
 #[tokio::test]
 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 assertion = unique("assertion");
 
@@ -778,8 +828,10 @@ async fn store_replay_markers_first_claim_wins() -> TestResult {
 
 #[tokio::test]
 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 new_session = unique("session");
   let record = sessions::SessionRecord {
@@ -855,8 +907,10 @@ async fn store_sessions_and_login_counters() -> TestResult {
 
 #[tokio::test]
 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");
 
   // 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).
 #[tokio::test]
 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 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).
 #[tokio::test]
 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 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).
 #[tokio::test]
 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 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.
 #[tokio::test]
 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 client = unique("client");
   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.
 #[tokio::test]
 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_id = unique("session");
@@ -1235,8 +1299,10 @@ async fn store_session_and_presession_ttl_within_configured_window() -> TestResu
 /// (`routes::PENDING_TTL`, 300 s).
 #[tokio::test]
 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");
 
   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.
 #[tokio::test]
 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 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.
 #[tokio::test]
 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 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).
 #[tokio::test]
 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 jti = unique("jti");
@@ -1659,8 +1731,10 @@ async fn store_retry_budget_bounds_idempotent_reads() -> TestResult {
   use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
   static ATTEMPTS: AtomicU32 = AtomicU32::new(0);
   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 {
     ATTEMPTS.fetch_add(1, AtomicOrdering::Relaxed);
     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).
 #[tokio::test]
 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 ttl = Duration::from_secs(300);
   let pairs = vec![
@@ -1750,8 +1826,10 @@ async fn store_hset_record_writes_fields_and_ttl() -> TestResult {
 /// member records behind it.
 #[tokio::test]
 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 client = unique("client");
   let seed = unique("seed-refresh");
@@ -1820,8 +1898,10 @@ async fn store_grant_revoke_exhaustion_is_not_silent() -> TestResult {
 /// admission).
 #[tokio::test]
 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 key = device::poll_key(&store, &device_hash);
   let _: () = redis::cmd("SET")
@@ -1928,8 +2008,10 @@ fn store_error_classification_lua_runtime_is_500() -> TestResult {
 /// the delete, i.e. a lost update.
 #[tokio::test]
 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");
   for round in 0..100u32 {
     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).
 #[tokio::test]
 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 client = unique("client");
   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.
 #[tokio::test]
 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 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).
 #[tokio::test]
 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(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    token_prefix.clone(),
+    rl_prefix.clone(),
     DurabilityMode::Wait,
   );
   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.
 #[tokio::test]
 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(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    token_prefix.clone(),
+    rl_prefix.clone(),
     DurabilityMode::Wait,
   );
   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).
 #[tokio::test]
 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 ttl = Duration::from_secs(300);
   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.
 #[tokio::test]
 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(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    token_prefix.clone(),
+    rl_prefix.clone(),
     DurabilityMode::Wait,
   );
   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.
 #[tokio::test]
 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(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    token_prefix.clone(),
+    rl_prefix.clone(),
     DurabilityMode::Wait,
   );
   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 {
   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 crate::{
     config::{DurabilityMode, RedisConfig, redis::RawRedisConfig},
     state::RedisConn,
     store::{Store, StoreError},
-    test_guard::ContainerGuard,
   };
 
   type TestResult = Result<(), TestError>;
 
   #[derive(Debug, thiserror::Error)]
   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}")]
     Config(#[from] crate::config::ConfigError),
     #[error("redis error: {0}")]
@@ -713,26 +706,16 @@ mod put_member_tests {
   /// shortens the set TTL.
   #[tokio::test]
   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 raw = RawRedisConfig {
-      url: Some(url),
+      url: Some(crate::test_support::test_redis_url()),
       durability: Some(DurabilityMode::WaitAof),
       response_timeout_secs: 10,
       connect_timeout_secs: 5,
+      token_key_prefix: Some(token_prefix),
+      ratelimit_key_prefix: Some(ratelimit_prefix),
       ..Default::default()
     };
     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
 //! 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
 //!   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
 //! `"../../src/test_guard.rs"` from `tests/common/`), so every compilation
 //! 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};
 

+ 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 secrecy::SecretString;
 use sqlx::{PgPool, Row as _};
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use uuid::Uuid;
 
 use super::{
@@ -45,7 +40,7 @@ use crate::{
     pending::{self, PendingAuth},
     sessions,
   },
-  test_guard::{ContainerGuard, Lease},
+  test_guard::Lease,
   ui::login::form_token,
 };
 
@@ -66,39 +61,20 @@ struct Harness {
   store: Store,
   /// PII key ring for the encrypted user-email lookup (strict encrypt-only).
   keys: KeyRing,
-  /// Redis container guard (synchronous removal on drop).
-  _redis: ContainerGuard,
 }
 
 /// Start Postgres (fresh DB) + Redis for one test.
 async fn harness() -> Result<Harness, TestError> {
   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 {
     _db: db,
     pool,
     conn: RedisConn::Single(manager),
     store,
     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.
 //!
 //! 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 sqlx::PgPool;
@@ -9,11 +10,6 @@ use std::{
   net::{IpAddr, Ipv4Addr, SocketAddr},
   time::Duration,
 };
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 use time::OffsetDateTime;
 use uuid::Uuid;
 
@@ -28,7 +24,6 @@ use crate::{
   state::RedisConn,
   store::Store,
   store::pending::{self, PendingAuth},
-  test_guard::ContainerGuard,
 };
 
 pub(super) mod auth;
@@ -46,9 +41,6 @@ pub(super) type TestResult = Result<(), TestError>;
 /// Scaffolding failures for the login suites.
 #[derive(Debug, thiserror::Error)]
 pub(super) enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A Redis command failed.
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
@@ -87,8 +79,6 @@ pub(super) struct Harness {
   pub(super) store: Store,
   /// Validated config (weak Argon2 for speed, test keys).
   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).
   pub(super) now: OffsetDateTime,
   /// 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()
     .await
     .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(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    state_prefix.clone(),
+    rl_prefix.clone(),
     crate::config::DurabilityMode::WaitAof,
   );
   let fixture = json!({
@@ -124,7 +104,12 @@ pub(super) async fn harness() -> Result<Harness, TestError> {
     "environment": "development",
     "tls": {"cert_path": "/tmp/login-test-cert.pem", "key_path": "/tmp/login-test-key.pem"},
     "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",
     "totp_encryption_key": "login-test-totp-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),
     store,
     config: std::sync::Arc::new(config),
-    _redis: guarded,
     now,
     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")
     .query_async(&mut h.conn)
     .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(
     client,
     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"));
   }
+  // 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(())
 }