| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026 |
- //! `test(resilience)` — failure-injection suite (plan todo 44).
- //!
- //! Every test name contains `resilience` so
- //! `cargo nextest run -E 'test(resilience)'` selects exactly them (plus the
- //! `src/resilience.rs` unit tests and the task-4 `tests_resilience` health
- //! tests, which share the substring). Bodies use `Result` + `?` (`unwrap`/
- //! `expect`/`panic` are denied workspace-wide) and real Postgres/Redis
- //! containers through [`test_guard::ContainerGuard`] — never `ContainerAsync`
- //! directly, so no container leaks under nextest.
- //!
- //! Coverage: Redis stopped mid-run → protected surfaces answer `503
- //! temporarily_unavailable` + `Retry-After` while `/health/live` stays 200,
- //! and recovery needs no process restart; Postgres stopped → `/health/ready`
- //! and the (cold-catalog) metadata document fail closed, then recover; a
- //! DPoP replay-store outage rejects proofs instead of accepting them; a lost
- //! reply / indeterminate post-`EXEC` result is resolved by the rotation
- //! idempotency mapping (replay), never double-minted and never confused with
- //! reuse; a SIGKILL (not a graceful restart) immediately after a
- //! rotation+tombstone write leaves no resurrected token (the `WAITAOF`
- //! barrier fsynced before the reply); Sentinel/Cluster failover contracts
- //! (`MOVED`/`ASK` transient, single caller-visible error per episode, no
- //! cross-slot atomic op) hold at the resolver/classifier level.
- #[path = "../src/test_guard.rs"]
- #[allow(dead_code)]
- mod test_guard;
- use std::{
- sync::{
- Arc,
- atomic::{AtomicU32, Ordering},
- },
- time::Duration,
- };
- use d_oauth::{
- config::{Config, ConfigError, DurabilityMode},
- error::AppError,
- resilience::{
- FailoverEpisode, RetryBudget, is_single_slot, is_transient_redis, retry_idempotent,
- },
- routes::build_router,
- state::{AppState, StateError},
- store::{
- ErrorClass, OpClass, Store, StoreError, classify_redis,
- dcr::claim_dpop_jti,
- grants::{GrantRecord, create as create_grant},
- tokens::{
- AccessRecord, RefreshRecord, RotateOutcome, RotationInput, get_refresh, put_refresh, rotate,
- tombstone_of,
- },
- },
- };
- use test_guard::ContainerGuard;
- use testcontainers::{
- GenericImage, ImageExt,
- core::{IntoContainerPort, WaitFor},
- runners::AsyncRunner,
- };
- /// Test body result (no `unwrap`/`expect` under the deny lints).
- type TestResult = Result<(), TestError>;
- /// `(status, headers, body)` of one driven request.
- type Driven = (u16, axum::http::HeaderMap, String);
- /// Postgres superuser password for these fixtures.
- const PG_PASSWORD: &str = "resilience-test-secret";
- /// Fixed pepper for rotation-id derivation in these tests.
- const PEPPER: &[u8] = b"resilience-test-pepper-0123456789";
- /// Postgres URL for a container host port.
- fn pg_url_for(port: u16) -> String {
- format!("postgres://postgres:{PG_PASSWORD}@127.0.0.1:{port}/postgres?sslmode=disable")
- }
- /// Start Postgres and return the guard plus a plaintext URL.
- async fn postgres_container() -> Result<(ContainerGuard, String), TestError> {
- let container = GenericImage::new("postgres", "18-alpine")
- .with_exposed_port(5432.tcp())
- .with_wait_for(WaitFor::message_on_stderr(
- "database system is ready to accept connections",
- ))
- .with_wait_for(WaitFor::message_on_stdout(
- "database system is ready to accept connections",
- ))
- .with_env_var("POSTGRES_PASSWORD", PG_PASSWORD)
- .start()
- .await?;
- let guarded = ContainerGuard::new(container);
- let port = guarded.get_host_port_ipv4(5432).await?;
- Ok((guarded, pg_url_for(port)))
- }
- /// Start a single-node Redis with AOF on (the durability under test).
- ///
- /// `appendfsync always` is the task-44 topology durability mode for
- /// single-node deployments: every rotation/tombstone write is fsynced before
- /// its reply, so the `WAITAOF` barrier (and the SIGKILL test below) exercises
- /// real fsync durability instead of the `everysec` background window.
- async fn redis_container() -> Result<(ContainerGuard, String), TestError> {
- let container = GenericImage::new("redis", "8-alpine")
- .with_exposed_port(6379.tcp())
- .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
- .with_cmd(vec![
- "redis-server",
- "--appendonly",
- "yes",
- "--appendfsync",
- "always",
- ])
- .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}")))
- }
- /// Current Redis URL for a (possibly restarted) container: Docker rebinds
- /// the host port on restart, so callers must re-resolve after `start()`.
- async fn redis_url_for(guard: &ContainerGuard) -> Result<String, TestError> {
- let port = guard.get_host_port_ipv4(6379).await?;
- Ok(format!("redis://127.0.0.1:{port}"))
- }
- /// Build a full [`Config`] with short dependency timeouts so outage tests
- /// fail fast instead of hanging on the production budgets.
- fn test_config(
- pg_url: &str,
- redis_url: &str,
- rate_limit_enabled: bool,
- ) -> Result<Config, ConfigError> {
- let fixture = serde_json::json!({
- "issuer": "https://auth.example.test",
- "environment": "development",
- "tls": {"cert_path": "/tmp/resilience-test-cert.pem", "key_path": "/tmp/resilience-test-key.pem"},
- "database": {"url": pg_url},
- "redis": {
- "url": redis_url,
- "durability": "waitaof",
- "connect_timeout_secs": 1,
- "response_timeout_secs": 1,
- "retry_budget": 1,
- "retry_min_delay_ms": 50,
- "retry_max_delay_ms": 200,
- },
- "rate_limit": {"enabled": rate_limit_enabled},
- "token_pepper": "resilience-test-pepper-0123456789abcdef",
- "totp_encryption_key": "resilience-test-totp-key-0123456789abcdef",
- "pii_encryption_key": "resilience-test-pii-key-0123456789abcdef",
- });
- Config::from_source(::config::Config::try_from(&fixture)?)
- }
- /// Embedded migrations (same files the server ships); readiness requires the
- /// `_sqlx_migrations` version match, so tests apply them like `migrate` would.
- static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
- /// Connect live state over fresh containers and apply the migrations.
- ///
- /// Returns the guards, their current URLs, and the connected state. URLs go
- /// stale across a container restart (Docker rebinds host ports), so tests
- /// that restart a container re-resolve and reconnect in-process via
- /// [`reconnect`] — no process restart.
- async fn live_state(
- rate_limit_enabled: bool,
- ) -> Result<(ContainerGuard, ContainerGuard, String, String, AppState), TestError> {
- let (pg, pg_url) = postgres_container().await?;
- let (redis, redis_url) = redis_container().await?;
- let state = reconnect(&pg_url, &redis_url, rate_limit_enabled).await?;
- MIGRATOR.run(&state.pool).await?;
- Ok((pg, redis, pg_url, redis_url, state))
- }
- /// Reconnect state in-process over the given URLs (no process restart).
- async fn reconnect(
- pg_url: &str,
- redis_url: &str,
- rate_limit_enabled: bool,
- ) -> Result<AppState, TestError> {
- let config = test_config(pg_url, redis_url, rate_limit_enabled)?;
- Ok(AppState::connect(Arc::new(config)).await?)
- }
- /// Drive one request through the public router (peer identity extensions
- /// included, mirroring the task-40 harness).
- async fn drive(
- router: axum::Router,
- method: &str,
- uri: &str,
- headers: &[(&str, &str)],
- body: &str,
- ) -> Result<Driven, TestError> {
- use tower::ServiceExt as _;
- let peer = std::net::SocketAddr::from(([127, 0, 0, 1], 40_001));
- let mut builder = axum::http::Request::builder().method(method).uri(uri);
- for (name, value) in headers {
- builder = builder.header(*name, *value);
- }
- let mut request = builder.body(axum::body::Body::from(body.to_owned()))?;
- request
- .extensions_mut()
- .insert(axum::extract::ConnectInfo(peer));
- request
- .extensions_mut()
- .insert(axum::extract::connect_info::MockConnectInfo(peer));
- let response = router.oneshot(request).await?;
- let status = response.status().as_u16();
- let response_headers = response.headers().clone();
- let bytes = http_body_util::BodyExt::collect(response.into_body())
- .await
- .map_err(|_| TestError::Body("body collection failed".to_owned()))?
- .to_bytes();
- Ok((
- status,
- response_headers,
- String::from_utf8_lossy(&bytes).into_owned(),
- ))
- }
- /// Poll `/health/ready` until it reports `want` (or the attempts run out).
- async fn poll_ready(state: &AppState, want: u16) -> Result<(), TestError> {
- for _ in 0..30 {
- let (status, _, _) =
- drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
- if status == want {
- return Ok(());
- }
- tokio::time::sleep(Duration::from_secs(1)).await;
- }
- Err(TestError::Body(format!("readiness never reached {want}")))
- }
- /// Poll Postgres until it answers `SELECT 1` (a restarted container needs
- /// seconds before the pool can connect; without this the reconnect races the
- /// database startup and fails with a reset connection).
- async fn wait_pg(pg_url: &str) -> Result<(), TestError> {
- for _ in 0..30 {
- let attempt = sqlx::postgres::PgPoolOptions::new()
- .max_connections(1)
- .acquire_timeout(Duration::from_secs(2))
- .connect(pg_url)
- .await;
- if let Ok(pool) = attempt {
- let ready = sqlx::query("SELECT 1").execute(&pool).await.is_ok();
- pool.close().await;
- if ready {
- return Ok(());
- }
- }
- tokio::time::sleep(Duration::from_secs(1)).await;
- }
- Err(TestError::Body("postgres never came back".to_owned()))
- }
- /// Poll a direct Redis `PING` until the node answers (post-kill restarts
- /// reload the AOF before accepting writes).
- async fn poll_ping(redis_url: &str) -> Result<(), TestError> {
- for _ in 0..30 {
- let client = redis::Client::open(redis_url)?;
- if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
- let pong: Result<String, redis::RedisError> = redis::cmd("PING").query_async(&mut conn).await;
- if matches!(pong.as_deref(), Ok("PONG")) {
- return Ok(());
- }
- }
- tokio::time::sleep(Duration::from_secs(1)).await;
- }
- Err(TestError::Body("redis never answered PING".to_owned()))
- }
- /// `docker kill` (SIGKILL, no graceful flush) the container.
- async fn docker_kill(id: &str) -> Result<(), TestError> {
- let output = tokio::process::Command::new("docker")
- .args(["kill", id])
- .output()
- .await
- .map_err(|_| TestError::Docker("kill".to_owned()))?;
- if !output.status.success() {
- return Err(TestError::Docker("kill".to_owned()));
- }
- Ok(())
- }
- /// Render an [`AppError`] to `(status, retry-after present, body)`.
- async fn render(error: AppError) -> Result<(u16, bool, String), TestError> {
- use axum::response::IntoResponse as _;
- use http_body_util::BodyExt as _;
- let response = error.into_response();
- let status = response.status().as_u16();
- let retry_after = response
- .headers()
- .contains_key(axum::http::header::RETRY_AFTER);
- let bytes = response
- .into_body()
- .collect()
- .await
- .map_err(|_| TestError::Body("body collection failed".to_owned()))?
- .to_bytes();
- Ok((
- status,
- retry_after,
- String::from_utf8_lossy(&bytes).into_owned(),
- ))
- }
- /// Seed one live refresh-token record for rotation tests.
- async fn seed_refresh(
- state: &AppState,
- old_hash: &str,
- client: &str,
- grant: &str,
- ) -> Result<Store, TestError> {
- let store = Store::from_config(&state.config.redis);
- let mut conn = state.redis.clone();
- put_refresh(
- &mut conn,
- &store,
- old_hash,
- &RefreshRecord {
- client_id: client.to_owned(),
- grant_id: grant.to_owned(),
- scope: "api:read".to_owned(),
- subject: "user-1".to_owned(),
- cnf: String::new(),
- aud: Vec::new(),
- },
- Duration::from_secs(3600),
- )
- .await?;
- create_grant(
- &mut conn,
- &store,
- &GrantRecord {
- grant_id: grant.to_owned(),
- client_id: client.to_owned(),
- subject: "user-1".to_owned(),
- scope: "api:read".to_owned(),
- aud: Vec::new(),
- },
- Duration::from_secs(3600),
- )
- .await?;
- Ok(store)
- }
- /// One rotation input over fixed candidate hashes.
- fn rotation_input(
- old_hash: &str,
- client: &str,
- grant: &str,
- new_refresh_hash: &str,
- new_access_hash: &str,
- request_id: &str,
- ) -> RotationInput {
- RotationInput {
- old_refresh_hash: old_hash.to_owned(),
- expected_client_id: client.to_owned(),
- new_refresh_hash: new_refresh_hash.to_owned(),
- new_access_hash: new_access_hash.to_owned(),
- new_refresh: RefreshRecord {
- client_id: client.to_owned(),
- grant_id: grant.to_owned(),
- scope: "api:read".to_owned(),
- subject: "user-1".to_owned(),
- cnf: String::new(),
- aud: Vec::new(),
- },
- new_access: AccessRecord {
- client_id: client.to_owned(),
- grant_id: grant.to_owned(),
- scope: "api:read".to_owned(),
- subject: "user-1".to_owned(),
- cnf: String::new(),
- aud: Vec::new(),
- },
- new_refresh_ttl: Duration::from_secs(3600),
- new_access_ttl: Duration::from_secs(600),
- request_id: Some(request_id.to_owned()),
- jwt_jti_hash: None,
- }
- }
- /// Stopping Redis mid-run fails protected surfaces closed (503 +
- /// `Retry-After`, `temporarily_unavailable`) while `/health/live` stays 200;
- /// restarting the same container recovers with no process restart.
- #[tokio::test]
- async fn resilience_redis_stop_token_503_and_ready_flips_then_recovers() -> TestResult {
- let (_pg, redis, pg_url, _redis_url, state) = live_state(true).await?;
- let form = [("content-type", "application/x-www-form-urlencoded")];
- let (baseline, _, _) =
- drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
- if baseline != 200 {
- return Err(TestError::Body(format!(
- "ready baseline must be 200, got {baseline}"
- )));
- }
- redis.stop_with_timeout(Some(0)).await?;
- let (token_status, token_headers, token_body) = drive(
- build_router(state.clone()),
- "POST",
- "/token",
- &form,
- "grant_type=refresh_token",
- )
- .await?;
- if token_status != 503 {
- return Err(TestError::Body(format!(
- "POST /token with Redis down must be 503, got {token_status}: {token_body}"
- )));
- }
- if !token_headers.contains_key(axum::http::header::RETRY_AFTER) {
- return Err(TestError::Body("503 must carry Retry-After".to_owned()));
- }
- if !token_body.contains("temporarily_unavailable") {
- return Err(TestError::Body(format!(
- "503 body must carry temporarily_unavailable: {token_body}"
- )));
- }
- let (ready_status, _, _) =
- drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
- if ready_status != 503 {
- return Err(TestError::Body(format!(
- "readiness with Redis down must be 503, got {ready_status}"
- )));
- }
- let (live_status, _, _) =
- drive(build_router(state.clone()), "GET", "/health/live", &[], "").await?;
- if live_status != 200 {
- return Err(TestError::Body(format!(
- "liveness must stay 200, got {live_status}"
- )));
- }
- redis.start().await?;
- // Docker rebinds the host port on restart: re-resolve and reconnect
- // in-process (the same test process keeps running — no process restart).
- let redis_url = redis_url_for(&redis).await?;
- let state = reconnect(&pg_url, &redis_url, true).await?;
- poll_ready(&state, 200).await?;
- // Past the limiter again: the real dispatcher authenticates the (absent)
- // client and answers 400 invalid_client, proving recovery without a
- // process restart and that /token no longer stubs 501.
- let (recovered, _, recovered_body) = drive(
- build_router(state.clone()),
- "POST",
- "/token",
- &form,
- "grant_type=refresh_token",
- )
- .await?;
- if recovered != 400 {
- return Err(TestError::Body(format!(
- "POST /token after recovery must reach the dispatcher (400 invalid_client), got {recovered}"
- )));
- }
- if !recovered_body.contains("invalid_client") {
- return Err(TestError::Body(format!(
- "POST /token after recovery must answer invalid_client, got {recovered_body}"
- )));
- }
- Ok(())
- }
- /// Stopping Postgres mid-run fails readiness and the cold-catalog metadata
- /// document closed; restarting recovers both with no process restart.
- #[tokio::test]
- async fn resilience_postgres_stop_ready_503_and_metadata_fail_closed_then_recovers() -> TestResult {
- let (pg, _redis, _pg_url, redis_url, state) = live_state(false).await?;
- let (baseline, _, _) =
- drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
- if baseline != 200 {
- return Err(TestError::Body(format!(
- "ready baseline must be 200, got {baseline}"
- )));
- }
- pg.stop_with_timeout(Some(0)).await?;
- let (ready_status, _, _) =
- drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
- if ready_status != 503 {
- return Err(TestError::Body(format!(
- "readiness with Postgres down must be 503, got {ready_status}"
- )));
- }
- let (live_status, _, _) =
- drive(build_router(state.clone()), "GET", "/health/live", &[], "").await?;
- if live_status != 200 {
- return Err(TestError::Body(format!(
- "liveness must stay 200, got {live_status}"
- )));
- }
- // The scope catalog was never populated in this process and Postgres is
- // down: the metadata document fails closed rather than advertising an
- // unknown scope list.
- let (meta_status, _, meta_body) = drive(
- build_router(state.clone()),
- "GET",
- "/.well-known/oauth-authorization-server",
- &[],
- "",
- )
- .await?;
- if meta_status != 503 || !meta_body.contains("temporarily_unavailable") {
- return Err(TestError::Body(format!(
- "metadata with cold catalog and Postgres down must be 503 temporarily_unavailable, got {meta_status}: {meta_body}"
- )));
- }
- // A Postgres-backed token grant fails closed as 503 through the
- // connectivity mapping: never a 500, and never an unauthenticated mint.
- let form = [("content-type", "application/x-www-form-urlencoded")];
- let (token_status, token_headers, token_body) = drive(
- build_router(state.clone()),
- "POST",
- "/token",
- &form,
- "grant_type=client_credentials&client_id=pg-down&client_secret=pg-down",
- )
- .await?;
- if token_status != 503 || !token_body.contains("temporarily_unavailable") {
- return Err(TestError::Body(format!(
- "POST /token with Postgres down must be 503 temporarily_unavailable, got {token_status}: {token_body}"
- )));
- }
- if !token_headers.contains_key(axum::http::header::RETRY_AFTER) {
- return Err(TestError::Body(
- "token 503 must carry Retry-After".to_owned(),
- ));
- }
- if token_body.contains("access_token") {
- return Err(TestError::Body("token 503 must mint nothing".to_owned()));
- }
- pg.start().await?;
- let pg_url = pg_url_for(pg.get_host_port_ipv4(5432).await?);
- wait_pg(&pg_url).await?;
- let state = reconnect(&pg_url, &redis_url, false).await?;
- poll_ready(&state, 200).await?;
- let (meta_recovered, _, meta_doc) = drive(
- build_router(state.clone()),
- "GET",
- "/.well-known/oauth-authorization-server",
- &[],
- "",
- )
- .await?;
- if meta_recovered != 200 || !meta_doc.contains("https://auth.example.test") {
- return Err(TestError::Body(format!(
- "metadata after recovery must be 200, got {meta_recovered}: {meta_doc}"
- )));
- }
- Ok(())
- }
- /// A DPoP replay-store outage rejects proofs (fail closed, 503) instead of
- /// accepting them; after recovery the first claim wins and the replay loses.
- #[tokio::test]
- async fn resilience_dpop_replay_store_failure_rejects_proof() -> TestResult {
- let (_pg, redis, pg_url, _redis_url, state) = live_state(false).await?;
- let store = Store::from_config(&state.config.redis);
- redis.stop_with_timeout(Some(0)).await?;
- let mut conn = state.redis.clone();
- match claim_dpop_jti(
- &mut conn,
- &store,
- "resilience-jti-hash",
- Duration::from_secs(300),
- )
- .await
- {
- Ok(_) => {
- return Err(TestError::Body(
- "DPoP claim with Redis down must fail closed".to_owned(),
- ));
- }
- Err(error) => {
- let (status, retry_after, body) = render(AppError::from(error)).await?;
- if status != 503 || !retry_after || !body.contains("temporarily_unavailable") {
- return Err(TestError::Body(format!(
- "replay-store failure must render 503 + Retry-After + temporarily_unavailable, got {status}: {body}"
- )));
- }
- }
- }
- redis.start().await?;
- let redis_url = redis_url_for(&redis).await?;
- poll_ping(&redis_url).await?;
- let state = reconnect(&pg_url, &redis_url, false).await?;
- let store = Store::from_config(&state.config.redis);
- let mut conn = state.redis.clone();
- if !claim_dpop_jti(
- &mut conn,
- &store,
- "resilience-jti-hash",
- Duration::from_secs(300),
- )
- .await?
- {
- return Err(TestError::Body(
- "first claim after recovery must win".to_owned(),
- ));
- }
- if claim_dpop_jti(
- &mut conn,
- &store,
- "resilience-jti-hash",
- Duration::from_secs(300),
- )
- .await?
- {
- return Err(TestError::Body("replayed jti must lose".to_owned()));
- }
- Ok(())
- }
- /// A lost reply / indeterminate post-`EXEC` result is resolved by the rotation
- /// idempotency mapping (replay with the original tokens), never double-minted
- /// and never confused with reuse.
- #[tokio::test]
- async fn resilience_rotation_lost_reply_resolved_by_mapping_not_reuse() -> TestResult {
- let (_pg, _redis, _pg_url, _redis_url, state) = live_state(false).await?;
- let store = seed_refresh(&state, "resilience-old-1", "client-a", "grant-a").await?;
- let mut conn = state.redis.clone();
- // First rotation succeeds; the reply is "lost" (indeterminate outcome).
- let first = rotate(
- &mut conn,
- &store,
- PEPPER,
- &rotation_input(
- "resilience-old-1",
- "client-a",
- "grant-a",
- "resilience-new-1",
- "resilience-acc-1",
- "req-lost-1",
- ),
- )
- .await?;
- let (original_refresh, original_access) = match first {
- RotateOutcome::Rotated {
- new_refresh_hash,
- new_access_hash,
- } => (new_refresh_hash, new_access_hash),
- other => {
- return Err(TestError::Body(format!(
- "first rotation must apply, got {other:?}"
- )));
- }
- };
- // Retry with the same request id but different candidates: the mapping
- // decides replay with the ORIGINAL tokens; the new candidates are ignored.
- let retry = rotate(
- &mut conn,
- &store,
- PEPPER,
- &rotation_input(
- "resilience-old-1",
- "client-a",
- "grant-a",
- "resilience-decoy-1",
- "resilience-decoy-acc-1",
- "req-lost-1",
- ),
- )
- .await?;
- match retry {
- RotateOutcome::Replay {
- new_refresh_hash,
- new_access_hash,
- } => {
- if new_refresh_hash != original_refresh || new_access_hash != original_access {
- return Err(TestError::Body(
- "replay must return the original tokens".to_owned(),
- ));
- }
- }
- other => {
- return Err(TestError::Body(format!(
- "lost-reply retry must replay, got {other:?}"
- )));
- }
- }
- // No double-mint: the decoy candidates were never stored.
- if get_refresh(&mut conn, &store, "resilience-decoy-1")
- .await?
- .is_some()
- {
- return Err(TestError::Body(
- "decoy candidate must never be minted".to_owned(),
- ));
- }
- if get_refresh(&mut conn, &store, &original_refresh)
- .await?
- .is_none()
- {
- return Err(TestError::Body(
- "original rotated token must exist once".to_owned(),
- ));
- }
- // Same old token WITHOUT a valid mapping is reuse (tombstone decides),
- // which cascade-revokes the grant family.
- match rotate(
- &mut conn,
- &store,
- PEPPER,
- &rotation_input(
- "resilience-old-1",
- "client-a",
- "grant-a",
- "resilience-new-2",
- "resilience-acc-2",
- "req-other-1",
- ),
- )
- .await?
- {
- RotateOutcome::Reused { .. } => Ok(()),
- other => Err(TestError::Body(format!(
- "unmapped re-presentation must be reuse, got {other:?}"
- ))),
- }
- }
- /// SIGKILL (no graceful flush) immediately after a rotation+tombstone write
- /// leaves no resurrected token: the tombstone and the idempotency mapping
- /// both survive through the `WAITAOF` barrier, so reuse is still detected.
- #[tokio::test]
- async fn resilience_sigkill_after_rotation_tombstone_no_resurrection() -> TestResult {
- let (_pg, redis, pg_url, _redis_url, state) = live_state(false).await?;
- let store = seed_refresh(&state, "resilience-old-2", "client-a", "grant-b").await?;
- let mut conn = state.redis.clone();
- let outcome = rotate(
- &mut conn,
- &store,
- PEPPER,
- &rotation_input(
- "resilience-old-2",
- "client-a",
- "grant-b",
- "resilience-new-9",
- "resilience-acc-9",
- "req-kill-1",
- ),
- )
- .await?;
- if !matches!(outcome, RotateOutcome::Rotated { .. }) {
- return Err(TestError::Body(format!(
- "rotation must apply, got {outcome:?}"
- )));
- }
- // The durability barrier ran inside `rotate` before it returned; SIGKILL
- // now (no `SAVE`, no graceful flush) exercises AOF durability, not a clean
- // restart.
- docker_kill(redis.id()).await?;
- redis.start().await?;
- let redis_url = redis_url_for(&redis).await?;
- poll_ping(&redis_url).await?;
- let state = reconnect(&pg_url, &redis_url, false).await?;
- let store = Store::from_config(&state.config.redis);
- let mut conn = state.redis.clone();
- match tombstone_of(&mut conn, &store, "resilience-old-2").await? {
- Some(rotated_to) if rotated_to == "resilience-new-9" => {}
- other => {
- return Err(TestError::Body(format!(
- "tombstone must survive SIGKILL (no resurrection), got {other:?}"
- )));
- }
- }
- // The mapping survived too: the original request id still replays.
- match rotate(
- &mut conn,
- &store,
- PEPPER,
- &rotation_input(
- "resilience-old-2",
- "client-a",
- "grant-b",
- "resilience-decoy-9",
- "resilience-decoy-acc-9",
- "req-kill-1",
- ),
- )
- .await?
- {
- RotateOutcome::Replay {
- new_refresh_hash, ..
- } if new_refresh_hash == "resilience-new-9" => {}
- other => {
- return Err(TestError::Body(format!(
- "post-kill retry with the valid mapping must replay, got {other:?}"
- )));
- }
- }
- // And an unmapped re-presentation is still reuse, not a live token.
- match rotate(
- &mut conn,
- &store,
- PEPPER,
- &rotation_input(
- "resilience-old-2",
- "client-a",
- "grant-b",
- "resilience-new-10",
- "resilience-acc-10",
- "req-kill-2",
- ),
- )
- .await?
- {
- RotateOutcome::Reused { .. } => Ok(()),
- other => Err(TestError::Body(format!(
- "old token must stay dead after SIGKILL, got {other:?}"
- ))),
- }
- }
- /// In-flight requests during an outage all fail clean (503, no hang, no
- /// 500/panic) instead of queueing into `PoolTimedOut`.
- #[tokio::test]
- async fn resilience_inflight_requests_fail_clean_during_outage() -> TestResult {
- let (_pg, redis, _pg_url, _redis_url, state) = live_state(true).await?;
- redis.stop_with_timeout(Some(0)).await?;
- let burst = async {
- let mut handles = Vec::new();
- for _ in 0..20 {
- let router = build_router(state.clone());
- handles.push(tokio::spawn(async move {
- drive(router, "GET", "/health/ready", &[], "").await
- }));
- }
- let mut statuses = Vec::new();
- for handle in handles {
- match handle.await {
- Ok(Ok((status, _, _))) => statuses.push(status),
- Ok(Err(_)) | Err(_) => statuses.push(0),
- }
- }
- statuses
- };
- let statuses = tokio::time::timeout(Duration::from_secs(30), burst)
- .await
- .map_err(|_| TestError::Body("in-flight burst hung".to_owned()))?;
- if statuses.len() != 20 {
- return Err(TestError::Body(
- "every in-flight request must settle".to_owned(),
- ));
- }
- for status in &statuses {
- if *status != 503 {
- return Err(TestError::Body(format!(
- "every in-flight request must fail closed with 503, got {status}"
- )));
- }
- }
- Ok(())
- }
- /// Topology failover contracts without a multi-node deployment: `MOVED`/`ASK`
- /// classify transient (slot-refresh recovery), failover episodes allow at
- /// most one caller-visible error, and atomic multi-key operations never span
- /// slots.
- #[tokio::test]
- async fn resilience_topology_failover_contracts_recover_with_single_error() -> TestResult {
- // `MOVED`/`ASK` (Cluster slot refresh) are transient: safe to retry an
- // idempotent read through them.
- for wire in [
- "-MOVED 1234 127.0.0.1:7000\r\n",
- "-ASK 5678 127.0.0.1:7001\r\n",
- "-TRYAGAIN Multiple keys request must be in the same slot\r\n",
- ] {
- let value = redis::parse_redis_value(wire.as_bytes())
- .map_err(|_| TestError::Body("wire parse failed".to_owned()))?;
- let server = match value {
- redis::Value::ServerError(error) => error,
- _ => return Err(TestError::Body("expected a server-error value".to_owned())),
- };
- let error = redis::RedisError::from(server);
- if classify_redis(&error) != ErrorClass::Transient {
- return Err(TestError::Body(format!("{wire:?} must classify transient")));
- }
- if !is_transient_redis(&error) {
- return Err(TestError::Body(
- "resilience predicate must agree".to_owned(),
- ));
- }
- if !d_oauth::store::may_retry_op(OpClass::IdempotentRead, &error) {
- return Err(TestError::Body(
- "idempotent reads must retry through slot moves".to_owned(),
- ));
- }
- }
- // A failover episode recovers transparently: transient faults retried
- // inside the budget surface zero caller-visible errors.
- let episode = FailoverEpisode::new();
- let attempts = Arc::new(AtomicU32::new(0));
- let probe = Arc::clone(&attempts);
- let moved = || {
- let value = redis::parse_redis_value(b"-MOVED 0 127.0.0.1:7000\r\n");
- match value {
- Ok(redis::Value::ServerError(error)) => redis::RedisError::from(error),
- _ => redis::RedisError::from((redis::ErrorKind::Io, "synthetic")),
- }
- };
- let budget = RetryBudget::new(8);
- let outcome = retry_idempotent(
- move || {
- let probe = Arc::clone(&probe);
- let moved = moved();
- async move {
- let attempt = probe.fetch_add(1, Ordering::SeqCst);
- if attempt < 2 {
- Err::<(), redis::RedisError>(moved)
- } else {
- Ok(())
- }
- }
- },
- is_transient_redis,
- &budget,
- 2,
- )
- .await;
- if outcome.is_err() {
- return Err(TestError::Body(
- "transient failover faults must recover".to_owned(),
- ));
- }
- if episode.caller_errors() != 0 || !episode.within_budget() {
- return Err(TestError::Body(
- "recovered episode must show no caller-visible error".to_owned(),
- ));
- }
- episode.record_caller_error();
- if !episode.within_budget() {
- return Err(TestError::Body(
- "one caller-visible error per episode is allowed".to_owned(),
- ));
- }
- // No atomic multi-key operation spans slots: the rotation key set shares
- // `{state}`, and the rate-limit keyspace never joins it.
- let store = Store::from_parts(
- "oauth:{state}:".to_owned(),
- "oauth:{rl}:".to_owned(),
- DurabilityMode::WaitAof,
- );
- let old_key = store.refresh_key("old");
- let new_key = store.refresh_key("new");
- let acc_key = store.access_key("acc");
- let set_key = store.grant_tokens_key("grant");
- let map_key = store.rotation_map_key("req");
- let rotation_keys = [
- old_key.as_str(),
- new_key.as_str(),
- acc_key.as_str(),
- set_key.as_str(),
- map_key.as_str(),
- ];
- if !is_single_slot(&rotation_keys) {
- return Err(TestError::Body(
- "rotation keys must share one slot".to_owned(),
- ));
- }
- let bucket_key = store.ratelimit_key("bucket");
- let split = [old_key.as_str(), bucket_key.as_str()];
- if is_single_slot(&split) {
- return Err(TestError::Body(
- "token + rate-limit keys must never share an atomic op".to_owned(),
- ));
- }
- Ok(())
- }
- /// Errors raised by the resilience harness itself (never rendered to a
- /// client; secrets never appear in these messages).
- #[derive(Debug, thiserror::Error)]
- enum TestError {
- /// A testcontainers operation failed.
- #[error("container error: {0}")]
- Containers(#[from] testcontainers::TestcontainersError),
- /// The crate configuration failed to load.
- #[error("config error: {0}")]
- Config(#[from] ConfigError),
- /// Application state failed to connect.
- #[error("state error: {0}")]
- State(#[from] StateError),
- /// The layered config source failed to load.
- #[error("config source error: {0}")]
- Source(#[from] ::config::ConfigError),
- /// A `docker kill` call failed.
- #[error("docker {0} failed")]
- Docker(String),
- /// An HTTP builder error while driving the router.
- #[error("http error: {0}")]
- Http(#[from] axum::http::Error),
- /// The in-process router returned its (uninhabited) error type.
- #[error("router error")]
- Router(#[from] std::convert::Infallible),
- /// A body/JSON error while reading a probe response.
- #[error("body error: {0}")]
- Body(String),
- /// A JSON error while reading a probe response.
- #[error("json error: {0}")]
- Json(#[from] serde_json::Error),
- /// A migration failure inside the test body.
- #[error("migration error: {0}")]
- Migrate(#[from] sqlx::migrate::MigrateError),
- /// A sqlx failure inside the test body.
- #[error("database error: {0}")]
- Database(#[from] sqlx::Error),
- /// A Redis failure inside the test body.
- #[error("redis error: {0}")]
- Redis(#[from] redis::RedisError),
- /// A store failure inside the test body.
- #[error("store error")]
- Store(#[from] StoreError),
- }
|