ソースを参照

Fixed bugs and tests

Efren Yevale Varela 1 週間 前
親
コミット
b4b95f18a4

+ 8 - 9
docs/TEST-COVERAGE.md

@@ -178,27 +178,26 @@ python3 scripts/check_doc_links.py
 ## Final gate run
 
 The closing run on the final tree passed every authoritative gate with exit
-code 0. The full nextest suite passed 1050/1050 tests in 59.1 s; the coverage
-run passed the same 1050/1050 tests in 761.2 s, and the full coverage command
-took 784 s. These are the final numbers; the baseline above stays as the
-historical first measurement.
+code 0. The full nextest suite passed 1052/1052 tests in 59.0 s; the coverage
+run passed the same 1052/1052 tests. These are the final numbers; the baseline
+above stays as the historical first measurement.
 
 Final coverage (`scripts/check_coverage.py` exit 0):
 
-- Global: 90.5% (22022/24335), floor 89.0.
-- Security aggregate: 92.1% (17226/18695), floor 91.0.
+- Global: 90.5% (22033/24337), floor 89.0.
+- Security aggregate: 92.2% (17228/18690), floor 91.0.
 
 | Module     | Final    | Hit / Found | Floor |
 |------------|----------|-------------|-------|
 | crypto     | 95.6%    | 1066/1115   | 94.0  |
 | jose       | 92.6%    | 1106/1194   | 91.0  |
-| protocol   | 93.3%    | 8313/8911   | 92.0  |
+| protocol   | 93.3%    | 8309/8906   | 92.0  |
 | db         | 91.7%    | 1602/1747   | 90.0  |
-| store      | 88.6%    | 2090/2360   | 87.0  |
+| store      | 88.8%    | 2096/2360   | 87.0  |
 | state      | 76.0%    | 355/467     | 75.0  |
 | middleware | 95.7%    | 986/1030    | 94.0  |
 | ui         | 88.7%    | 1362/1535   | 87.0  |
-| config     | 93.5%    | 907/970     | 92.0  |
+| config     | 93.4%    | 909/973     | 92.0  |
 | keys       | 88.5%    | 184/208     | 87.0  |
 | error      | 89.0%    | 422/474     | 88.0  |
 | routes     | 92.7%    | 1102/1189   | 91.0  |

+ 6 - 2
src/config/jwt.rs

@@ -92,8 +92,9 @@ impl JwtSigningKey {
   /// out-of-range EC scalar or malformed material fails at load.
   ///
   /// # Errors
-  /// Returns [`ConfigError::InvalidJwtSigningKey`] when the material cannot
-  /// sign under `alg`.
+  /// Returns [`ConfigError::WeakKeyMaterial`] when the material is not exactly
+  /// [`JWT_KEY_BYTES`] bytes, or [`ConfigError::InvalidJwtSigningKey`] when it
+  /// cannot sign under `alg`.
   #[doc(hidden)]
   pub fn from_raw(
     kid: KeyId,
@@ -102,6 +103,9 @@ impl JwtSigningKey {
     field: &'static str,
   ) -> Result<Self, ConfigError> {
     let raw = secret.expose_secret().as_bytes();
+    if raw.len() != JWT_KEY_BYTES {
+      return Err(ConfigError::WeakKeyMaterial { key: field });
+    }
     if alg == JwtSigningAlg::EdDsa && raw.iter().all(|byte| *byte == 0) {
       return Err(ConfigError::InvalidJwtSigningKey { key: field });
     }

+ 14 - 20
src/protocol/authorize.rs

@@ -51,8 +51,9 @@ pub struct CimdRequest<'a> {
   /// Total fetch budget (`config.cimd_fetch_timeout`).
   pub timeout: Duration,
   /// Document fetcher: the SSRF-guarded production fetch in handlers, an
-  /// injected stub in tests. Cloned (never re-fetched) at each use.
-  pub fetcher: cimd::CimdFetcher,
+  /// injected stub in tests. Cloned (never re-fetched) at each use. Crate
+  /// private so the SSRF guard cannot be swapped by a downstream consumer.
+  pub(crate) fetcher: cimd::CimdFetcher,
 }
 
 impl std::fmt::Debug for CimdRequest<'_> {
@@ -229,27 +230,20 @@ pub async fn validate(mut input: AuthorizeInput<'_>) -> Result<ValidatedRequest,
   // A registered row always wins over CIMD; an `https://` `client_id` with no
   // active row is a CIMD client only while the feature is enabled (CIMD
   // draft §3). With `cimd` absent the unknown-client page is unchanged.
-  let resolution = match client_repo::find_active_by_client_id(input.pool, input.keys, client_id)
+  let resolution = match client_repo::find_by_client_id_any_state(input.pool, input.keys, client_id)
     .await
     .map_err(AuthorizeFailure::Page)?
   {
-    Some(client) => ResolvedClient::Registered(Box::new(client)),
-    None => {
-      // Fail-closed: a registered row that is not active is dead. An
-      // inactive `https://` row must never fall through to the CIMD
-      // fallback below (zero fetches); the caller keeps unknown-client.
-      if client_repo::find_by_client_id_any_state(input.pool, input.keys, client_id)
-        .await
-        .map_err(AuthorizeFailure::Page)?
-        .is_some()
-      {
-        return Err(page(AppError::invalid_client_non_basic()));
-      }
-      match resolve_cimd(&mut input, client_id).await? {
-        Some(validated) => ResolvedClient::Cimd(validated),
-        None => return Err(page(AppError::invalid_client_non_basic())),
-      }
-    }
+    Some(client) if client.active => ResolvedClient::Registered(Box::new(client)),
+    // Fail-closed: a registered row that is not active is dead. An inactive
+    // `https://` row must never fall through to the CIMD fallback below
+    // (zero fetches); the caller keeps unknown-client. One any-state lookup
+    // keeps activation and the disabled check on a single snapshot.
+    Some(_) => return Err(page(AppError::invalid_client_non_basic())),
+    None => match resolve_cimd(&mut input, client_id).await? {
+      Some(validated) => ResolvedClient::Cimd(validated),
+      None => return Err(page(AppError::invalid_client_non_basic())),
+    },
   };
 
   // A registration-free CIMD client resolves through the same JAR entry

+ 10 - 5
src/protocol/cimd.rs

@@ -42,9 +42,13 @@ mod tests;
 #[cfg(test)]
 mod tests_principal;
 
-use std::{collections::HashSet, future::Future, pin::Pin, sync::Arc, time::Duration};
-
-use std::sync::LazyLock;
+use std::{
+  collections::HashSet,
+  future::Future,
+  pin::Pin,
+  sync::{Arc, LazyLock},
+  time::Duration,
+};
 
 use serde::{Deserialize, Serialize};
 use sqlx::PgPool;
@@ -425,8 +429,9 @@ pub struct CimdSettings<'a> {
   /// the RFC 8252 §7.3 loopback exception.
   pub allow_localhost: bool,
   /// Document fetcher: [`ssrf_fetcher`] in production, an injected stub in
-  /// tests. Cloned (never re-fetched) at every construction site.
-  pub fetcher: CimdFetcher,
+  /// tests. Cloned (never re-fetched) at every construction site. Crate
+  /// private so the SSRF guard cannot be swapped by a downstream consumer.
+  pub(crate) fetcher: CimdFetcher,
 }
 
 impl std::fmt::Debug for CimdSettings<'_> {

+ 14 - 2
src/state.rs

@@ -126,8 +126,9 @@ pub struct AppState {
   /// Injectable clock.
   pub clock: Clock,
   /// CIMD document fetcher: [`ssrf_fetcher`] in production, an injected stub
-  /// in tests. Cloned into every per-request CIMD settings value.
-  pub cimd_fetcher: CimdFetcher,
+  /// in tests. Cloned into every per-request CIMD settings value. Crate
+  /// private: tests swap it through [`AppState::with_cimd_fetcher`].
+  pub(crate) cimd_fetcher: CimdFetcher,
 }
 
 impl AppState {
@@ -151,6 +152,17 @@ impl AppState {
       cimd_fetcher: ssrf_fetcher(),
     })
   }
+
+  /// Replace the CIMD document fetcher with a test stub.
+  ///
+  /// Test-only seam: production always uses [`ssrf_fetcher`], and the field
+  /// is crate private so a downstream consumer cannot swap it by accident.
+  #[doc(hidden)]
+  #[must_use]
+  pub fn with_cimd_fetcher(mut self, fetcher: CimdFetcher) -> Self {
+    self.cimd_fetcher = fetcher;
+    self
+  }
 }
 
 /// Whether encrypt-only mode would serve over un-backfilled plaintext PII.

+ 90 - 18
tests/cli_user_disable.rs

@@ -84,14 +84,26 @@ impl Drop for TempConfig {
 /// resolves `D_OAUTH_CONFIG` through `File::with_name`, which probes supported
 /// formats (`tests/bootstrap.rs` uses the same pattern).
 fn write_config(world: &World) -> Result<(TempConfig, String), TestError> {
+  write_config_with_redis(world, None)
+}
+
+/// Like [`write_config`], but an explicit Redis URL overrides the world's, so
+/// the fail-closed test can point the CLI at an unavailable server.
+fn write_config_with_redis(
+  world: &World,
+  redis_override: Option<&str>,
+) -> Result<(TempConfig, String), TestError> {
   let config = &world.state.config;
-  let redis_url = match &config.redis.topology {
-    RedisTopology::Single { url } => url.expose_secret().to_owned(),
-    _ => {
-      return Err(fail(
-        "CLI config fixture requires the single-node Redis topology",
-      ));
-    }
+  let redis_url = match redis_override {
+    Some(url) => url.to_owned(),
+    None => match &config.redis.topology {
+      RedisTopology::Single { url } => url.expose_secret().to_owned(),
+      _ => {
+        return Err(fail(
+          "CLI config fixture requires the single-node Redis topology",
+        ));
+      }
+    },
   };
   let (pepper_id, pepper) = config
     .keys
@@ -136,6 +148,12 @@ fn write_config(world: &World) -> Result<(TempConfig, String), TestError> {
     &file,
     serde_json::to_vec(&fixture).map_err(|error| fail(format!("config JSON failed: {error}")))?,
   )?;
+  // The fixture carries live key material; keep it owner-only on disk.
+  #[cfg(unix)]
+  {
+    use std::os::unix::fs::PermissionsExt as _;
+    std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600))?;
+  }
   let stem = file.with_extension("");
   let path = stem
     .to_str()
@@ -159,18 +177,17 @@ async fn user_disabled(world: &World, user_id: uuid::Uuid) -> Result<bool, TestE
   Ok(disabled)
 }
 
-/// The real CLI purges the user's Redis tokens, then flips `users.disabled`.
-#[tokio::test]
-async fn cli_user_disable_purges_redis_and_flips_the_flag() -> Result<(), TestError> {
-  let world = World::start(open_rate_limit()).await?;
-  seed_scope(&world.pool, SCOPE).await?;
-  let (user_id, _totp_secret) = seed_entitled_user(&world, EMAIL, PASSWORD, SCOPE).await?;
-
-  let mut conn = world.state.redis.clone();
+/// Mint one opaque pair whose subject is the seeded user's id, so the CLI's
+/// `purge_subject(email, user_id)` sweep matches it.
+async fn mint_subject_pair(
+  world: &World,
+  conn: &mut RedisConn,
+  user_id: uuid::Uuid,
+) -> Result<(String, String), TestError> {
   let minted = world
     .service
     .mint(
-      &mut conn,
+      conn,
       &world.store,
       &MintRequest {
         client_id: CLIENT_ID.to_owned(),
@@ -192,9 +209,21 @@ async fn cli_user_disable_purges_redis_and_flips_the_flag() -> Result<(), TestEr
     )
     .await
     .map_err(|error| fail(format!("fixture mint failed: {error}")))?;
+  Ok((minted.access_hash, minted.refresh_hash))
+}
 
-  let access_key = world.store.access_key(&minted.access_hash);
-  let refresh_key = world.store.refresh_key(&minted.refresh_hash);
+/// The real CLI purges the user's Redis tokens, then flips `users.disabled`.
+#[tokio::test]
+async fn cli_user_disable_purges_redis_and_flips_the_flag() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  seed_scope(&world.pool, SCOPE).await?;
+  let (user_id, _totp_secret) = seed_entitled_user(&world, EMAIL, PASSWORD, SCOPE).await?;
+
+  let mut conn = world.state.redis.clone();
+  let (access_hash, refresh_hash) = mint_subject_pair(&world, &mut conn, user_id).await?;
+
+  let access_key = world.store.access_key(&access_hash);
+  let refresh_key = world.store.refresh_key(&refresh_hash);
   ensure(
     key_exists(&mut conn, &access_key).await?,
     "seeded access token must be live before disable",
@@ -237,3 +266,46 @@ async fn cli_user_disable_purges_redis_and_flips_the_flag() -> Result<(), TestEr
   )?;
   Ok(())
 }
+
+/// A Redis outage during the purge must leave the account enabled and the
+/// tokens live: the flag flips only after a successful purge.
+#[tokio::test]
+async fn cli_user_disable_fails_closed_when_redis_is_down() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  seed_scope(&world.pool, SCOPE).await?;
+  let (user_id, _totp_secret) = seed_entitled_user(&world, EMAIL, PASSWORD, SCOPE).await?;
+
+  let mut conn = world.state.redis.clone();
+  let (access_hash, refresh_hash) = mint_subject_pair(&world, &mut conn, user_id).await?;
+  let access_key = world.store.access_key(&access_hash);
+  let refresh_key = world.store.refresh_key(&refresh_hash);
+
+  let (_config_guard, config_path) = write_config_with_redis(&world, Some("redis://127.0.0.1:1"))?;
+  let output = tokio::process::Command::new(server_binary()?)
+    .args(["user", "disable", "--email", EMAIL])
+    .env("D_OAUTH_CONFIG", &config_path)
+    .output()
+    .await?;
+  ensure(
+    !output.status.success(),
+    format!(
+      "a Redis outage must fail the CLI: {} {}",
+      output.status,
+      String::from_utf8_lossy(&output.stderr)
+    ),
+  )?;
+
+  ensure(
+    !user_disabled(&world, user_id).await?,
+    "a failed purge must leave users.disabled false",
+  )?;
+  ensure(
+    key_exists(&mut conn, &access_key).await?,
+    "a failed purge must leave the access token live",
+  )?;
+  ensure(
+    key_exists(&mut conn, &refresh_key).await?,
+    "a failed purge must leave the refresh token live",
+  )?;
+  Ok(())
+}

+ 3 - 3
tests/e2e/harness.rs

@@ -223,14 +223,14 @@ impl World {
     }
     let config =
       Config::from_source(::config::Config::try_from(&fixture).map_err(ConfigError::from)?)?;
-    let mut state = AppState::connect(std::sync::Arc::new(config))
+    let state = AppState::connect(std::sync::Arc::new(config))
       .await
       .map_err(|error| {
         fail(format!(
           "cannot connect shared test services ({COMPOSE_HINT}): {error}"
         ))
-      })?;
-    state.cimd_fetcher = fetcher;
+      })?
+      .with_cimd_fetcher(fetcher);
     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}")))?;

+ 11 - 1
tests/e2e_admin.rs

@@ -157,6 +157,10 @@ async fn consent_for_new_code(
     format!("second consent must render: {status} {body}"),
   )?;
   let bound = hidden_input(&body, "request_id")?;
+  ensure(
+    bound == request_id,
+    format!("consent must serve the new request {request_id}, got {bound}"),
+  )?;
   let csrf = hidden_input(&body, "csrf")?;
   let (status, headers, body) = post_consent(browser, session, &bound, &csrf, "approve").await?;
   ensure(
@@ -305,8 +309,14 @@ async fn e2e_user_disable_kills_tokens_refresh_and_code() -> Result<(), TestErro
     summary.devices == 1,
     format!("the purge must delete the approved device record, got {summary:?}"),
   )?;
+  ensure(
+    summary.grants >= 2,
+    format!("the purge must revoke both token pairs' grant families, got {summary:?}"),
+  )?;
 
-  // 1. Introspection: both the fixture token and the browser token are dead.
+  // 1. Introspection: the fixture token is owned by the introspecting client,
+  // so its inactive answer proves the purge. The browser token is foreign-owned,
+  // where the ownership check alone would also answer inactive.
   ensure(
     introspect(&browser.world, &fixture.access_token).await?["active"] == false,
     "the fixture access token must introspect inactive after disable",

+ 17 - 8
tests/e2e_cimd.rs

@@ -76,14 +76,20 @@ fn boxed<'a>(
   Box::pin(future)
 }
 
-/// A document fetcher that counts every call and returns `document`.
-fn document_fetcher(document: CimdDocument) -> (CimdFetcher, Arc<AtomicUsize>) {
+/// A document fetcher bound to `expected_url`: a request for any other URL
+/// fails, so URL drift cannot pass the fetch-count assertions.
+fn document_fetcher(expected_url: &str, document: CimdDocument) -> (CimdFetcher, Arc<AtomicUsize>) {
+  let expected_url = expected_url.to_owned();
   let calls = Arc::new(AtomicUsize::new(0));
   let counter = Arc::clone(&calls);
-  let fetcher: CimdFetcher = Arc::new(move |_url: &str, _limits: CimdLimits| {
+  let fetcher: CimdFetcher = Arc::new(move |url: &str, _limits: CimdLimits| {
     let document = document.clone();
     let counter = Arc::clone(&counter);
+    let matches = url == expected_url;
     boxed(async move {
+      if !matches {
+        return Err(CimdError::Fetch);
+      }
       counter.fetch_add(1, Ordering::SeqCst);
       Ok(document)
     })
@@ -297,7 +303,7 @@ async fn approve(browser: &Browser, expected_state: &str) -> Result<String, Test
 /// performs no second fetch.
 #[tokio::test]
 async fn e2e_cimd_authorize_login_consent_token() -> Result<(), TestError> {
-  let (fetcher, calls) = document_fetcher(none_document(CLIENT_URL, true));
+  let (fetcher, calls) = document_fetcher(CLIENT_URL, none_document(CLIENT_URL, true));
   let world = cimd_world(fetcher, true).await?;
   let browser = browser::begin_browser_for(
     world,
@@ -399,7 +405,10 @@ async fn e2e_cimd_authorize_login_consent_token() -> Result<(), TestError> {
 #[tokio::test]
 async fn e2e_cimd_private_key_jwt_requires_par_or_jar() -> Result<(), TestError> {
   let key = DocKey::generate("cimd-doc-key-plain")?;
-  let (fetcher, calls) = document_fetcher(private_key_document(CLIENT_URL, key.jwks.clone()));
+  let (fetcher, calls) = document_fetcher(
+    CLIENT_URL,
+    private_key_document(CLIENT_URL, key.jwks.clone()),
+  );
   let world = cimd_world(fetcher, true).await?;
   seed_scope(&world.pool, SCOPE).await?;
 
@@ -424,7 +433,7 @@ async fn e2e_cimd_private_key_jwt_par_jar_positive() -> Result<(), TestError> {
   const KID: &str = "cimd-doc-key-jar";
   const URL: &str = "https://cimd.example.test/private-key-jar.json";
   let key = DocKey::generate(KID)?;
-  let (fetcher, calls) = document_fetcher(private_key_document(URL, key.jwks.clone()));
+  let (fetcher, calls) = document_fetcher(URL, private_key_document(URL, key.jwks.clone()));
   let world = cimd_world(fetcher, true).await?;
   seed_scope(&world.pool, SCOPE).await?;
   let email = "cimd-jar@example.test";
@@ -562,7 +571,7 @@ async fn e2e_cimd_invalid_documents_fail_closed() -> Result<(), TestError> {
   ];
 
   for (url, document, expected) in rows {
-    let (fetcher, calls) = document_fetcher(document);
+    let (fetcher, calls) = document_fetcher(url, document);
     let world = cimd_world(fetcher, true).await?;
     let (challenge, _) = oauth2_pkce();
     let (status, _, body) = world.get(&authorize_path(url, &challenge), &[]).await?;
@@ -583,7 +592,7 @@ async fn e2e_cimd_invalid_documents_fail_closed() -> Result<(), TestError> {
 async fn e2e_cimd_registered_row_wins_and_inactive_is_dead() -> Result<(), TestError> {
   // Active registered row with an `https://` id: zero fetches.
   const ACTIVE_URL: &str = "https://cimd.example.test/registered.json";
-  let (fetcher, active_calls) = document_fetcher(none_document(ACTIVE_URL, true));
+  let (fetcher, active_calls) = document_fetcher(ACTIVE_URL, none_document(ACTIVE_URL, true));
   let world = cimd_world(fetcher, true).await?;
   seed_client(
     &world.pool,

+ 13 - 0
tests/e2e_dcr.rs

@@ -287,6 +287,19 @@ async fn e2e_dcr_register_read_update_delete_lifecycle() -> Result<(), TestError
     format!("management after delete must be invalid_client, got {body}"),
   )?;
 
+  // `DELETE` revokes the records themselves. A foreign client's introspection
+  // would answer inactive for any owner mismatch, so assert the store directly
+  // and keep the cross-client check as a separate observable contract.
+  let mut conn = world.state.redis.clone();
+  let access = d_oauth::store::tokens::get_access(&mut conn, &world.store, &minted.access_hash)
+    .await
+    .map_err(|error| fail(format!("access lookup failed: {error}")))?;
+  ensure(access.is_none(), "DELETE must revoke the access token")?;
+  let refresh = d_oauth::store::tokens::get_refresh(&mut conn, &world.store, &minted.refresh_hash)
+    .await
+    .map_err(|error| fail(format!("refresh lookup failed: {error}")))?;
+  ensure(refresh.is_none(), "DELETE must revoke the refresh token")?;
+
   let other_id = "e2e-dcr-introspector";
   let other_secret = "e2e-dcr-introspector-secret-aaaabbbbccccdddd";
   seed_client(

+ 52 - 0
tests/e2e_logout.rs

@@ -98,6 +98,58 @@ async fn e2e_logout_post_destroys_session_and_redirects() -> Result<(), TestErro
   Ok(())
 }
 
+/// A caller-supplied return target is ignored: only the URI frozen at
+/// authorization time is honored, even when the request presents another.
+#[tokio::test]
+async fn e2e_logout_ignores_caller_supplied_return_target() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  let browser = begin_browser_with_post_logout(
+    world,
+    CLIENT_ID,
+    REDIRECT_URI,
+    SCOPE,
+    "logout-evil@example.test",
+    "correct-horse-28-logout-evil",
+    POST_LOGOUT_URI,
+  )
+  .await?;
+  let (_session, cookie) = login(&browser).await?;
+
+  let (status, _, body) = browser
+    .world
+    .get("/logout", &[("cookie", cookie.as_str())])
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("GET /logout must render the confirmation: {status} {body}"),
+  )?;
+  let logout_csrf = hidden_input(&body, "csrf")?;
+
+  let (status, headers, body) = browser
+    .world
+    .post_form(
+      "/logout",
+      &[
+        ("csrf", logout_csrf.as_str()),
+        ("post_logout_redirect_uri", "https://evil.example/steal"),
+        ("redirect_uri", "https://evil.example/steal"),
+      ],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("POST /logout must 303 to the bound target: {status} {body}"),
+  )?;
+  let location = location_of(&headers)?;
+  ensure(
+    location == POST_LOGOUT_URI,
+    format!("logout must ignore a caller-supplied return target, got {location}"),
+  )?;
+  Ok(())
+}
+
 /// `GET /logout` only renders the confirmation form; the session survives and
 /// still serves the bound consent page.
 #[tokio::test]

+ 3 - 2
tests/e2e_mtls_grant.rs

@@ -132,9 +132,10 @@ async fn e2e_mtls_client_credentials_mints_cnf_bound_token() -> Result<(), TestE
   Ok(())
 }
 
-/// A verified chain whose subject DN is not the registered one is rejected.
+/// A verified chain whose leaf matches neither the registered subject DN nor
+/// thumbprint is rejected.
 #[tokio::test]
-async fn e2e_mtls_unknown_dn_rejected() -> Result<(), TestError> {
+async fn e2e_mtls_unknown_leaf_rejected() -> Result<(), TestError> {
   let world = World::start(open_rate_limit()).await?;
   seed_mtls_client(&world, CLIENT_ID, PKI_DN, PKI_THUMBPRINT).await?;
   seed_scope(&world.pool, "api:read").await?;

+ 4 - 3
tests/e2e_stale_catalog.rs

@@ -5,9 +5,10 @@
 //! A resource or scope disabled AFTER issuance must be dead at the next
 //! mint-time check: refresh re-validates the grant's stored audience before
 //! rotation (`invalid_target`, presented token left live), the device poll
-//! validates before consuming, authorization-code redemption validates a
-//! presented audience before consuming the code, and refresh narrows a
-//! disabled scope into the replacement token without ever widening it back.
+//! consumes the code and rejects `invalid_target`, authorization-code
+//! redemption validates a presented audience before consuming the code, and
+//! refresh narrows a disabled scope into the replacement token without ever
+//! widening it back.
 //!
 //! Re-enabling each catalog row proves the disable was the cause: the same
 //! live token/code then succeeds.

+ 10 - 2
tests/e2e_token_exchange.rs

@@ -172,11 +172,19 @@ async fn e2e_token_exchange_allowlisted_mints_act_bound_token() -> Result<(), Te
     value.get("refresh_token").is_none(),
     "exchange must never issue a refresh token",
   )?;
+  let exchanged_ttl = exchange_ttl(&world.service);
   ensure(
-    value["expires_in"] == exchange_ttl(&world.service).as_secs(),
+    value["expires_in"] == exchanged_ttl.as_secs(),
     format!(
       "exchange must use the shortened TTL {}: {value}",
-      exchange_ttl(&world.service).as_secs()
+      exchanged_ttl.as_secs()
+    ),
+  )?;
+  ensure(
+    exchanged_ttl < world.state.config.ttl.access,
+    format!(
+      "the exchange TTL ({exchanged_ttl:?}) must be shorter than the access TTL ({:?})",
+      world.state.config.ttl.access
     ),
   )?;
   let access = value["access_token"]

+ 6 - 2
tests/proptest_inputs.rs

@@ -178,7 +178,7 @@ proptest! {
 
   #[test]
   fn totp_verify_window_accepts_only_generated_codes(
-    secret_bytes in proptest::collection::vec(any::<u8>(), 10..=32),
+    secret_bytes in proptest::collection::vec(any::<u8>(), 16..=32),
     counter in 0u64..=(u64::MAX / TOTP_STEP_SECS),
     arbitrary_code in proptest::collection::vec(any::<char>(), 0..=16)
       .prop_map(|chars| chars.into_iter().collect::<String>()),
@@ -186,7 +186,10 @@ proptest! {
     let secret = to_base32(&secret_bytes);
     let totp = match build_totp(&secret) {
       Ok(totp) => totp,
-      Err(_) => return Ok(()),
+      Err(_) => {
+        prop_assert!(false, "a 16..=32-byte base32 secret must build");
+        return Ok(());
+      }
     };
     let now = counter.saturating_mul(TOTP_STEP_SECS);
     let generated = totp.generate(now).to_string();
@@ -216,6 +219,7 @@ proptest! {
     replacement in 0u8..8,
   ) {
     let Some(fixture) = jwt_fixture() else {
+      prop_assert!(false, "the fixed JWT fixture must build");
       return Ok(());
     };