Explorar o código

Added styles and improved tests

Efren Yevale Varela hai 1 mes
pai
achega
837c400c9f

+ 6 - 1
.github/workflows/ci.yml

@@ -126,8 +126,13 @@ jobs:
           tool: nextest
       - name: Install redis-server (in-process Sentinel/Cluster fixtures)
         run: sudo apt-get update && sudo apt-get install -y redis-server
-      - name: Full test suite (testcontainers provide Postgres/Redis)
+      - name: Start shared test services (Postgres + Redis)
+        run: docker compose -f compose.test.yaml up -d
+      - name: Full test suite (shared services; testcontainers for lifecycle)
         run: cargo nextest run --all-targets
+      - name: Stop shared test services
+        if: always()
+        run: docker compose -f compose.test.yaml down -v
 
   doc-tests:
     name: doc-tests

+ 10 - 2
AGENTS.md

@@ -78,8 +78,16 @@ right reason.
 cargo nextest run -E 'test(<behavior_name>)'   # red, then green
 ```
 
-Use real Postgres and Redis through testcontainers for integration. Prefer a
-real object or an in-memory fake over a mock. Assert the observable contract.
+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`.
 
 Quality gates for any change:
 

+ 7 - 0
Cargo.lock

@@ -944,6 +944,7 @@ dependencies = [
  "oauth2",
  "password-hash",
  "proptest",
+ "qrcode",
  "rand 0.10.3",
  "redis",
  "redis-test",
@@ -2703,6 +2704,12 @@ dependencies = [
  "prost",
 ]
 
+[[package]]
+name = "qrcode"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
+
 [[package]]
 name = "quick-error"
 version = "1.2.3"

+ 3 - 0
Cargo.toml

@@ -29,6 +29,9 @@ hyper = "1.11.1"
 hyper-util = { version = "0.1.21", features = ["server-auto", "tokio", "service"] }
 jsonwebtoken = { version = "11.1.0", features = ["rust_crypto"] }
 password-hash = "0.6.1"
+# TOTP enrollment QR: SVG-only (no `image` codecs), embedded inline in the
+# server-rendered setup page under a `script-src 'none'` policy.
+qrcode = { version = "0.14.1", default-features = false, features = ["svg"] }
 rand = { version = "0.10.3", features = ["std_rng"] }
 redis = { version = "1.7.1", features = ["tokio-comp", "connection-manager", "sentinel", "cluster-async", "tls-rustls", "tokio-rustls-comp", "tls-rustls-webpki-roots"] }
 # Task 22: the JAR validator fetches pre-registered `request_uri` objects

+ 18 - 5
README.md

@@ -29,8 +29,9 @@ state is in Redis, so replicas are interchangeable.
 - PostgreSQL 14 or newer with the `citext` extension available.
 - Redis 7.2 or newer, with persistence enabled and eviction off.
 - A TLS certificate and key for the public listener.
-- Docker, for the container image and for the integration tests that use
-  testcontainers.
+- 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).
 - `cargo-nextest` for the test suite:
   `cargo install cargo-nextest --locked`.
 
@@ -142,15 +143,27 @@ full grant behavior.
 
 ## Tests
 
-The authoritative suite:
+The authoritative suite. The integration and end-to-end tests connect to the
+shared Postgres and Redis from `compose.test.yaml` (Postgres on
+`127.0.0.1:5433`, Redis on `127.0.0.1:6380`), so start the services first and
+tear them down with `down -v`:
 
 ```bash
+docker compose -f compose.test.yaml up -d
 cargo nextest run --all-targets
 cargo test --doc
+docker compose -f compose.test.yaml down -v
 ```
 
-The integration and end-to-end tests start real Postgres and Redis containers,
-so Docker must be running. To iterate on one area, filter by name:
+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:
 
 ```bash
 cargo nextest run -E 'test(refresh_rotation)'

+ 44 - 0
compose.test.yaml

@@ -0,0 +1,44 @@
+# Test-only shared services for the compose-first integration harness.
+#
+# Starts ONE Postgres + ONE Redis shared by every test process. Per-test
+# isolation comes from the harness itself (fresh `CREATE DATABASE` per test,
+# unique Redis key prefixes per test) — never from per-test containers, and
+# never via FLUSHALL/FLUSHDB (nextest runs tests in parallel processes).
+#
+# Credentials and ports match the harness defaults exactly
+# (`D_OAUTH_TEST_POSTGRES_URL` / `D_OAUTH_TEST_REDIS_URL` overrides in
+# `tests/common/mod.rs`):
+#
+#   docker compose -f compose.test.yaml up -d
+#   cargo nextest run -E 'test(migration_users_creates_schema_and_second_run_is_noop)'
+#   docker compose -f compose.test.yaml down -v
+#
+# 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`.
+
+name: d-oauth-test
+
+services:
+  test-db:
+    image: postgres:16-alpine
+    environment:
+      POSTGRES_PASSWORD: oauth-test-only
+    ports:
+      - "5433:5432"
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U postgres"]
+      interval: 5s
+      timeout: 3s
+      retries: 20
+
+  test-redis:
+    image: redis:7-alpine
+    # Mirrors the server's durability contract: AOF on, eviction off.
+    command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
+    ports:
+      - "6380:6379"
+    healthcheck:
+      test: ["CMD", "redis-cli", "ping"]
+      interval: 5s
+      timeout: 3s
+      retries: 20

+ 1 - 1
docs/ARCHITECTURE.md

@@ -27,7 +27,7 @@ the library.
 | `src/db/` | Postgres repositories for the six domain tables. See [DATABASE.md](DATABASE.md). |
 | `src/store/` | Redis ephemeral state and the durability barrier. See [REDIS.md](REDIS.md). |
 | `src/protocol/` | OAuth endpoints and grants. See [OAUTH.md](OAUTH.md). |
-| `src/ui/` | Server-rendered login, consent, and device pages (Askama templates). |
+| `src/ui/` | Server-rendered login, consent, and device pages (Askama templates) plus the embedded same-origin stylesheet served at `/assets/app.css`. |
 | `src/middleware/` | Request id, tracing and redaction, rate limiting, security headers, and audit. |
 | `src/routes.rs` | Router assembly and the middleware order. |
 | `src/serve.rs`, `src/tls.rs` | Listener bootstrap and the custom TLS accept loop. |

+ 8 - 0
docs/CHANGELOG.md

@@ -24,6 +24,14 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
   and `client attach|detach-redirect-uri` accept `--kind`, and `config sync` file
   entries accept a `kind` field. A client may register at most one enabled
   `post_logout` URI.
+- The server-rendered pages (login, consent, device, TOTP) now share an embedded
+  same-origin stylesheet served at `GET /assets/app.css` (`src/ui/app.css`). It
+  adds no JavaScript and needs no CSP change: the page policy's existing
+  `style-src 'self'` covers it, so `script-src 'none'` still holds.
+- TOTP enrollment now renders the `otpauth://` URI as a server-side inline SVG
+  QR code beside the text secret and URI, so users can scan instead of
+  hand-typing. The new `qrcode` dependency is pinned SVG-only (no `image`
+  codecs); the text secret, URI, and one-time recovery codes are unchanged.
 
 ### Fixed
 

+ 40 - 10
docs/CONTRIBUTING.md

@@ -16,8 +16,9 @@ with the [README](../README.md) and [OPERATIONS.md](OPERATIONS.md).
   and `clippy`). `rustup` installs it automatically on first build.
 - [`cargo-nextest`](https://nexte.st/) for the test suite:
   `cargo install cargo-nextest --locked`.
-- Docker for the integration tests, which start real Postgres and Redis
-  containers through `testcontainers`.
+- 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`.
 - `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`.
 
@@ -48,9 +49,13 @@ cargo audit
 cargo deny check
 ```
 
-`cargo nextest run` is the authoritative test entry point. It runs unit,
-integration (testcontainers), and end-to-end tests. `cargo test --doc` runs the
-doc examples separately because nextest does not cover doctests.
+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.
 
 ### Narrow scope while developing
 
@@ -66,9 +71,30 @@ touched code. The full suite runs once, at integration time.
 
 ### Database-backed tests
 
-Integration tests start their own Postgres and Redis containers and apply the
-embedded migrations. They need a working Docker daemon, no more. Migrations are
-never run by the test process against a shared database.
+Integration tests connect to the shared Postgres and Redis started from
+`compose.test.yaml`:
+
+```bash
+docker compose -f compose.test.yaml up -d
+cargo nextest run --all-targets
+docker compose -f compose.test.yaml down -v
+```
+
+The defaults are Postgres on `127.0.0.1:5433` and Redis on `127.0.0.1:6380`,
+overridable through `D_OAUTH_TEST_POSTGRES_URL` and `D_OAUTH_TEST_REDIS_URL`.
+Each test isolates itself with a freshly created database and unique Redis key
+prefixes, so parallel tests never share state and nothing is flushed.
+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.
 
 ## Module conventions
 
@@ -112,8 +138,12 @@ Every behavior change follows red, green, refactor:
 2. Green: write the smallest change that makes the test pass.
 3. Refactor: restructure with the test green as the safety net.
 
-Prefer a real object or an in-memory fake over a mock. Use `testcontainers` for
-Postgres and Redis. Assert the observable contract, not an implementation dump.
+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.
 
 ## Commit style
 

+ 0 - 36
docs/TODO.md

@@ -175,42 +175,6 @@ the mitigations to implement.
 - **Proposed change.** Restrict who may exec and audit it.
 - **Files.** `docs/OPERATIONS.md`, `docs/DEPLOYMENT.md`.
 
-## Frontend usability
-
-The server-rendered pages (`src/ui/`) are intentionally minimal unstyled HTML
-with no JavaScript (the page CSP sets `script-src 'none'`). This section lists
-the usability work to make them presentable without weakening that posture:
-any styling must keep the CSP and the login CSRF tokens intact.
-
-### 14. Prettify frontend screens
-
-- **Problem.** The login, consent, device, password-change, and TOTP pages are
-  bare HTML. They work but look unfinished, which undermines trust in a
-  security-critical flow.
-- **Proposed change.** Add a small shared stylesheet served from the binary
-  (same-origin, no external assets, no inline scripts) with a clean, readable
-  layout for every page: consistent headings, readable forms, visible error
-  states (`role="alert"` preserved), and a responsive width. Keep zero
-  JavaScript so `script-src 'none'` still holds; if any inline `<style>` is
-  used, scope the CSP with a hash rather than `unsafe-inline`.
-- **Files.** `src/ui/` (all page templates), `src/routes.rs` (middleware order
-  if a static-asset route is added), `docs/ARCHITECTURE.md` (if the CSP
-  changes).
-
-### 15. Add QR code generation for TOTP enrollment
-
-- **Problem.** TOTP setup shows the `otpauth://` URI as text
-  (`src/crypto/totp.rs`), so enrolling means hand-typing the secret into the
-  authenticator app. Users expect to scan a QR code.
-- **Proposed change.** Render the enrollment secret as a QR code
-  server-side (e.g. the `qrcode` crate emitting SVG embedded in the page),
-  so no client-side script is needed and `script-src 'none'` still holds.
-  Keep showing the secret in text form as a fallback, alongside the recovery
-  codes already displayed once at enrollment.
-- **Files.** `src/crypto/totp.rs` (URI builder), `src/ui/login.rs` (TOTP setup
-  template), `Cargo.toml`/`Cargo.lock` (new dependency, must pass
-  `cargo deny check`).
-
 ## HTTP response hardening
 
 ### 16. Make the HTTP cache policy explicit on every endpoint

+ 1 - 1
src/cli/test_support.rs

@@ -169,7 +169,7 @@ async fn build() -> Result<Harness, TestError> {
 }
 
 async fn postgres_container() -> Result<(ContainerGuard, String), TestError> {
-  let container = GenericImage::new("postgres", "latest")
+  let container = GenericImage::new("postgres", "16-alpine")
     .with_exposed_port(5432.tcp())
     .with_wait_for(WaitFor::message_on_stderr(
       "database system is ready to accept connections",

+ 19 - 0
src/crypto/totp.rs

@@ -18,6 +18,7 @@
 use std::time::Duration;
 
 use base32::Alphabet;
+use qrcode::{QrCode, render::svg};
 use rand::{TryRng as _, rngs::SysRng};
 use ring::{
   aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
@@ -62,6 +63,9 @@ pub enum TotpError {
   /// The blob is malformed, names an unknown key, or fails authentication.
   #[error("TOTP secret cannot be decrypted")]
   Decrypt,
+  /// The enrollment URI is too long to encode as a QR code.
+  #[error("TOTP QR rendering failed")]
+  Qr,
   /// The replay store was unreachable.
   #[error("TOTP replay store unavailable")]
   Store(#[from] redis::RedisError),
@@ -361,6 +365,21 @@ pub fn otpauth_uri(issuer: &str, account: &str, secret_b32: &str) -> String {
   )
 }
 
+/// Render an `otpauth://` enrollment URI as a self-contained SVG QR code.
+///
+/// The output is inline SVG with no external references, no `<image>`, and no
+/// script, so it embeds directly in a server-rendered page under a
+/// `script-src 'none'` policy. An over-long URI maps to [`TotpError::Qr`]; no
+/// secret or URI content is logged.
+pub fn qr_svg(uri: &str) -> Result<String, TotpError> {
+  let code = QrCode::new(uri.as_bytes()).map_err(|_| TotpError::Qr)?;
+  let mut svg = code.render::<svg::Color>().min_dimensions(200, 200).build();
+  Ok(match svg.find("<svg") {
+    Some(start) => svg.split_off(start),
+    None => svg,
+  })
+}
+
 /// Derive the AES-256 key from configured TOTP key material (SHA-256).
 fn derive_key(totp_key: &[u8]) -> [u8; 32] {
   let digest = Sha256::digest(totp_key);

+ 25 - 1
src/crypto/totp/tests.rs

@@ -5,7 +5,7 @@ use secrecy::SecretString;
 
 use super::{
   RECOVERY_CODE_COUNT, TOTP_STEP_SECS, TotpError, build_totp, decrypt_secret, encrypt_secret,
-  generate_recovery_codes, generate_secret, normalize_recovery_code, otpauth_uri,
+  generate_recovery_codes, generate_secret, normalize_recovery_code, otpauth_uri, qr_svg,
   replay_fingerprint, verify_window,
 };
 use crate::keys::{KeyId, KeyRing};
@@ -208,3 +208,27 @@ fn login_totp_otpauth_uri_carries_secret_and_params() -> Result<(), TotpError> {
   }
   Ok(())
 }
+
+#[test]
+fn login_totp_qr_svg_is_self_contained() -> Result<(), TotpError> {
+  let uri = otpauth_uri("Example AS", "user@example.test", &rfc_secret());
+  let svg = qr_svg(&uri)?;
+  if !svg.starts_with("<svg") || !svg.trim_end().ends_with("</svg>") {
+    return Err(TotpError::Qr);
+  }
+  for forbidden in ["<script", "<image", "href="] {
+    if svg.contains(forbidden) {
+      return Err(TotpError::Qr);
+    }
+  }
+  Ok(())
+}
+
+#[test]
+fn login_totp_qr_svg_rejects_oversized_uri() -> Result<(), TotpError> {
+  let oversized = "A".repeat(4000);
+  if !matches!(qr_svg(&oversized), Err(TotpError::Qr)) {
+    return Err(TotpError::Qr);
+  }
+  Ok(())
+}

+ 2 - 2
src/db/mod.rs

@@ -15,7 +15,7 @@ pub use redirect_repo::{RedirectKind, RedirectUri};
 
 /// Test-only scaffolding shared by the repository suites.
 ///
-/// One `postgres:latest` container is started lazily and reused by every test
+/// One `postgres:16-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
@@ -54,7 +54,7 @@ pub(crate) mod test_support {
   /// 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", "latest")
+      let container = GenericImage::new("postgres", "16-alpine")
         .with_exposed_port(5432.tcp())
         .with_wait_for(WaitFor::message_on_stderr(
           "database system is ready to accept connections",

+ 6 - 3
src/protocol/authorize.rs

@@ -278,9 +278,12 @@ fn check_challenge_shape(challenge: &str) -> Result<(), AppError> {
 /// Render a direct error page: HTTP 400 HTML, no `Location`.
 fn error_page(error: &AppError) -> Response {
   let body = format!(
-    "<!doctype html><html><body><h1>{}</h1><p>{}</p></body></html>",
-    escape_html(error.code().as_str()),
-    escape_html(error.description()),
+    "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
+    <link rel=\"stylesheet\" href=\"/assets/app.css\">\
+    <title>{code}</title></head><body><h1>{code}</h1><p>{description}</p></body></html>",
+    code = escape_html(error.code().as_str()),
+    description = escape_html(error.description()),
   );
   let mut response = Html(body).into_response();
   *response.status_mut() = StatusCode::BAD_REQUEST;

+ 3 - 1
src/routes.rs

@@ -69,7 +69,7 @@ use crate::state::{AppState, health_router};
 use crate::store::pending::PendingAuth;
 use crate::store::{Store, pending};
 use crate::ui::device::DeviceForm;
-use crate::ui::{consent, device as device_page, login};
+use crate::ui::{assets, consent, device as device_page, login};
 
 /// Largest accepted request body (2 MiB; DCR may carry an inline `jwks`).
 pub const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
@@ -117,6 +117,7 @@ pub fn build_router_with_options(state: AppState, options: RouterOptions) -> axu
   let resilience = ResilienceConfig::from_policy(&state.config.failure_policy);
 
   let router = axum::Router::new()
+    .route("/assets/app.css", get(assets::stylesheet))
     .route(
       "/.well-known/oauth-authorization-server",
       get(metadata::metadata),
@@ -469,6 +470,7 @@ mod router_tests {
     let router = build_router(passthrough_state()?);
     let form = [("content-type", "application/x-www-form-urlencoded")];
     let routes: &[Route<'_>] = &[
+      ("GET", "/assets/app.css", &[]),
       ("GET", "/.well-known/oauth-authorization-server", &[]),
       ("GET", "/authorize?client_id=nobody", &[]),
       ("POST", "/par", &form),

+ 2 - 2
src/state/test_support.rs

@@ -1,6 +1,6 @@
 //! Shared testcontainers scaffolding for the `test(health)` suite.
 //!
-//! Postgres and Redis run from locally cached images (`postgres:latest`,
+//! Postgres and Redis run from locally cached images (`postgres:16-alpine`,
 //! `redis:7.2-alpine`); no pulls, no `redis-server` binary needed.
 //! Sentinel and Cluster topologies talk over container bridge IPs, which are
 //! directly routable from the Linux host, so Sentinel-advertised and
@@ -32,7 +32,7 @@ pub type TestResult = Result<(), TestError>;
 ///
 /// The guard removes the container synchronously when the test drops it.
 pub async fn postgres_container() -> Result<(ContainerGuard, String), TestError> {
-  let container = GenericImage::new("postgres", "latest")
+  let container = GenericImage::new("postgres", "16-alpine")
     .with_exposed_port(5432.tcp())
     .with_wait_for(WaitFor::message_on_stderr(
       "database system is ready to accept connections",

+ 235 - 0
src/ui/app.css

@@ -0,0 +1,235 @@
+/* Shared same-origin stylesheet for every server-rendered page.
+ *
+ * Embedded at compile time (`include_str!`) and served from `/assets/app.css`
+ * so `style-src 'self'` covers it without inline styles. No imports, no remote
+ * URLs, no web fonts, no JavaScript. */
+
+:root {
+  color-scheme: light dark;
+  --bg: #f4f5f7;
+  --surface: #ffffff;
+  --text: #1b1f24;
+  --muted: #59616c;
+  --border: #d5dae0;
+  --field: #f7f8fa;
+  --accent: #0b5cad;
+  --accent-hover: #094b8e;
+  --accent-contrast: #ffffff;
+  --focus: #0b5cad;
+  --alert-bg: #fdeceb;
+  --alert-border: #b3261e;
+  --alert-text: #7d1c17;
+  --code: #eef1f4;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --bg: #131619;
+    --surface: #1d2227;
+    --text: #e9ebee;
+    --muted: #a4acb7;
+    --border: #363d45;
+    --field: #15191d;
+    --accent: #77b3ec;
+    --accent-hover: #9ac8f2;
+    --accent-contrast: #10161c;
+    --focus: #9ac8f2;
+    --alert-bg: #3b1e1d;
+    --alert-border: #e58078;
+    --alert-text: #f7cec9;
+    --code: #262c32;
+  }
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+body {
+  max-width: 40rem;
+  min-height: 100vh;
+  margin: 0 auto;
+  padding: 2.5rem 1.25rem 3rem;
+  background: var(--bg);
+  color: var(--text);
+  font-family:
+    system-ui,
+    -apple-system,
+    "Segoe UI",
+    Roboto,
+    "Helvetica Neue",
+    Arial,
+    sans-serif;
+  font-size: 1rem;
+  line-height: 1.6;
+  overflow-wrap: break-word;
+  -webkit-text-size-adjust: 100%;
+}
+
+h1,
+h2,
+h3 {
+  margin: 0 0 1rem;
+  color: var(--text);
+  line-height: 1.25;
+  letter-spacing: -0.01em;
+}
+
+h1 {
+  font-size: 1.6rem;
+}
+
+h2 {
+  font-size: 1.3rem;
+}
+
+h3 {
+  font-size: 1.1rem;
+}
+
+p {
+  margin: 0 0 1rem;
+}
+
+a {
+  color: var(--accent);
+  text-underline-offset: 0.15em;
+}
+
+a:hover {
+  color: var(--accent-hover);
+}
+
+ul {
+  margin: 0 0 1rem;
+  padding-left: 1.5rem;
+}
+
+li {
+  margin: 0.3rem 0;
+}
+
+/* Recovery codes and one-time secrets: monospace, spaced for transcription. */
+li strong {
+  padding: 0.1rem 0.3rem;
+  border-radius: 0.25rem;
+  background: var(--code);
+  font-family:
+    ui-monospace,
+    SFMono-Regular,
+    Menlo,
+    Consolas,
+    "Liberation Mono",
+    monospace;
+  letter-spacing: 0.03em;
+  overflow-wrap: anywhere;
+}
+
+strong {
+  font-weight: 600;
+}
+
+/* Error and status messages: a distinct, non-color-only surface. */
+[role="alert"] {
+  margin: 0 0 1.25rem;
+  padding: 0.75rem 1rem;
+  border: 1px solid var(--alert-border);
+  border-left-width: 0.35rem;
+  border-radius: 0.4rem;
+  background: var(--alert-bg);
+  color: var(--alert-text);
+  font-weight: 600;
+}
+
+/* Forms are the card surface: background + border + padding separate them
+ * from the page without extra markup. */
+form {
+  margin: 0 0 1.5rem;
+  padding: 1.5rem;
+  border: 1px solid var(--border);
+  border-radius: 0.6rem;
+  background: var(--surface);
+}
+
+label {
+  display: block;
+  margin: 0 0 0.35rem;
+  font-size: 0.95rem;
+  font-weight: 600;
+}
+
+input:not([type="hidden"]),
+textarea {
+  display: block;
+  width: 100%;
+  min-height: 2.75rem;
+  margin: 0 0 1rem;
+  padding: 0.6rem 0.75rem;
+  border: 1px solid var(--border);
+  border-radius: 0.4rem;
+  background: var(--field);
+  color: var(--text);
+  /* 16px floor stops iOS Safari from zooming the layout on focus. */
+  font-family: inherit;
+  font-size: 1rem;
+}
+
+input[type="hidden"] {
+  display: none;
+}
+
+input::placeholder {
+  color: var(--muted);
+}
+
+button {
+  min-height: 2.75rem;
+  padding: 0.65rem 1.25rem;
+  border: 1px solid transparent;
+  border-radius: 0.4rem;
+  background: var(--accent);
+  color: var(--accent-contrast);
+  font: inherit;
+  font-weight: 600;
+  cursor: pointer;
+}
+
+button:hover {
+  background: var(--accent-hover);
+}
+
+/* Deny/destructive choices read as secondary next to Approve. */
+button[value="deny"] {
+  border-color: var(--border);
+  background: transparent;
+  color: var(--accent);
+}
+
+button[value="deny"]:hover {
+  border-color: var(--accent);
+  background: var(--field);
+}
+
+/* Keyboard focus only: a visible ring on every interactive control. */
+a:focus-visible,
+button:focus-visible,
+input:focus-visible {
+  outline: 3px solid var(--focus);
+  outline-offset: 2px;
+}
+
+@media (max-width: 30rem) {
+  body {
+    padding: 1.25rem 0.875rem 2rem;
+  }
+
+  form {
+    padding: 1.1rem;
+  }
+
+  button {
+    width: 100%;
+  }
+}

+ 20 - 0
src/ui/assets.rs

@@ -0,0 +1,20 @@
+//! Embedded same-origin assets for the server-rendered pages.
+//!
+//! The stylesheet is compiled into the binary (`include_str!`) and served from
+//! `/assets/app.css`; the page policy `style-src 'self'` allows the `<link>`
+//! without weakening CSP and without any inline style.
+
+use axum::http::header;
+use axum::response::{IntoResponse, Response};
+
+/// The shared page stylesheet (`src/ui/app.css`), embedded at compile time.
+pub const STYLESHEET: &str = include_str!("app.css");
+
+/// `GET /assets/app.css`: serve the embedded stylesheet as `text/css`.
+pub async fn stylesheet() -> Response {
+  (
+    [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
+    STYLESHEET,
+  )
+    .into_response()
+}

+ 2 - 0
src/ui/consent.rs

@@ -381,6 +381,8 @@ fn push_percent_encoded(out: &mut String, text: &str) {
 #[derive(Template)]
 #[template(
   source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
+  <link rel=\"stylesheet\" href=\"/assets/app.css\">\
   <title>Authorize</title></head><body>\
   <h1>Authorize {{ client_name }}</h1>\
   <p><strong>{{ client_name }}</strong> is requesting access.</p>\

+ 6 - 0
src/ui/device.rs

@@ -296,6 +296,8 @@ fn unhex(text: &str) -> Result<Vec<u8>, ()> {
 #[derive(Template)]
 #[template(
   source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
+  <link rel=\"stylesheet\" href=\"/assets/app.css\">\
   <title>Device verification</title></head><body>\
   <h1>Enter the code shown on your device</h1>\
   {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
@@ -316,6 +318,8 @@ struct EntryTemplate {
 #[derive(Template)]
 #[template(
   source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
+  <link rel=\"stylesheet\" href=\"/assets/app.css\">\
   <title>Authorize device</title></head><body>\
   <h1>Authorize this device?</h1>\
   <p>Code <strong>{{ user_code_display }}</strong></p>\
@@ -340,6 +344,8 @@ struct ReviewTemplate {
 #[derive(Template)]
 #[template(
   source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
+  <link rel=\"stylesheet\" href=\"/assets/app.css\">\
   <title>Device {{ verdict }}</title></head><body>\
   {% if approved %}<h1>Device approved</h1>\
   <p>You may return to your device; it will pick up the authorization.</p>\

+ 38 - 15
src/ui/login.rs

@@ -1015,15 +1015,17 @@ pub struct ConfirmForm {
 #[derive(Template)]
 #[template(
   source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
+  <link rel=\"stylesheet\" href=\"/assets/app.css\">\
   <title>Sign in</title></head><body>\
   <h1>Sign in</h1>\
   {% if has_client %}<p>Continue to <strong>{{ client }}</strong>.</p>{% endif %}\
   {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
   <form method=\"post\" action=\"/login\">\
   <label for=\"email\">Email</label>\
-  <input id=\"email\" name=\"email\" type=\"email\" value=\"{{ email }}\" autocomplete=\"username\">\
+  <input id=\"email\" name=\"email\" type=\"email\" value=\"{{ email }}\" inputmode=\"email\" autocomplete=\"username\" autocapitalize=\"none\" spellcheck=\"false\">\
   <label for=\"password\">Password</label>\
-  <input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\">\
+  <input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" autocapitalize=\"none\" spellcheck=\"false\">\
   <label for=\"code\">Authenticator code or recovery code</label>\
   <input id=\"code\" name=\"code\" autocomplete=\"one-time-code\">\
   <input type=\"hidden\" name=\"csrf\" value=\"{{ csrf }}\">\
@@ -1043,14 +1045,16 @@ struct LoginTemplate {
 #[derive(Template)]
 #[template(
   source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
+  <link rel=\"stylesheet\" href=\"/assets/app.css\">\
   <title>Change password</title></head><body>\
   <h1>Change your password</h1>\
   {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
   <form method=\"post\" action=\"/login/change-password\">\
   <label for=\"new_password\">New password (at least 12 characters)</label>\
-  <input id=\"new_password\" name=\"new_password\" type=\"password\" autocomplete=\"new-password\">\
+  <input id=\"new_password\" name=\"new_password\" type=\"password\" autocomplete=\"new-password\" autocapitalize=\"none\" spellcheck=\"false\">\
   <label for=\"confirm\">Confirm new password</label>\
-  <input id=\"confirm\" name=\"confirm\" type=\"password\" autocomplete=\"new-password\">\
+  <input id=\"confirm\" name=\"confirm\" type=\"password\" autocomplete=\"new-password\" autocapitalize=\"none\" spellcheck=\"false\">\
   <input type=\"hidden\" name=\"csrf\" value=\"{{ csrf }}\">\
   <button type=\"submit\">Change password</button></form></body></html>",
   ext = "html"
@@ -1065,9 +1069,12 @@ struct PasswordTemplate {
 #[derive(Template)]
 #[template(
   source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
+  <link rel=\"stylesheet\" href=\"/assets/app.css\">\
   <title>Set up authenticator</title></head><body>\
   <h1>Set up your authenticator</h1>\
-  <p>Scan the secret into your authenticator app, then confirm with a code.</p>\
+  <p>Scan the QR code or enter the secret into your authenticator app, then confirm with a code.</p>\
+  <div class=\"qr\">{{ qr_svg|safe }}</div>\
   <p>Secret: <strong>{{ secret }}</strong></p>\
   <p>URI: {{ uri }}</p>\
   <p>Recovery codes (shown once, store them safely):</p>\
@@ -1075,7 +1082,7 @@ struct PasswordTemplate {
   {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
   <form method=\"post\" action=\"/login/totp/confirm\">\
   <label for=\"code\">Authenticator code</label>\
-  <input id=\"code\" name=\"code\" autocomplete=\"one-time-code\">\
+  <input id=\"code\" name=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" pattern=\"[0-9]*\" autocapitalize=\"none\" spellcheck=\"false\">\
   <input type=\"hidden\" name=\"csrf\" value=\"{{ csrf }}\">\
   <button type=\"submit\">Confirm</button></form></body></html>",
   ext = "html"
@@ -1084,6 +1091,7 @@ struct SetupTemplate {
   csrf: String,
   secret: String,
   uri: String,
+  qr_svg: String,
   codes: Vec<String>,
   error: String,
   has_error: bool,
@@ -1093,12 +1101,14 @@ struct SetupTemplate {
 #[derive(Template)]
 #[template(
   source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
+  <link rel=\"stylesheet\" href=\"/assets/app.css\">\
   <title>Confirm authenticator</title></head><body>\
   <h1>Confirm your authenticator</h1>\
   {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
   <form method=\"post\" action=\"/login/totp/confirm\">\
   <label for=\"code\">Authenticator code</label>\
-  <input id=\"code\" name=\"code\" autocomplete=\"one-time-code\">\
+  <input id=\"code\" name=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" pattern=\"[0-9]*\" autocapitalize=\"none\" spellcheck=\"false\">\
   <input type=\"hidden\" name=\"csrf\" value=\"{{ csrf }}\">\
   <button type=\"submit\">Confirm</button></form></body></html>",
   ext = "html"
@@ -1113,6 +1123,8 @@ struct ConfirmTemplate {
 #[derive(Template)]
 #[template(
   source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
+  <link rel=\"stylesheet\" href=\"/assets/app.css\">\
   <title>Signed in</title></head><body>\
   <h1>Signed in</h1><p>You are signed in.</p>\
   <form method=\"post\" action=\"/logout\">\
@@ -1129,6 +1141,8 @@ struct SuccessTemplate;
 #[derive(Template)]
 #[template(
   source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
+  <link rel=\"stylesheet\" href=\"/assets/app.css\">\
   <title>Signed out</title></head><body>\
   <h1>Signed out</h1><p>Your session has ended.</p>\
   <p><a href=\"/login\">Sign in again</a>.</p></body></html>",
@@ -1325,14 +1339,21 @@ pub async fn get_totp_setup(State(state): State<AppState>, jar: CookieJar) -> Re
   }
   let mut deps = deps_of(&state, &mut conn, &store);
   match provision_totp(&mut deps, &session_id).await {
-    Ok(offer) => render(SetupTemplate {
-      csrf: form_token(&live.meta.csrf_secret, TOTP_CONFIRM_ACTION),
-      secret: offer.secret,
-      uri: offer.otpauth_uri,
-      codes: offer.recovery_codes,
-      error: String::new(),
-      has_error: false,
-    }),
+    Ok(offer) => {
+      let qr_svg = match totp::qr_svg(&offer.otpauth_uri) {
+        Ok(svg) => svg,
+        Err(_) => return AppError::server_error().into_response(),
+      };
+      render(SetupTemplate {
+        csrf: form_token(&live.meta.csrf_secret, TOTP_CONFIRM_ACTION),
+        secret: offer.secret,
+        uri: offer.otpauth_uri,
+        qr_svg,
+        codes: offer.recovery_codes,
+        error: String::new(),
+        has_error: false,
+      })
+    }
     Err(error) => error.into_response(),
   }
 }
@@ -1548,6 +1569,8 @@ fn render(template: impl Template) -> Response {
 fn login_rejected_page(email: &str) -> String {
   format!(
     "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
+    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
+    <link rel=\"stylesheet\" href=\"/assets/app.css\">\
     <title>Sign in</title></head><body>\
     <h1>Sign in</h1>\
     <p role=\"alert\">{INVALID_CREDENTIALS}</p>\

+ 1 - 0
src/ui/mod.rs

@@ -6,6 +6,7 @@
 //!
 //! TODO(task 25): wire re-exports for `login`/`consent`/`device`.
 
+pub mod assets;
 pub mod consent;
 pub mod device;
 pub mod login;

+ 53 - 56
tests/bootstrap.rs

@@ -1,10 +1,15 @@
-//! `test(bootstrap)` — real `serve` bootstrap over live containers (plan todo 41).
+//! `test(bootstrap)` — real `serve` bootstrap over the shared test services
+//! (plan todo 41).
 //!
 //! Both tests boot the actual `d-oauth-server` binary (never an in-process
 //! router): `config check` first (fail-fast env smoke), then `migrate` (the
 //! only path that applies migrations — `serve` never does), then `serve`
 //! with a TLS public listener (PKI client-CA, the mTLS reachability
-//! contract) plus the plaintext pod-IP health listener.
+//! contract) plus the plaintext pod-IP health listener. Postgres and Redis
+//! come from `compose.test.yaml`
+//! (`docker compose -f compose.test.yaml up -d`); each test uses a freshly
+//! created database and unique Redis key prefixes, so parallel tests never
+//! share state (and never FLUSH shared state).
 //!
 //! * `bootstrap_serve_tls_mtls_registered_completes_unregistered_rejected`:
 //!   `/health/ready` is 200 beforehand, mutual-TLS traffic flows through
@@ -28,9 +33,7 @@
 #[allow(dead_code)]
 mod certs;
 
-#[path = "../src/test_guard.rs"]
-#[allow(dead_code)]
-mod test_guard;
+mod common;
 
 use std::{
   net::SocketAddr,
@@ -40,18 +43,11 @@ use std::{
   time::{Duration, Instant},
 };
 
+use common::{pg_server_url, redis_url, unique_prefixes};
 use d_oauth::protocol::mtls;
 use rustls::pki_types::{CertificateDer, PrivateKeyDer};
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
-
-use test_guard::ContainerGuard;
+use sqlx::{Executor as _, postgres::PgPoolOptions};
 
-/// Throwaway Postgres superuser password (alphanumeric: safe in URLs).
-const PG_PASSWORD: &str = "bootstraptestsecret";
 /// Registered `tls_client_auth` client for the mTLS test.
 const CLIENT_ID: &str = "bootstrap-tls-client";
 /// Subject DN of the CA-signed fixture, in [`mtls::subject_dn`] rendering.
@@ -62,9 +58,9 @@ const PKI_THUMBPRINT: &str = "PC8ir71uIdRIdw1XFkMmoFvGC8Ud4CANxkk4x3BWqAc";
 /// Test-body error.
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
+  /// The shared test service is unreachable (usually: compose not started).
+  #[error("test setup: {0}")]
+  Setup(String),
   /// A sqlx operation failed.
   #[error("database error: {0}")]
   Database(#[from] sqlx::Error),
@@ -99,11 +95,9 @@ enum TestError {
 
 type TestResult<T = ()> = Result<T, TestError>;
 
-/// Live world for one test: guarded containers, ports, fixture files, and
+/// Live world for one test: shared-service URLs, ports, fixture files, and
 /// the config file the child `serve` process boots from.
 struct World {
-  _postgres: ContainerGuard,
-  _redis: ContainerGuard,
   pg_url: String,
   tls_port: u16,
   health_port: u16,
@@ -140,42 +134,42 @@ async fn free_port() -> Result<u16, TestError> {
   Ok(listener.local_addr()?.port())
 }
 
-/// Start Postgres and return the guard plus a plaintext URL.
-async fn start_postgres() -> Result<(ContainerGuard, String), TestError> {
-  let container = GenericImage::new("postgres", "latest")
-    .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))
-}
-
-/// Start a single-node Redis with AOF on (satisfies the durability contract).
-async fn start_redis() -> Result<(ContainerGuard, String), TestError> {
-  let container = GenericImage::new("redis", "7.2-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}")))
+/// Hint shown when the shared test services are unreachable.
+const COMPOSE_HINT: &str = "run `docker compose -f compose.test.yaml up -d`";
+
+/// Create a uniquely named database on the shared Postgres server and return
+/// a URL pointing at it (the child `serve` process migrates it via its own
+/// `migrate` subcommand, like production).
+async fn fresh_db_url() -> Result<String, TestError> {
+  let server = pg_server_url();
+  let admin = PgPoolOptions::new()
+    .max_connections(1)
+    .connect(&server)
+    .await
+    .map_err(|error| {
+      TestError::Setup(format!(
+        "cannot reach shared test Postgres at {server}: {error}; {COMPOSE_HINT}"
+      ))
+    })?;
+  let name = format!("bootstrap_{}", 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?;
+  Ok(server.replacen("/postgres?", &format!("/{name}?"), 1))
 }
 
-/// Build one test world: containers, ports, PEM fixture files, config file.
+/// Build one test world: shared-service URLs, ports, PEM fixture files,
+/// config file.
 async fn build_world() -> Result<World, TestError> {
-  let (postgres, pg_url) = start_postgres().await?;
-  let (redis, redis_url) = start_redis().await?;
+  let pg_url = fresh_db_url().await?;
+  let redis_url = redis_url();
+  let (token_prefix, ratelimit_prefix) = unique_prefixes();
   let tls_port = free_port().await?;
   let health_port = free_port().await?;
   let dir = std::env::temp_dir().join(format!("bootstrap-test-{}", uuid::Uuid::new_v4()));
@@ -197,7 +191,12 @@ async fn build_world() -> Result<World, TestError> {
       "client_ca_path": path_string(&ca_path)?,
     },
     "database": {"url": pg_url.clone()},
-    "redis": {"url": redis_url, "durability": "waitaof"},
+    "redis": {
+      "url": redis_url,
+      "durability": "waitaof",
+      "token_key_prefix": token_prefix,
+      "ratelimit_key_prefix": ratelimit_prefix,
+    },
     "token_pepper": "bootstrap-test-pepper-0123456789abcdef",
     "totp_encryption_key": "bootstrap-test-totp-key-0123456789abcdef",
   });
@@ -209,8 +208,6 @@ async fn build_world() -> Result<World, TestError> {
   )?;
   let stem = config_file.with_extension("");
   Ok(World {
-    _postgres: postgres,
-    _redis: redis,
     pg_url,
     tls_port,
     health_port,

+ 107 - 37
tests/common/mod.rs

@@ -1,21 +1,27 @@
+// Dead code is expected here: this file is `#[path]`-included by several
+// test binaries (`migration_clients`, the e2e harness), each using a
+// different subset of these shared helpers.
+#![allow(dead_code)]
+
 //! Shared scaffolding for Postgres-backed migration integration tests.
 //!
+//! Compose-first: tests connect to the shared external services from
+//! `compose.test.yaml` (`docker compose -f compose.test.yaml up -d`) instead
+//! of booting per-test containers. Per-test isolation comes from a freshly
+//! created database per `postgres()` call, so parallel tests never share
+//! state (and never FLUSH shared state).
+//!
 //! The migration SQL is embedded byte-for-byte from the repo so assertions
 //! run against the exact shipped file. Test bodies use `Result` and `?`
 //! (`unwrap`/`expect`/`panic` are denied workspace-wide).
+//!
+//! Reachability: this module is included via `#[path]` both as
+//! `tests/common/mod.rs` (from `tests/migration_clients.rs`) and as
+//! `../common/mod.rs` (from `tests/e2e/harness.rs`), so the helpers below
+//! (`pg_server_url`, `redis_url`, `fresh_migrated_db`, `unique_prefixes`)
+//! are the single shared definition — no duplication needed.
 
-use sqlx::{PgPool, Row as _};
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
-
-#[path = "../../src/test_guard.rs"]
-#[allow(dead_code)]
-mod test_guard;
-
-use test_guard::ContainerGuard;
+use sqlx::{Executor as _, PgPool, Row as _, postgres::PgPoolOptions};
 
 /// The `oauth_clients` migration under test (todo 8).
 pub const OAUTH_CLIENTS_SQL: &str = include_str!("../../migrations/1791072000_oauth_clients.sql");
@@ -23,41 +29,105 @@ pub const OAUTH_CLIENTS_SQL: &str = include_str!("../../migrations/1791072000_oa
 /// Version encoded in the `oauth_clients` migration filename.
 pub const OAUTH_CLIENTS_VERSION: i64 = 1_791_072_000;
 
-/// Throwaway Postgres superuser password.
-const PG_PASSWORD: &str = "migration-clients-secret";
+/// Hint shown when the shared test services are unreachable.
+const COMPOSE_HINT: &str = "run `docker compose -f compose.test.yaml up -d`";
 
 /// Aggregate error so test bodies use `?` instead of panicking.
 #[derive(Debug, thiserror::Error)]
 pub enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
+  /// The shared test service is unreachable (usually: compose not started).
+  #[error("test setup: {0}")]
+  Setup(String),
   /// A sqlx operation failed.
   #[error("database error: {0}")]
   Database(#[from] sqlx::Error),
 }
 
-/// Start a fresh Postgres and connect a pool to it.
+/// 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 pg_server_url() -> String {
+  match std::env::var("D_OAUTH_TEST_POSTGRES_URL") {
+    Ok(url) => url,
+    Err(_) => {
+      "postgres://postgres:oauth-test-only@127.0.0.1:5433/postgres?sslmode=disable".to_owned()
+    }
+  }
+}
+
+/// URL of the shared test Redis server.
 ///
-/// The returned guard must stay alive for the duration of the test; dropping
-/// it removes the container synchronously.
-pub async fn postgres() -> Result<(ContainerGuard, PgPool), TestError> {
-  let container = GenericImage::new("postgres", "latest")
-    .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");
-  let pool = PgPool::connect(&url).await?;
-  Ok((guarded, pool))
+/// Overridable via `D_OAUTH_TEST_REDIS_URL`; the default matches
+/// `compose.test.yaml` (`test-redis`, port 6380).
+pub fn redis_url() -> String {
+  match std::env::var("D_OAUTH_TEST_REDIS_URL") {
+    Ok(url) => url,
+    Err(_) => "redis://127.0.0.1:6380".to_owned(),
+  }
+}
+
+/// 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.
+///
+/// The database is empty (not migrated); callers apply exactly the migration
+/// SQL they assert on. A connection failure names the compose file to start.
+pub async fn postgres() -> Result<PgPool, TestError> {
+  fresh_db(&pg_server_url()).await
+}
+
+/// A fresh database on the shared server with every embedded migration applied.
+pub async fn fresh_migrated_db() -> Result<PgPool, TestError> {
+  let pool = fresh_db(&pg_server_url()).await?;
+  d_oauth::state::MIGRATOR
+    .run(&pool)
+    .await
+    .map_err(|error| TestError::Setup(format!("migrations failed to apply: {error}")))?;
+  Ok(pool)
+}
+
+/// Create a uniquely named database on the shared server and pool to it.
+async fn fresh_db(server_url: &str) -> Result<PgPool, TestError> {
+  let admin = PgPoolOptions::new()
+    .max_connections(1)
+    .connect(server_url)
+    .await
+    .map_err(|error| connect_hint(server_url, &error))?;
+  let name = format!("mig_{}", 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 = server_url.replacen("/postgres?", &format!("/{name}?"), 1);
+  PgPoolOptions::new()
+    .max_connections(4)
+    .connect(&target)
+    .await
+    .map_err(|error| connect_hint(&target, &error))
+}
+
+/// 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}"
+  ))
 }
 
 /// Apply a migration script exactly as the migrator would (multi-statement).

+ 87 - 113
tests/e2e/harness.rs

@@ -1,9 +1,11 @@
 //! Shared E2E harness for the task-43 conformance + abuse suites.
 //!
 //! Every test boots the REAL production router ([`d_oauth::routes::build_router`],
-//! unmodified) over a REAL loopback TCP socket, against REAL testcontainers
-//! Postgres + Redis with the shipped migrations applied. Nothing is mocked;
-//! every assertion reads an HTTP status/header/body observed through `reqwest`.
+//! unmodified) over a REAL loopback TCP socket, against the REAL shared
+//! Postgres + Redis from `compose.test.yaml`
+//! (`docker compose -f compose.test.yaml up -d`) with the shipped migrations
+//! applied. Nothing is mocked; every assertion reads an HTTP
+//! status/header/body observed through `reqwest`.
 //!
 //! TLS termination is intentionally NOT re-proven here: the task-41
 //! `bootstrap` suite already drives mutual-TLS through the production accept
@@ -14,6 +16,12 @@
 //! (DPoP proofs, PAR pushes, device flow, introspection, revocation) is
 //! hand-rolled `reqwest` so the suite never depends on crate support the
 //! extensions do not have.
+//!
+//! Isolation: each `World` migrates a freshly created database on the shared
+//! server and uses unique Redis key prefixes, so parallel tests never share
+//! state (and never FLUSH shared state).
+//!
+//! [`Store`]: d_oauth::store::Store
 
 use std::net::SocketAddr;
 
@@ -26,86 +34,37 @@ use d_oauth::{
   state::{AppState, RedisConn, StateError},
   store::Store,
 };
-use testcontainers::{
-  ContainerAsync, GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 
-/// RAII owner of one testcontainer with synchronous removal on drop.
-///
-/// Under nextest each test is its own process, so the fire-and-forget removal
-/// that `testcontainers` schedules on `Drop` never runs and the container
-/// leaks. This guard takes the container out of itself, removes it on a fresh
-/// OS thread + fresh current-thread runtime, and joins — the same pattern as
-/// the crate's `test_guard`, inlined here so this suite owns every item it
-/// compiles (no shared-module dead code, no suppressions).
-pub struct E2EGuard {
-  /// The live container, `None` only while `Drop` is running.
-  inner: Option<ContainerAsync<GenericImage>>,
-}
+// `tests/common/mod.rs` is the single definition of the shared-service
+// helpers (`pg_server_url`, `redis_url`, `unique_prefixes`); this `#[path]`
+// include (relative to `tests/e2e/`) keeps the e2e harness on the same
+// contract without duplicating it.
+#[path = "../common/mod.rs"]
+mod common;
 
-impl E2EGuard {
-  /// Take ownership of a started container.
-  pub fn new(container: ContainerAsync<GenericImage>) -> Self {
-    Self {
-      inner: Some(container),
-    }
-  }
+use common::{pg_server_url, redis_url, unique_prefixes};
+use sqlx::{Executor as _, postgres::PgPoolOptions};
 
-  /// Host port mapped from a container port.
-  pub async fn get_host_port_ipv4(
-    &self,
-    port: u16,
-  ) -> Result<u16, testcontainers::TestcontainersError> {
-    // `inner` is `None` only inside `Drop` on this same value (which holds
-    // `&mut self`), so every observable call sees `Some`; a missing value
-    // reports the mapping error rather than panicking.
-    match &self.inner {
-      Some(container) => container.get_host_port_ipv4(port).await,
-      None => Err(testcontainers::TestcontainersError::Other(
-        "container guard is being dropped".into(),
-      )),
-    }
-  }
+/// Placeholder guard kept so [`World`]'s shape is unchanged.
+///
+/// With shared external services there is no per-test container to guard;
+/// per-World isolation comes from a fresh database plus unique Redis key
+/// prefixes instead. The fields exist only so every existing caller keeps
+/// compiling unchanged.
+pub struct E2EGuard {
+  /// Unused; present only to preserve the struct shape.
+  _placeholder: (),
 }
 
-impl Drop for E2EGuard {
-  fn drop(&mut self) {
-    if let Some(container) = self.inner.take() {
-      let id = container.id().to_string();
-      let thread_id = id.clone();
-      let outcome = std::thread::spawn(move || match tokio::runtime::Builder::new_current_thread()
-        .enable_all()
-        .build()
-      {
-        Ok(runtime) => {
-          if let Err(error) = runtime.block_on(container.rm()) {
-            eprintln!("e2e guard: failed to remove container {thread_id}: {error}");
-          }
-        }
-        Err(error) => {
-          eprintln!("e2e guard: failed to build removal runtime for {thread_id}: {error}");
-        }
-      })
-      .join();
-      if outcome.is_err() {
-        eprintln!("e2e guard: removal thread failed for container {id}");
-      }
-    }
-  }
-}
 /// Argon2id work factor for seeded client secrets (fast: test-only).
 const SEED_MEM_KIB: u32 = 8;
-/// Throwaway Postgres superuser password (alphanumeric: safe in URLs).
-const PG_PASSWORD: &str = "e2etask43secret";
+
+/// Hint shown when the shared test services are unreachable.
+const COMPOSE_HINT: &str = "run `docker compose -f compose.test.yaml up -d`";
 
 /// Harness failure (no `unwrap`/`expect`/`panic` under the deny lints).
 #[derive(Debug, thiserror::Error)]
 pub enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
   /// A database operation failed.
   #[error("database error: {0}")]
   Database(#[from] sqlx::Error),
@@ -139,13 +98,13 @@ pub fn ensure(cond: bool, message: impl Into<String>) -> Result<(), TestError> {
   if cond { Ok(()) } else { Err(fail(message)) }
 }
 
-/// Live world for one E2E test: guarded containers, migrated DB, connected
+/// Live world for one E2E test: shared-service handles, migrated DB, connected
 /// state, the production router on a loopback socket, and a redirect-free
 /// `reqwest` client pointed at it.
 pub struct World {
-  /// Postgres container (guarded: synchronously removed on drop).
+  /// Placeholder (no per-test container under shared services).
   _postgres: E2EGuard,
-  /// Redis container (guarded: synchronously removed on drop).
+  /// Placeholder (no per-test container under shared services).
   _redis: E2EGuard,
   /// Migrated Postgres pool (same database the router serves).
   pub pool: sqlx::PgPool,
@@ -170,14 +129,24 @@ impl Drop for World {
 }
 
 impl World {
-  /// Start one world: containers, migrations, connected state, live router.
+  /// Start one world: fresh DB + unique Redis prefixes on the shared
+  /// services, migrations, connected state, live router.
   ///
   /// `rate_limit` is the `rate_limit` table of the config fixture, so the
   /// abuse suite can pin tiny budgets while conformance runs wide open.
   pub async fn start(rate_limit: serde_json::Value) -> Result<Self, TestError> {
-    let (postgres, pg_url) = start_postgres().await?;
-    let (redis, redis_url) = start_redis().await?;
-    let pool = sqlx::PgPool::connect(&pg_url).await?;
+    let pg_url = fresh_db_url().await?;
+    let redis_url = redis_url();
+    let (token_prefix, ratelimit_prefix) = unique_prefixes();
+    let pool = PgPoolOptions::new()
+      .max_connections(4)
+      .connect(&pg_url)
+      .await
+      .map_err(|error| {
+        fail(format!(
+          "cannot reach shared test Postgres at {pg_url}: {error}; {COMPOSE_HINT}"
+        ))
+      })?;
     d_oauth::state::MIGRATOR
       .run(&pool)
       .await
@@ -190,7 +159,12 @@ impl World {
       "health_addr": "127.0.0.1:0",
       "tls": {"cert_path": "/tmp/e2e-cert.pem", "key_path": "/tmp/e2e-key.pem"},
       "database": {"url": pg_url},
-      "redis": {"url": redis_url, "durability": "waitaof"},
+      "redis": {
+        "url": redis_url,
+        "durability": "waitaof",
+        "token_key_prefix": token_prefix,
+        "ratelimit_key_prefix": ratelimit_prefix,
+      },
       "token_pepper": "e2e-test-pepper-0123456789abcdef",
       "totp_encryption_key": "e2e-test-totp-key-0123456789abcdef",
       "rate_limit": rate_limit,
@@ -198,7 +172,13 @@ impl World {
     });
     let config =
       Config::from_source(::config::Config::try_from(&fixture).map_err(ConfigError::from)?)?;
-    let state = AppState::connect(std::sync::Arc::new(config)).await?;
+    let state = AppState::connect(std::sync::Arc::new(config))
+      .await
+      .map_err(|error| {
+        fail(format!(
+          "cannot connect shared test services ({COMPOSE_HINT}): {error}"
+        ))
+      })?;
     let store = Store::from_config(&state.config.redis);
     let service = TokenService::new(&state.config.ttl, &state.keys)
       .map_err(|error| fail(format!("token service failed: {error}")))?;
@@ -218,8 +198,8 @@ impl World {
       .timeout(std::time::Duration::from_secs(10))
       .build()?;
     Ok(Self {
-      _postgres: postgres,
-      _redis: redis,
+      _postgres: E2EGuard { _placeholder: () },
+      _redis: E2EGuard { _placeholder: () },
       pool,
       state,
       store,
@@ -303,36 +283,30 @@ impl World {
   }
 }
 
-/// Start Postgres and return the guard plus a plaintext URL.
-async fn start_postgres() -> Result<(E2EGuard, String), TestError> {
-  let container = GenericImage::new("postgres", "latest")
-    .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 = E2EGuard::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))
-}
-
-/// Start a single-node Redis with AOF on (satisfies the durability contract).
-async fn start_redis() -> Result<(E2EGuard, String), TestError> {
-  let container = GenericImage::new("redis", "7.2-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 = E2EGuard::new(container);
-  let port = guarded.get_host_port_ipv4(6379).await?;
-  Ok((guarded, format!("redis://127.0.0.1:{port}")))
+/// Create a uniquely named database on the shared Postgres server and return
+/// a URL pointing at it (the caller connects and migrates).
+async fn fresh_db_url() -> Result<String, TestError> {
+  let server = pg_server_url();
+  let admin = PgPoolOptions::new()
+    .max_connections(1)
+    .connect(&server)
+    .await
+    .map_err(|error| {
+      fail(format!(
+        "cannot reach shared test Postgres at {server}: {error}; {COMPOSE_HINT}"
+      ))
+    })?;
+  let name = format!("e2e_{}", 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| fail(format!("failed to create test database {name}: {error}")));
+  admin.close().await;
+  created?;
+  Ok(server.replacen("/postgres?", &format!("/{name}?"), 1))
 }
 
 /// Default (wide-open) rate-limit table for conformance worlds.

+ 2 - 2
tests/e2e_abuse.rs

@@ -1,8 +1,8 @@
 //! `test(e2e_)` abuse matrix over the live router: wired-endpoint rows.
 //!
 //! Every test boots the REAL production router over a REAL loopback socket
-//! against REAL testcontainers Postgres + Redis (see `e2e/harness.rs`) and
-//! asserts only observable HTTP behavior. `#[test]` names all start with
+//! against the shared Postgres + Redis from `compose.test.yaml` (see
+//! `e2e/harness.rs`) and asserts only observable HTTP behavior. `#[test]` names all start with
 //! `e2e_` so `cargo nextest run -E 'test(e2e_)'` selects exactly this suite.
 //!
 //! Rows whose endpoint is wired live here; `/token` grant rows live in

+ 2 - 2
tests/e2e_grants.rs

@@ -1,8 +1,8 @@
 //! `test(e2e_)` token-grant rows over the live router: the wired dispatcher.
 //!
 //! Every test boots the REAL production router ([`d_oauth::routes::build_router`])
-//! over a REAL loopback socket against REAL testcontainers Postgres + Redis
-//! and asserts only observable HTTP behavior: the five grants dispatch, the
+//! over a REAL loopback socket against the shared Postgres + Redis from
+//! `compose.test.yaml` and asserts only observable HTTP behavior: the five grants dispatch, the
 //! sender-constraint matrix holds, and every failure is the specified 4xx.
 //!
 //! The `oauth2` crate drives the baseline shapes (PKCE material, code

+ 43 - 37
tests/jar_support.rs

@@ -1,8 +1,13 @@
 //! Shared scaffolding for the `test(jar)` suites.
 //!
-//! Fixture client projection, JWKS, shared Redis container, and outcome
+//! Fixture client projection, JWKS, shared Redis handles, and outcome
 //! helpers. Included via `#[path]` from `jar.rs` and `jar_request_uri.rs`;
 //! this file itself carries no tests.
+//!
+//! Redis comes from the shared services in `compose.test.yaml`
+//! (`docker compose -f compose.test.yaml up -d`); each `store_conn` call
+//! uses unique key prefixes, so parallel tests never share state (and never
+//! FLUSH shared state).
 
 use std::{collections::HashMap, time::Duration};
 
@@ -13,17 +18,17 @@ use d_oauth::{
   store::Store,
 };
 use serde_json::{Value, json};
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
+
+#[path = "common/mod.rs"]
+mod common;
+
+use common::{redis_url, unique_prefixes};
 
 #[path = "../src/test_guard.rs"]
 #[allow(dead_code)]
 mod test_guard;
 
-use test_guard::{ContainerGuard, Lease, Shared};
+use test_guard::{Lease, Shared};
 use time::OffsetDateTime;
 
 /// Test body result (no `unwrap`/`expect` under the deny lints).
@@ -32,9 +37,9 @@ pub type TestResult = Result<(), TestError>;
 /// Errors raised by the JAR test scaffolding itself.
 #[derive(Debug, thiserror::Error)]
 pub enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
+  /// The shared test service is unreachable (usually: compose not started).
+  #[error("test setup: {0}")]
+  Setup(String),
   /// A Redis command failed.
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
@@ -97,54 +102,55 @@ pub fn test_client(auth_method: &str, request_uris: Option<Value>) -> Client {
   }
 }
 
-/// Shared Redis server plus the guard keeping it alive.
+/// Shared Redis server handle (no per-test container: the URL points at the
+/// shared services from `compose.test.yaml`).
 ///
-/// The guard removes the container synchronously once the last test in the
-/// process releases the shared slot.
+/// The lease keeps the shared slot alive; bind it (usually as `_redis`) for
+/// the duration of the test.
 pub struct JarRedis {
-  _container: ContainerGuard,
   url: String,
 }
 
 static JAR_REDIS: Shared<JarRedis> = Shared::new();
 
-/// Start the shared Redis once; each test connects its own manager.
+/// Resolve the shared Redis URL once; each test connects its own manager.
 ///
-/// The returned lease keeps the shared container alive; bind it (usually as
+/// The returned lease keeps the shared slot alive; bind it (usually as
 /// `_redis`) for the duration of the test.
 async fn shared_redis() -> Result<Lease<JarRedis>, TestError> {
   JAR_REDIS
-    .lease::<TestError>(async {
-      let container = GenericImage::new("redis", "7.2-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(JarRedis {
-        _container: guarded,
-        url: format!("redis://127.0.0.1:{port}"),
-      })
-    })
+    .lease::<TestError>(async { Ok(JarRedis { url: redis_url() }) })
     .await
 }
 
-/// A connected [`RedisConn`] plus [`Store`] over the shared container.
+/// A connected [`RedisConn`] plus [`Store`] over the shared Redis.
 ///
-/// The returned lease keeps the shared container alive; bind it (usually as
-/// `_redis`) for the duration of the test.
+/// The returned lease keeps the shared slot alive; bind it (usually as
+/// `_redis`) for the duration of the test. Key prefixes are unique per
+/// call, so parallel tests never share state.
 pub async fn store_conn() -> Result<(Lease<JarRedis>, RedisConn, Store), TestError> {
   let redis = shared_redis().await?;
-  let client = redis::Client::open(redis.url.as_str())?;
+  let client = redis::Client::open(redis.url.as_str()).map_err(|error| {
+    TestError::Setup(format!(
+      "cannot reach shared test Redis at {}: {error}; run `docker compose -f compose.test.yaml up -d`",
+      redis.url,
+    ))
+  })?;
   let 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, config).await?;
+  let manager = redis::aio::ConnectionManager::new_with_config(client, config)
+    .await
+    .map_err(|error| {
+      TestError::Setup(format!(
+        "cannot reach shared test Redis at {}: {error}; run `docker compose -f compose.test.yaml up -d`",
+        redis.url,
+      ))
+    })?;
+  let (token_prefix, ratelimit_prefix) = unique_prefixes();
   let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
+    token_prefix,
+    ratelimit_prefix,
     d_oauth::config::DurabilityMode::WaitAof,
   );
   Ok((redis, RedisConn::Single(manager), store))

+ 4 - 4
tests/migration_clients.rs

@@ -34,7 +34,7 @@ fn migration_clients_is_embedded_in_migrator() {
 /// column outside the plan was added.
 #[tokio::test]
 async fn migration_clients_columns_match_plan() -> Result<(), TestError> {
-  let (_container, pool) = postgres().await?;
+  let pool = postgres().await?;
   migrate(&pool, OAUTH_CLIENTS_SQL).await?;
 
   let expected: BTreeSet<(String, String, String)> = [
@@ -82,7 +82,7 @@ async fn migration_clients_columns_match_plan() -> Result<(), TestError> {
 /// indexes on the mTLS identity columns exist and are shaped as specified.
 #[tokio::test]
 async fn migration_clients_indexes_are_unique_and_partial() -> Result<(), TestError> {
-  let (_container, pool) = postgres().await?;
+  let pool = postgres().await?;
   migrate(&pool, OAUTH_CLIENTS_SQL).await?;
 
   let defs = client_indexes(&pool).await?;
@@ -132,7 +132,7 @@ async fn migration_clients_indexes_are_unique_and_partial() -> Result<(), TestEr
 /// violate a unique index; NULL identity fields remain unconstrained.
 #[tokio::test]
 async fn migration_clients_reject_ambiguous_identities() -> Result<(), TestError> {
-  let (_container, pool) = postgres().await?;
+  let pool = postgres().await?;
   migrate(&pool, OAUTH_CLIENTS_SQL).await?;
 
   let alpha = insert_client(
@@ -216,7 +216,7 @@ async fn migration_clients_reject_ambiguous_identities() -> Result<(), TestError
 /// to false, so pre-existing rows stay unconstrained.
 #[tokio::test]
 async fn migration_clients_dpop_bound_column_defaults_false() -> Result<(), TestError> {
-  let (_container, pool) = postgres().await?;
+  let pool = postgres().await?;
   migrate(&pool, OAUTH_CLIENTS_SQL).await?;
   migrate(
     &pool,

+ 44 - 48
tests/migration_users.rs

@@ -1,4 +1,4 @@
-//! `test(migration_users)` — real-Postgres (testcontainers) tests for
+//! `test(migration_users)` — real-Postgres (shared external server) tests for
 //! `migrations/*_users.sql`: the users / scopes / user_scopes / redirect_uris
 //! / client_redirect_uris half of the six-table schema.
 //!
@@ -9,7 +9,8 @@
 //! second migration run, and the checksum guard rejecting a tampered
 //! already-applied migration.
 //!
-//! One Postgres container is shared for the whole binary; each test migrates a
+//! The shared Postgres server comes from `compose.test.yaml`
+//! (`docker compose -f compose.test.yaml up -d`); each test migrates a
 //! freshly created database so the tests stay isolated under parallel execution.
 
 use std::{
@@ -24,24 +25,39 @@ use sqlx::{
   migrate::{MigrateError, Migrator},
   postgres::PgPoolOptions,
 };
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 
 #[path = "../src/test_guard.rs"]
 #[allow(dead_code)]
 mod test_guard;
 
-use test_guard::{ContainerGuard, Lease, Shared};
+use test_guard::{Lease, Shared};
 
 /// Embedded migrations — the exact compile-time set `d_oauth::state::MIGRATOR`
 /// serves from (task 8's `oauth_clients` + this task's `users`).
 static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
 
-/// Test Postgres superuser password.
-const PG_PASSWORD: &str = "migration-users-secret";
+/// 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). Start it with
+/// `docker compose -f compose.test.yaml up -d`.
+fn pg_server_url() -> String {
+  match std::env::var("D_OAUTH_TEST_POSTGRES_URL") {
+    Ok(url) => url,
+    Err(_) => {
+      "postgres://postgres:oauth-test-only@127.0.0.1:5433/postgres?sslmode=disable".to_owned()
+    }
+  }
+}
+
+/// The shared Postgres server address.
+///
+/// Compose-first: no container is booted here. The lease only keeps the
+/// address alive for the test; the server itself is shared across all test
+/// processes via `compose.test.yaml`.
+struct Db {
+  url: String,
+}
 
 /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
 type TestError = Box<dyn Error + Send + Sync>;
@@ -49,54 +65,34 @@ type TestError = Box<dyn Error + Send + Sync>;
 /// Fallible test body.
 type TestResult = Result<(), TestError>;
 
-/// The shared Postgres server plus the guard keeping it alive.
-///
-/// The guard removes the container synchronously once the last test in the
-/// process releases the shared slot.
-struct Db {
-  _container: ContainerGuard,
-  url: String,
-}
-
 /// The one Postgres server reused by every test in this binary.
 static DB: Shared<Db> = Shared::new();
 
-/// Start (once) and return a lease on the shared Postgres server.
+/// Resolve (once) and return a lease on the shared Postgres server.
 ///
-/// The lease must stay alive as long as the server is used; dropping the last
-/// lease removes the shared container deterministically.
+/// Compose-first: no container is booted. The lease must stay alive as long
+/// as the server is used; bind it (usually as `_db`) for the test duration.
+/// A connection failure names the compose file to start instead of surfacing
+/// a bare driver error.
 async fn shared_db() -> Result<Lease<Db>, TestError> {
   DB.lease::<TestError>(async {
-    let (container, url) = start_postgres().await?;
-    Ok(Db {
-      _container: container,
-      url,
-    })
+    let url = pg_server_url();
+    let probe = PgPoolOptions::new()
+      .max_connections(1)
+      .connect(&url)
+      .await
+      .map_err(|error| {
+        format!(
+          "cannot reach shared test Postgres at {url}: {error}; \
+           run `docker compose -f compose.test.yaml up -d`"
+        )
+      })?;
+    probe.close().await;
+    Ok(Db { url })
   })
   .await
 }
 
-/// Start a `postgres:latest` container and return it with a plaintext URL.
-///
-/// The guard removes the container synchronously when the test drops it.
-async fn start_postgres() -> Result<(ContainerGuard, String), TestError> {
-  let container = GenericImage::new("postgres", "latest")
-    .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))
-}
-
 /// Create a uniquely named database on the shared server and pool to it.
 async fn fresh_pool(server_url: &str) -> Result<PgPool, TestError> {
   let admin = PgPoolOptions::new()

+ 15 - 38
tests/mtls.rs

@@ -26,17 +26,7 @@ use d_oauth::{
     self, MtlsCnf, MtlsError, PROXY_CERT_HEADER, PROXY_VERIFY_HEADER, PROXY_VERIFY_SUCCESS,
   },
 };
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
-
-#[path = "../src/test_guard.rs"]
-#[allow(dead_code)]
-mod test_guard;
-
-use test_guard::ContainerGuard;
+mod common;
 
 /// The `oauth_clients` migration, embedded byte-for-byte from the repo.
 const OAUTH_CLIENTS_SQL: &str = include_str!("../migrations/1791072000_oauth_clients.sql");
@@ -44,26 +34,13 @@ const OAUTH_CLIENTS_SQL: &str = include_str!("../migrations/1791072000_oauth_cli
 /// The forward-only client column additions the repository queries need.
 const CLIENT_DPOP_BOUND_SQL: &str = include_str!("../migrations/1791244800_client_dpop_bound.sql");
 
-/// Throwaway Postgres superuser password.
-const PG_PASSWORD: &str = "mtls-test-secret";
-
-/// Start a fresh Postgres and connect a pool to it. The returned guard
-/// must stay alive for the duration of the test; dropping it removes the
-/// container synchronously.
-async fn mtls_postgres() -> Result<(ContainerGuard, sqlx::PgPool), TestError> {
-  let container = GenericImage::new("postgres", "latest")
-    .with_exposed_port(5432.tcp())
-    .with_wait_for(WaitFor::message_on_stderr(
-      "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");
-  let pool = sqlx::PgPool::connect(&url).await?;
-  Ok((guarded, pool))
+/// Start a fresh database on the shared test Postgres and connect a pool
+/// to it (see `tests/common/mod.rs`). Each test migrates its own database,
+/// so parallel tests never share state.
+async fn mtls_postgres() -> Result<sqlx::PgPool, TestError> {
+  common::postgres()
+    .await
+    .map_err(|error| TestError::Setup(error.to_string()))
 }
 
 /// Apply the embedded migration script exactly as the migrator would.
@@ -86,9 +63,9 @@ const PKI_DN: &str = "CN=mtls-pki-client, O=Example";
 /// Test-body error.
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
+  /// The shared test service is unreachable (usually: compose not started).
+  #[error("test setup: {0}")]
+  Setup(String),
   /// A sqlx operation failed.
   #[error("database error: {0}")]
   Database(#[from] sqlx::Error),
@@ -210,7 +187,7 @@ fn mtls_subject_dn_renders_rfc4514() -> TestResult {
 /// A registered self-signed certificate binds `cnf.x5t#S256`.
 #[tokio::test]
 async fn mtls_registered_thumbprint_binds_cnf() -> TestResult {
-  let (_container, pool) = mtls_postgres().await?;
+  let pool = mtls_postgres().await?;
   mtls_migrate(&pool).await?;
   insert_mtls_client(
     &pool,
@@ -233,7 +210,7 @@ async fn mtls_registered_thumbprint_binds_cnf() -> TestResult {
 /// A CA-issued certificate binds via subject DN under `tls_client_auth`.
 #[tokio::test]
 async fn mtls_pki_subject_dn_binds_cnf() -> TestResult {
-  let (_container, pool) = mtls_postgres().await?;
+  let pool = mtls_postgres().await?;
   mtls_migrate(&pool).await?;
   insert_mtls_client(&pool, "mtls-pki", "tls_client_auth", Some(PKI_DN), None).await?;
   let client = client_repo::find_active_by_client_id(&pool, "mtls-pki")
@@ -248,7 +225,7 @@ async fn mtls_pki_subject_dn_binds_cnf() -> TestResult {
 /// An unregistered certificate is rejected as `invalid_client`.
 #[tokio::test]
 async fn mtls_unregistered_thumbprint_rejected_as_invalid_client() -> TestResult {
-  let (_container, pool) = mtls_postgres().await?;
+  let pool = mtls_postgres().await?;
   mtls_migrate(&pool).await?;
   insert_mtls_client(
     &pool,
@@ -271,7 +248,7 @@ async fn mtls_unregistered_thumbprint_rejected_as_invalid_client() -> TestResult
 /// A missing certificate rejects every mTLS-registered client.
 #[tokio::test]
 async fn mtls_missing_cert_rejects_mtls_clients() -> TestResult {
-  let (_container, pool) = mtls_postgres().await?;
+  let pool = mtls_postgres().await?;
   mtls_migrate(&pool).await?;
   let pinned = fixture_client("self_signed_tls_client_auth", None, Some(SELF_THUMBPRINT));
   let result = mtls::authenticate(&pool, &pinned, None).await;

+ 30 - 36
tests/proptest_rotation.rs

@@ -2,11 +2,13 @@
 //!
 //! Pure `proptest` properties over `store::tokens::derive_rotation_id`
 //! (deterministic, 64 lowercase hex chars, input-sensitive) plus
-//! container-backed rotation invariants over a real single-node Redis
-//! (AOF on, `WaitAof` durability): first rotation applies, an idempotent
-//! retry replays identical tokens, an unknown token is absent, and a foreign
-//! client mismatches. Containers are owned by `ContainerGuard` (never
-//! `ContainerAsync` directly). No `unwrap`/`expect`/`panic` anywhere.
+//! shared-service rotation invariants over the real Redis from
+//! `compose.test.yaml` (`docker compose -f compose.test.yaml up -d`, AOF on,
+//! `WaitAof` durability): first rotation applies, an idempotent retry replays
+//! identical tokens, an unknown token is absent, and a foreign client
+//! mismatches. Each test uses unique key prefixes, so parallel tests never
+//! share state (and never FLUSH shared state). No `unwrap`/`expect`/`panic`
+//! anywhere.
 
 use std::{
   sync::atomic::{AtomicU64, Ordering},
@@ -25,24 +27,17 @@ use d_oauth::{
   },
 };
 use proptest::prelude::*;
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 
-#[path = "../src/test_guard.rs"]
-#[allow(dead_code)]
-mod test_guard;
+mod common;
 
-use test_guard::ContainerGuard;
+use common::{redis_url, unique_prefixes};
 
 /// Test-body error.
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
+  /// The shared test service is unreachable (usually: compose not started).
+  #[error("test setup: {0}")]
+  Setup(String),
   /// A Redis command failed.
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
@@ -107,30 +102,29 @@ proptest! {
   }
 }
 
-/// Start a single-node Redis with AOF on (the `WaitAof` barrier needs it).
-async fn redis_single() -> Result<(ContainerGuard, String), TestError> {
-  let container = GenericImage::new("redis", "7.2-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}")))
+/// URL of the shared test Redis (see `tests/common/mod.rs`).
+fn redis_single() -> String {
+  redis_url()
 }
 
 async fn direct_store(url: &str) -> Result<(RedisConn, Store), TestError> {
-  let client = redis::Client::open(url)?;
+  let client = redis::Client::open(url).map_err(|error| {
+    TestError::Setup(format!(
+      "cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`"
+    ))
+  })?;
   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 = redis::aio::ConnectionManager::new_with_config(client, manager_config)
+    .await
+    .map_err(|error| {
+      TestError::Setup(format!(
+        "cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`"
+      ))
+    })?;
+  let (token_prefix, ratelimit_prefix) = unique_prefixes();
+  let store = Store::from_parts(token_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
   Ok((RedisConn::Single(manager), store))
 }
 
@@ -178,7 +172,7 @@ fn rotation_input(
 /// Rotation applies once, replays idempotently, and rejects strangers.
 #[tokio::test]
 async fn rotation_apply_replay_absent_mismatch() -> TestResult {
-  let (_guard, url) = redis_single().await?;
+  let url = redis_single();
   let (mut conn, store) = direct_store(&url).await?;
   let pepper = b"rotation-proptest-pepper";
   let client = unique("client");

+ 1 - 1
tests/resilience.rs

@@ -74,7 +74,7 @@ fn pg_url_for(port: u16) -> String {
 
 /// Start Postgres and return the guard plus a plaintext URL.
 async fn postgres_container() -> Result<(ContainerGuard, String), TestError> {
-  let container = GenericImage::new("postgres", "latest")
+  let container = GenericImage::new("postgres", "16-alpine")
     .with_exposed_port(5432.tcp())
     .with_wait_for(WaitFor::message_on_stderr(
       "database system is ready to accept connections",

+ 28 - 36
tests/topology.rs

@@ -9,11 +9,12 @@
 //!   boots and a `{state}`-tagged key round-trips through
 //!   `ClusterClient` (the single-slot convention the token store relies on).
 //!
-//! Docker/testcontainers parity for CI environments without a `redis-server`
+//! Shared-service parity for CI environments without a `redis-server`
 //! binary:
-//! * `topology_single_node_store_roundtrip` — same assertions shape against a
-//!   `redis:7.2-alpine` container owned by `ContainerGuard`, driving the real
-//!   `Store::put_access`/`get_access` through our key layout.
+//! * `topology_single_node_store_roundtrip` — same assertions shape against
+//!   the shared Redis from `compose.test.yaml`
+//!   (`docker compose -f compose.test.yaml up -d`) with unique key prefixes,
+//!   driving the real `Store::put_access`/`get_access` through our key layout.
 //!
 //! The `redis-test` legs skip gracefully (green, noted on stderr) when no
 //! `redis-server` binary is on `PATH`; CI installs one via apt (see
@@ -38,24 +39,17 @@ use redis_test::{
   cluster::{RedisCluster, RedisClusterConfiguration},
   sentinel::RedisSentinelCluster,
 };
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 
-#[path = "../src/test_guard.rs"]
-#[allow(dead_code)]
-mod test_guard;
+mod common;
 
-use test_guard::ContainerGuard;
+use common::{redis_url, unique_prefixes};
 
 /// Test-body error.
 #[derive(Debug, thiserror::Error)]
 enum TestError {
-  /// A testcontainers operation failed.
-  #[error("container error: {0}")]
-  Containers(#[from] testcontainers::TestcontainersError),
+  /// The shared test service is unreachable (usually: compose not started).
+  #[error("test setup: {0}")]
+  Setup(String),
   /// A Redis command failed.
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
@@ -229,32 +223,30 @@ async fn topology_cluster_single_slot_roundtrip() -> TestResult {
   Ok(())
 }
 
-/// Parity leg that needs only Docker: the real token-store access path
-/// against a single node, so CI stays green where no `redis-server` binary
-/// exists but Docker does.
+/// Parity leg that needs only the shared services: the real token-store
+/// access path against the shared Redis with unique key prefixes, so CI
+/// stays green where no `redis-server` binary exists but Docker does.
 #[tokio::test]
 async fn topology_single_node_store_roundtrip() -> TestResult {
-  let container = GenericImage::new("redis", "7.2-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 url = redis_url();
+  let client = redis::Client::open(url.as_str()).map_err(|error| {
+    TestError::Setup(format!(
+      "cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`"
+    ))
+  })?;
   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 = redis::aio::ConnectionManager::new_with_config(client, manager_config)
+    .await
+    .map_err(|error| {
+      TestError::Setup(format!(
+        "cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`"
+      ))
+    })?;
   let mut conn = RedisConn::Single(manager);
-  let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
-    DurabilityMode::WaitAof,
-  );
+  let (token_prefix, ratelimit_prefix) = unique_prefixes();
+  let store = Store::from_parts(token_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
 
   let token_hash = "topology-parity-probe";
   let record = AccessRecord {

+ 37 - 69
tests/user_repo.rs

@@ -1,9 +1,13 @@
-//! `test(user_repo)` — real `testcontainers` Postgres + Redis coverage for
+//! `test(user_repo)` — shared-services Postgres + Redis coverage for
 //! `db/{user_repo,scope_repo,user_scope_repo}` and the Redis lockout counters.
 //!
-//! Postgres backs the user/scope/entitlement assertions; a single-node Redis
-//! backs the failed-login/lockout assertions. Test bodies use `Result` + `?`
-//! (`unwrap`/`expect`/`panic` are denied workspace-wide).
+//! Postgres backs the user/scope/entitlement assertions; the shared Redis
+//! backs the failed-login/lockout assertions. Both come from
+//! `compose.test.yaml` (`docker compose -f compose.test.yaml up -d`); each
+//! test migrates a freshly created database and uses unique Redis key
+//! prefixes, so parallel tests never share state (and never FLUSH shared
+//! state). Test bodies use `Result` + `?` (`unwrap`/`expect`/`panic` are
+//! denied workspace-wide).
 
 use std::{error::Error, time::Duration};
 
@@ -15,21 +19,14 @@ use d_oauth::{
     user_repo::{self, LockoutPolicy, NewUser, PasswordVerdict},
     user_scope_repo,
   },
-  state::{MIGRATOR, RedisConn},
+  state::RedisConn,
   store::Store,
 };
 use sqlx::PgPool;
-use testcontainers::{
-  GenericImage, ImageExt,
-  core::{IntoContainerPort, WaitFor},
-  runners::AsyncRunner,
-};
 
-#[path = "../src/test_guard.rs"]
-#[allow(dead_code)]
-mod test_guard;
+mod common;
 
-use test_guard::ContainerGuard;
+use common::{fresh_migrated_db, redis_url, unique_prefixes};
 
 /// Boxed test error.
 type TestError = Box<dyn Error + Send + Sync>;
@@ -37,14 +34,10 @@ type TestError = Box<dyn Error + Send + Sync>;
 /// Fallible test body.
 type TestResult = Result<(), TestError>;
 
-/// Throwaway Postgres superuser password.
-const PG_PASSWORD: &str = "user-repo-test-secret";
-
-/// Fast Argon2id parameters keep container-backed tests quick.
+/// Fast Argon2id parameters keep the hashing-dependent tests quick.
 fn fast_params() -> Result<Argon2Params, TestError> {
   Ok(Argon2Params::new(8, 1, 1, 32)?)
 }
-
 /// Build a `NewUser` with a freshly hashed password.
 fn new_user(
   email: &str,
@@ -60,56 +53,31 @@ fn new_user(
   })
 }
 
-/// Start Postgres, apply every migration, and return the pool.
-///
-/// The guard removes the container synchronously when the test drops it.
-async fn postgres_pool() -> Result<(ContainerGuard, PgPool), TestError> {
-  let container = GenericImage::new("postgres", "latest")
-    .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");
-  let pool = PgPool::connect(&url).await?;
-  MIGRATOR.run(&pool).await?;
-  Ok((guarded, pool))
+/// A pool on a freshly created, fully migrated database on the shared
+/// test Postgres (see `tests/common/mod.rs`).
+async fn postgres_pool() -> Result<PgPool, TestError> {
+  Ok(fresh_migrated_db().await?)
 }
 
-/// Start a single-node Redis and return a [`RedisConn`] plus [`Store`].
-///
-/// The guard removes the container synchronously when the test drops it.
-async fn redis_store() -> Result<(ContainerGuard, RedisConn, Store), TestError> {
-  let container = GenericImage::new("redis", "7.2-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 = redis::aio::ConnectionManager::new(client).await?;
-  let store = Store::from_parts(
-    "oauth:{state}:".to_owned(),
-    "oauth:{rl}:".to_owned(),
-    DurabilityMode::WaitAof,
-  );
-  Ok((guarded, RedisConn::Single(manager), store))
+/// A [`RedisConn`] plus [`Store`] on the shared test Redis with unique key
+/// prefixes, so parallel tests never share state.
+async fn redis_store() -> Result<(RedisConn, Store), TestError> {
+  let url = redis_url();
+  let client = redis::Client::open(url.as_str()).map_err(|error| {
+    format!("cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`")
+  })?;
+  let manager = redis::aio::ConnectionManager::new(client).await.map_err(|error| {
+    format!("cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`")
+  })?;
+  let (token_prefix, ratelimit_prefix) = unique_prefixes();
+  let store = Store::from_parts(token_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
+  Ok((RedisConn::Single(manager), store))
 }
 
 /// Create/find normalizes the email and rejects a case-variant duplicate.
 #[tokio::test]
 async fn user_repo_create_and_find_is_case_insensitive() -> TestResult {
-  let (_pg, pool) = postgres_pool().await?;
+  let pool = postgres_pool().await?;
 
   let created = user_repo::create_user(
     &pool,
@@ -146,7 +114,7 @@ async fn user_repo_create_and_find_is_case_insensitive() -> TestResult {
 /// Password verification matches/mismatches and refuses disabled accounts.
 #[tokio::test]
 async fn user_repo_verify_password() -> TestResult {
-  let (_pg, pool) = postgres_pool().await?;
+  let pool = postgres_pool().await?;
   let user = user_repo::create_user(
     &pool,
     &new_user("carol@example.com", "s3cret", false, true)?,
@@ -178,7 +146,7 @@ async fn user_repo_verify_password() -> TestResult {
 /// TOTP secret set/clear/toggle plus one-time recovery-code consumption.
 #[tokio::test]
 async fn user_repo_totp_and_recovery_codes() -> TestResult {
-  let (_pg, pool) = postgres_pool().await?;
+  let pool = postgres_pool().await?;
   let user =
     user_repo::create_user(&pool, &new_user("dave@example.com", "pw", false, false)?).await?;
 
@@ -202,7 +170,7 @@ async fn user_repo_totp_and_recovery_codes() -> TestResult {
 /// Admin counting/promote/demote and password rotation semantics.
 #[tokio::test]
 async fn user_repo_admin_and_password_rotation() -> TestResult {
-  let (_pg, pool) = postgres_pool().await?;
+  let pool = postgres_pool().await?;
   assert!(!user_repo::has_any_admin(&pool).await?);
 
   let user =
@@ -235,7 +203,7 @@ async fn user_repo_admin_and_password_rotation() -> TestResult {
 /// entitlement round-trips.
 #[tokio::test]
 async fn user_repo_scope_and_entitlement_round_trip() -> TestResult {
-  let (_pg, pool) = postgres_pool().await?;
+  let pool = postgres_pool().await?;
 
   let created = scope_repo::upsert(&pool, "api:read", "Read API").await?;
   assert!(created.enabled);
@@ -269,7 +237,7 @@ async fn user_repo_scope_and_entitlement_round_trip() -> TestResult {
 /// and TTL expiry both unlock; a correct password while locked is still gated.
 #[tokio::test]
 async fn user_repo_lockout_locks_after_threshold_and_clears() -> TestResult {
-  let (_redis, mut conn, store) = redis_store().await?;
+  let (mut conn, store) = redis_store().await?;
   let policy = LockoutPolicy::new(3, Duration::from_millis(600)).ok_or("invalid policy")?;
   let label = "Locked@Example.com";
 
@@ -314,8 +282,8 @@ async fn user_repo_lockout_locks_after_threshold_and_clears() -> TestResult {
 /// locked: the repo verifies the secret but `is_locked_out` must deny login.
 #[tokio::test]
 async fn user_repo_lockout_gate_rejects_correct_password() -> TestResult {
-  let (_pg, pool) = postgres_pool().await?;
-  let (_redis, mut conn, store) = redis_store().await?;
+  let pool = postgres_pool().await?;
+  let (mut conn, store) = redis_store().await?;
   user_repo::create_user(
     &pool,
     &new_user("gina@example.com", "correct", false, false)?,