resilience.rs 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008
  1. //! `test(resilience)` — failure-injection suite (plan todo 44).
  2. //!
  3. //! Every test name contains `resilience` so
  4. //! `cargo nextest run -E 'test(resilience)'` selects exactly them (plus the
  5. //! `src/resilience.rs` unit tests and the task-4 `tests_resilience` health
  6. //! tests, which share the substring). Bodies use `Result` + `?` (`unwrap`/
  7. //! `expect`/`panic` are denied workspace-wide) and real Postgres/Redis
  8. //! containers through [`test_guard::ContainerGuard`] — never `ContainerAsync`
  9. //! directly, so no container leaks under nextest.
  10. //!
  11. //! Coverage: Redis stopped mid-run → protected surfaces answer `503
  12. //! temporarily_unavailable` + `Retry-After` while `/health/live` stays 200,
  13. //! and recovery needs no process restart; Postgres stopped → `/health/ready`
  14. //! and the (cold-catalog) metadata document fail closed, then recover; a
  15. //! DPoP replay-store outage rejects proofs instead of accepting them; a lost
  16. //! reply / indeterminate post-`EXEC` result is resolved by the rotation
  17. //! idempotency mapping (replay), never double-minted and never confused with
  18. //! reuse; a SIGKILL (not a graceful restart) immediately after a
  19. //! rotation+tombstone write leaves no resurrected token (the `WAITAOF`
  20. //! barrier fsynced before the reply); Sentinel/Cluster failover contracts
  21. //! (`MOVED`/`ASK` transient, single caller-visible error per episode, no
  22. //! cross-slot atomic op) hold at the resolver/classifier level.
  23. #[path = "../src/test_guard.rs"]
  24. #[allow(dead_code)]
  25. mod test_guard;
  26. use std::{
  27. sync::{
  28. Arc,
  29. atomic::{AtomicU32, Ordering},
  30. },
  31. time::Duration,
  32. };
  33. use d_oauth::{
  34. config::{Config, ConfigError, DurabilityMode},
  35. error::AppError,
  36. resilience::{
  37. FailoverEpisode, RetryBudget, is_single_slot, is_transient_redis, retry_idempotent,
  38. },
  39. routes::build_router,
  40. state::{AppState, StateError},
  41. store::{
  42. ErrorClass, OpClass, Store, StoreError, classify_redis,
  43. dcr::claim_dpop_jti,
  44. tokens::{
  45. AccessRecord, RefreshRecord, RotateOutcome, RotationInput, get_refresh, put_refresh, rotate,
  46. tombstone_of,
  47. },
  48. },
  49. };
  50. use test_guard::ContainerGuard;
  51. use testcontainers::{
  52. GenericImage, ImageExt,
  53. core::{IntoContainerPort, WaitFor},
  54. runners::AsyncRunner,
  55. };
  56. /// Test body result (no `unwrap`/`expect` under the deny lints).
  57. type TestResult = Result<(), TestError>;
  58. /// `(status, headers, body)` of one driven request.
  59. type Driven = (u16, axum::http::HeaderMap, String);
  60. /// Postgres superuser password for these fixtures.
  61. const PG_PASSWORD: &str = "resilience-test-secret";
  62. /// Fixed pepper for rotation-id derivation in these tests.
  63. const PEPPER: &[u8] = b"resilience-test-pepper-0123456789";
  64. /// Postgres URL for a container host port.
  65. fn pg_url_for(port: u16) -> String {
  66. format!("postgres://postgres:{PG_PASSWORD}@127.0.0.1:{port}/postgres?sslmode=disable")
  67. }
  68. /// Start Postgres and return the guard plus a plaintext URL.
  69. async fn postgres_container() -> Result<(ContainerGuard, String), TestError> {
  70. let container = GenericImage::new("postgres", "16-alpine")
  71. .with_exposed_port(5432.tcp())
  72. .with_wait_for(WaitFor::message_on_stderr(
  73. "database system is ready to accept connections",
  74. ))
  75. .with_wait_for(WaitFor::message_on_stdout(
  76. "database system is ready to accept connections",
  77. ))
  78. .with_env_var("POSTGRES_PASSWORD", PG_PASSWORD)
  79. .start()
  80. .await?;
  81. let guarded = ContainerGuard::new(container);
  82. let port = guarded.get_host_port_ipv4(5432).await?;
  83. Ok((guarded, pg_url_for(port)))
  84. }
  85. /// Start a single-node Redis with AOF on (the durability under test).
  86. ///
  87. /// `appendfsync always` is the task-44 topology durability mode for
  88. /// single-node deployments: every rotation/tombstone write is fsynced before
  89. /// its reply, so the `WAITAOF` barrier (and the SIGKILL test below) exercises
  90. /// real fsync durability instead of the `everysec` background window.
  91. async fn redis_container() -> Result<(ContainerGuard, String), TestError> {
  92. let container = GenericImage::new("redis", "7.2-alpine")
  93. .with_exposed_port(6379.tcp())
  94. .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
  95. .with_cmd(vec![
  96. "redis-server",
  97. "--appendonly",
  98. "yes",
  99. "--appendfsync",
  100. "always",
  101. ])
  102. .start()
  103. .await?;
  104. let guarded = ContainerGuard::new(container);
  105. let port = guarded.get_host_port_ipv4(6379).await?;
  106. Ok((guarded, format!("redis://127.0.0.1:{port}")))
  107. }
  108. /// Current Redis URL for a (possibly restarted) container: Docker rebinds
  109. /// the host port on restart, so callers must re-resolve after `start()`.
  110. async fn redis_url_for(guard: &ContainerGuard) -> Result<String, TestError> {
  111. let port = guard.get_host_port_ipv4(6379).await?;
  112. Ok(format!("redis://127.0.0.1:{port}"))
  113. }
  114. /// Build a full [`Config`] with short dependency timeouts so outage tests
  115. /// fail fast instead of hanging on the production budgets.
  116. fn test_config(
  117. pg_url: &str,
  118. redis_url: &str,
  119. rate_limit_enabled: bool,
  120. ) -> Result<Config, ConfigError> {
  121. let fixture = serde_json::json!({
  122. "issuer": "https://auth.example.test",
  123. "environment": "development",
  124. "tls": {"cert_path": "/tmp/resilience-test-cert.pem", "key_path": "/tmp/resilience-test-key.pem"},
  125. "database": {"url": pg_url},
  126. "redis": {
  127. "url": redis_url,
  128. "durability": "waitaof",
  129. "connect_timeout_secs": 1,
  130. "response_timeout_secs": 1,
  131. "retry_budget": 1,
  132. "retry_min_delay_ms": 50,
  133. "retry_max_delay_ms": 200,
  134. },
  135. "rate_limit": {"enabled": rate_limit_enabled},
  136. "token_pepper": "resilience-test-pepper-0123456789abcdef",
  137. "totp_encryption_key": "resilience-test-totp-key-0123456789abcdef",
  138. "pii_encryption_key": "resilience-test-pii-key-0123456789abcdef",
  139. });
  140. Config::from_source(::config::Config::try_from(&fixture)?)
  141. }
  142. /// Embedded migrations (same files the server ships); readiness requires the
  143. /// `_sqlx_migrations` version match, so tests apply them like `migrate` would.
  144. static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
  145. /// Connect live state over fresh containers and apply the migrations.
  146. ///
  147. /// Returns the guards, their current URLs, and the connected state. URLs go
  148. /// stale across a container restart (Docker rebinds host ports), so tests
  149. /// that restart a container re-resolve and reconnect in-process via
  150. /// [`reconnect`] — no process restart.
  151. async fn live_state(
  152. rate_limit_enabled: bool,
  153. ) -> Result<(ContainerGuard, ContainerGuard, String, String, AppState), TestError> {
  154. let (pg, pg_url) = postgres_container().await?;
  155. let (redis, redis_url) = redis_container().await?;
  156. let state = reconnect(&pg_url, &redis_url, rate_limit_enabled).await?;
  157. MIGRATOR.run(&state.pool).await?;
  158. Ok((pg, redis, pg_url, redis_url, state))
  159. }
  160. /// Reconnect state in-process over the given URLs (no process restart).
  161. async fn reconnect(
  162. pg_url: &str,
  163. redis_url: &str,
  164. rate_limit_enabled: bool,
  165. ) -> Result<AppState, TestError> {
  166. let config = test_config(pg_url, redis_url, rate_limit_enabled)?;
  167. Ok(AppState::connect(Arc::new(config)).await?)
  168. }
  169. /// Drive one request through the public router (peer identity extensions
  170. /// included, mirroring the task-40 harness).
  171. async fn drive(
  172. router: axum::Router,
  173. method: &str,
  174. uri: &str,
  175. headers: &[(&str, &str)],
  176. body: &str,
  177. ) -> Result<Driven, TestError> {
  178. use tower::ServiceExt as _;
  179. let peer = std::net::SocketAddr::from(([127, 0, 0, 1], 40_001));
  180. let mut builder = axum::http::Request::builder().method(method).uri(uri);
  181. for (name, value) in headers {
  182. builder = builder.header(*name, *value);
  183. }
  184. let mut request = builder.body(axum::body::Body::from(body.to_owned()))?;
  185. request
  186. .extensions_mut()
  187. .insert(axum::extract::ConnectInfo(peer));
  188. request
  189. .extensions_mut()
  190. .insert(axum::extract::connect_info::MockConnectInfo(peer));
  191. let response = router.oneshot(request).await?;
  192. let status = response.status().as_u16();
  193. let response_headers = response.headers().clone();
  194. let bytes = http_body_util::BodyExt::collect(response.into_body())
  195. .await
  196. .map_err(|_| TestError::Body("body collection failed".to_owned()))?
  197. .to_bytes();
  198. Ok((
  199. status,
  200. response_headers,
  201. String::from_utf8_lossy(&bytes).into_owned(),
  202. ))
  203. }
  204. /// Poll `/health/ready` until it reports `want` (or the attempts run out).
  205. async fn poll_ready(state: &AppState, want: u16) -> Result<(), TestError> {
  206. for _ in 0..30 {
  207. let (status, _, _) =
  208. drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
  209. if status == want {
  210. return Ok(());
  211. }
  212. tokio::time::sleep(Duration::from_secs(1)).await;
  213. }
  214. Err(TestError::Body(format!("readiness never reached {want}")))
  215. }
  216. /// Poll Postgres until it answers `SELECT 1` (a restarted container needs
  217. /// seconds before the pool can connect; without this the reconnect races the
  218. /// database startup and fails with a reset connection).
  219. async fn wait_pg(pg_url: &str) -> Result<(), TestError> {
  220. for _ in 0..30 {
  221. let attempt = sqlx::postgres::PgPoolOptions::new()
  222. .max_connections(1)
  223. .acquire_timeout(Duration::from_secs(2))
  224. .connect(pg_url)
  225. .await;
  226. if let Ok(pool) = attempt {
  227. let ready = sqlx::query("SELECT 1").execute(&pool).await.is_ok();
  228. pool.close().await;
  229. if ready {
  230. return Ok(());
  231. }
  232. }
  233. tokio::time::sleep(Duration::from_secs(1)).await;
  234. }
  235. Err(TestError::Body("postgres never came back".to_owned()))
  236. }
  237. /// Poll a direct Redis `PING` until the node answers (post-kill restarts
  238. /// reload the AOF before accepting writes).
  239. async fn poll_ping(redis_url: &str) -> Result<(), TestError> {
  240. for _ in 0..30 {
  241. let client = redis::Client::open(redis_url)?;
  242. if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
  243. let pong: Result<String, redis::RedisError> = redis::cmd("PING").query_async(&mut conn).await;
  244. if matches!(pong.as_deref(), Ok("PONG")) {
  245. return Ok(());
  246. }
  247. }
  248. tokio::time::sleep(Duration::from_secs(1)).await;
  249. }
  250. Err(TestError::Body("redis never answered PING".to_owned()))
  251. }
  252. /// `docker kill` (SIGKILL, no graceful flush) the container.
  253. async fn docker_kill(id: &str) -> Result<(), TestError> {
  254. let output = tokio::process::Command::new("docker")
  255. .args(["kill", id])
  256. .output()
  257. .await
  258. .map_err(|_| TestError::Docker("kill".to_owned()))?;
  259. if !output.status.success() {
  260. return Err(TestError::Docker("kill".to_owned()));
  261. }
  262. Ok(())
  263. }
  264. /// Render an [`AppError`] to `(status, retry-after present, body)`.
  265. async fn render(error: AppError) -> Result<(u16, bool, String), TestError> {
  266. use axum::response::IntoResponse as _;
  267. use http_body_util::BodyExt as _;
  268. let response = error.into_response();
  269. let status = response.status().as_u16();
  270. let retry_after = response
  271. .headers()
  272. .contains_key(axum::http::header::RETRY_AFTER);
  273. let bytes = response
  274. .into_body()
  275. .collect()
  276. .await
  277. .map_err(|_| TestError::Body("body collection failed".to_owned()))?
  278. .to_bytes();
  279. Ok((
  280. status,
  281. retry_after,
  282. String::from_utf8_lossy(&bytes).into_owned(),
  283. ))
  284. }
  285. /// Seed one live refresh-token record for rotation tests.
  286. async fn seed_refresh(
  287. state: &AppState,
  288. old_hash: &str,
  289. client: &str,
  290. grant: &str,
  291. ) -> Result<Store, TestError> {
  292. let store = Store::from_config(&state.config.redis);
  293. let mut conn = state.redis.clone();
  294. put_refresh(
  295. &mut conn,
  296. &store,
  297. old_hash,
  298. &RefreshRecord {
  299. client_id: client.to_owned(),
  300. grant_id: grant.to_owned(),
  301. scope: "api:read".to_owned(),
  302. subject: "user-1".to_owned(),
  303. cnf: String::new(),
  304. },
  305. Duration::from_secs(3600),
  306. )
  307. .await?;
  308. Ok(store)
  309. }
  310. /// One rotation input over fixed candidate hashes.
  311. fn rotation_input(
  312. old_hash: &str,
  313. client: &str,
  314. grant: &str,
  315. new_refresh_hash: &str,
  316. new_access_hash: &str,
  317. request_id: &str,
  318. ) -> RotationInput {
  319. RotationInput {
  320. old_refresh_hash: old_hash.to_owned(),
  321. expected_client_id: client.to_owned(),
  322. new_refresh_hash: new_refresh_hash.to_owned(),
  323. new_access_hash: new_access_hash.to_owned(),
  324. new_refresh: RefreshRecord {
  325. client_id: client.to_owned(),
  326. grant_id: grant.to_owned(),
  327. scope: "api:read".to_owned(),
  328. subject: "user-1".to_owned(),
  329. cnf: String::new(),
  330. },
  331. new_access: AccessRecord {
  332. client_id: client.to_owned(),
  333. grant_id: grant.to_owned(),
  334. scope: "api:read".to_owned(),
  335. subject: "user-1".to_owned(),
  336. cnf: String::new(),
  337. },
  338. new_refresh_ttl: Duration::from_secs(3600),
  339. new_access_ttl: Duration::from_secs(600),
  340. request_id: Some(request_id.to_owned()),
  341. }
  342. }
  343. /// Stopping Redis mid-run fails protected surfaces closed (503 +
  344. /// `Retry-After`, `temporarily_unavailable`) while `/health/live` stays 200;
  345. /// restarting the same container recovers with no process restart.
  346. #[tokio::test]
  347. async fn resilience_redis_stop_token_503_and_ready_flips_then_recovers() -> TestResult {
  348. let (_pg, redis, pg_url, _redis_url, state) = live_state(true).await?;
  349. let form = [("content-type", "application/x-www-form-urlencoded")];
  350. let (baseline, _, _) =
  351. drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
  352. if baseline != 200 {
  353. return Err(TestError::Body(format!(
  354. "ready baseline must be 200, got {baseline}"
  355. )));
  356. }
  357. redis.stop_with_timeout(Some(0)).await?;
  358. let (token_status, token_headers, token_body) = drive(
  359. build_router(state.clone()),
  360. "POST",
  361. "/token",
  362. &form,
  363. "grant_type=refresh_token",
  364. )
  365. .await?;
  366. if token_status != 503 {
  367. return Err(TestError::Body(format!(
  368. "POST /token with Redis down must be 503, got {token_status}: {token_body}"
  369. )));
  370. }
  371. if !token_headers.contains_key(axum::http::header::RETRY_AFTER) {
  372. return Err(TestError::Body("503 must carry Retry-After".to_owned()));
  373. }
  374. if !token_body.contains("temporarily_unavailable") {
  375. return Err(TestError::Body(format!(
  376. "503 body must carry temporarily_unavailable: {token_body}"
  377. )));
  378. }
  379. let (ready_status, _, _) =
  380. drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
  381. if ready_status != 503 {
  382. return Err(TestError::Body(format!(
  383. "readiness with Redis down must be 503, got {ready_status}"
  384. )));
  385. }
  386. let (live_status, _, _) =
  387. drive(build_router(state.clone()), "GET", "/health/live", &[], "").await?;
  388. if live_status != 200 {
  389. return Err(TestError::Body(format!(
  390. "liveness must stay 200, got {live_status}"
  391. )));
  392. }
  393. redis.start().await?;
  394. // Docker rebinds the host port on restart: re-resolve and reconnect
  395. // in-process (the same test process keeps running — no process restart).
  396. let redis_url = redis_url_for(&redis).await?;
  397. let state = reconnect(&pg_url, &redis_url, true).await?;
  398. poll_ready(&state, 200).await?;
  399. // Past the limiter again: the real dispatcher authenticates the (absent)
  400. // client and answers 400 invalid_client, proving recovery without a
  401. // process restart and that /token no longer stubs 501.
  402. let (recovered, _, recovered_body) = drive(
  403. build_router(state.clone()),
  404. "POST",
  405. "/token",
  406. &form,
  407. "grant_type=refresh_token",
  408. )
  409. .await?;
  410. if recovered != 400 {
  411. return Err(TestError::Body(format!(
  412. "POST /token after recovery must reach the dispatcher (400 invalid_client), got {recovered}"
  413. )));
  414. }
  415. if !recovered_body.contains("invalid_client") {
  416. return Err(TestError::Body(format!(
  417. "POST /token after recovery must answer invalid_client, got {recovered_body}"
  418. )));
  419. }
  420. Ok(())
  421. }
  422. /// Stopping Postgres mid-run fails readiness and the cold-catalog metadata
  423. /// document closed; restarting recovers both with no process restart.
  424. #[tokio::test]
  425. async fn resilience_postgres_stop_ready_503_and_metadata_fail_closed_then_recovers() -> TestResult {
  426. let (pg, _redis, _pg_url, redis_url, state) = live_state(false).await?;
  427. let (baseline, _, _) =
  428. drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
  429. if baseline != 200 {
  430. return Err(TestError::Body(format!(
  431. "ready baseline must be 200, got {baseline}"
  432. )));
  433. }
  434. pg.stop_with_timeout(Some(0)).await?;
  435. let (ready_status, _, _) =
  436. drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
  437. if ready_status != 503 {
  438. return Err(TestError::Body(format!(
  439. "readiness with Postgres down must be 503, got {ready_status}"
  440. )));
  441. }
  442. let (live_status, _, _) =
  443. drive(build_router(state.clone()), "GET", "/health/live", &[], "").await?;
  444. if live_status != 200 {
  445. return Err(TestError::Body(format!(
  446. "liveness must stay 200, got {live_status}"
  447. )));
  448. }
  449. // The scope catalog was never populated in this process and Postgres is
  450. // down: the metadata document fails closed rather than advertising an
  451. // unknown scope list.
  452. let (meta_status, _, meta_body) = drive(
  453. build_router(state.clone()),
  454. "GET",
  455. "/.well-known/oauth-authorization-server",
  456. &[],
  457. "",
  458. )
  459. .await?;
  460. if meta_status != 503 || !meta_body.contains("temporarily_unavailable") {
  461. return Err(TestError::Body(format!(
  462. "metadata with cold catalog and Postgres down must be 503 temporarily_unavailable, got {meta_status}: {meta_body}"
  463. )));
  464. }
  465. // A Postgres-backed token grant fails closed as 503 through the
  466. // connectivity mapping: never a 500, and never an unauthenticated mint.
  467. let form = [("content-type", "application/x-www-form-urlencoded")];
  468. let (token_status, token_headers, token_body) = drive(
  469. build_router(state.clone()),
  470. "POST",
  471. "/token",
  472. &form,
  473. "grant_type=client_credentials&client_id=pg-down&client_secret=pg-down",
  474. )
  475. .await?;
  476. if token_status != 503 || !token_body.contains("temporarily_unavailable") {
  477. return Err(TestError::Body(format!(
  478. "POST /token with Postgres down must be 503 temporarily_unavailable, got {token_status}: {token_body}"
  479. )));
  480. }
  481. if !token_headers.contains_key(axum::http::header::RETRY_AFTER) {
  482. return Err(TestError::Body(
  483. "token 503 must carry Retry-After".to_owned(),
  484. ));
  485. }
  486. if token_body.contains("access_token") {
  487. return Err(TestError::Body("token 503 must mint nothing".to_owned()));
  488. }
  489. pg.start().await?;
  490. let pg_url = pg_url_for(pg.get_host_port_ipv4(5432).await?);
  491. wait_pg(&pg_url).await?;
  492. let state = reconnect(&pg_url, &redis_url, false).await?;
  493. poll_ready(&state, 200).await?;
  494. let (meta_recovered, _, meta_doc) = drive(
  495. build_router(state.clone()),
  496. "GET",
  497. "/.well-known/oauth-authorization-server",
  498. &[],
  499. "",
  500. )
  501. .await?;
  502. if meta_recovered != 200 || !meta_doc.contains("https://auth.example.test") {
  503. return Err(TestError::Body(format!(
  504. "metadata after recovery must be 200, got {meta_recovered}: {meta_doc}"
  505. )));
  506. }
  507. Ok(())
  508. }
  509. /// A DPoP replay-store outage rejects proofs (fail closed, 503) instead of
  510. /// accepting them; after recovery the first claim wins and the replay loses.
  511. #[tokio::test]
  512. async fn resilience_dpop_replay_store_failure_rejects_proof() -> TestResult {
  513. let (_pg, redis, pg_url, _redis_url, state) = live_state(false).await?;
  514. let store = Store::from_config(&state.config.redis);
  515. redis.stop_with_timeout(Some(0)).await?;
  516. let mut conn = state.redis.clone();
  517. match claim_dpop_jti(
  518. &mut conn,
  519. &store,
  520. "resilience-jti-hash",
  521. Duration::from_secs(300),
  522. )
  523. .await
  524. {
  525. Ok(_) => {
  526. return Err(TestError::Body(
  527. "DPoP claim with Redis down must fail closed".to_owned(),
  528. ));
  529. }
  530. Err(error) => {
  531. let (status, retry_after, body) = render(AppError::from(error)).await?;
  532. if status != 503 || !retry_after || !body.contains("temporarily_unavailable") {
  533. return Err(TestError::Body(format!(
  534. "replay-store failure must render 503 + Retry-After + temporarily_unavailable, got {status}: {body}"
  535. )));
  536. }
  537. }
  538. }
  539. redis.start().await?;
  540. let redis_url = redis_url_for(&redis).await?;
  541. poll_ping(&redis_url).await?;
  542. let state = reconnect(&pg_url, &redis_url, false).await?;
  543. let store = Store::from_config(&state.config.redis);
  544. let mut conn = state.redis.clone();
  545. if !claim_dpop_jti(
  546. &mut conn,
  547. &store,
  548. "resilience-jti-hash",
  549. Duration::from_secs(300),
  550. )
  551. .await?
  552. {
  553. return Err(TestError::Body(
  554. "first claim after recovery must win".to_owned(),
  555. ));
  556. }
  557. if claim_dpop_jti(
  558. &mut conn,
  559. &store,
  560. "resilience-jti-hash",
  561. Duration::from_secs(300),
  562. )
  563. .await?
  564. {
  565. return Err(TestError::Body("replayed jti must lose".to_owned()));
  566. }
  567. Ok(())
  568. }
  569. /// A lost reply / indeterminate post-`EXEC` result is resolved by the rotation
  570. /// idempotency mapping (replay with the original tokens), never double-minted
  571. /// and never confused with reuse.
  572. #[tokio::test]
  573. async fn resilience_rotation_lost_reply_resolved_by_mapping_not_reuse() -> TestResult {
  574. let (_pg, _redis, _pg_url, _redis_url, state) = live_state(false).await?;
  575. let store = seed_refresh(&state, "resilience-old-1", "client-a", "grant-a").await?;
  576. let mut conn = state.redis.clone();
  577. // First rotation succeeds; the reply is "lost" (indeterminate outcome).
  578. let first = rotate(
  579. &mut conn,
  580. &store,
  581. PEPPER,
  582. &rotation_input(
  583. "resilience-old-1",
  584. "client-a",
  585. "grant-a",
  586. "resilience-new-1",
  587. "resilience-acc-1",
  588. "req-lost-1",
  589. ),
  590. )
  591. .await?;
  592. let (original_refresh, original_access) = match first {
  593. RotateOutcome::Rotated {
  594. new_refresh_hash,
  595. new_access_hash,
  596. } => (new_refresh_hash, new_access_hash),
  597. other => {
  598. return Err(TestError::Body(format!(
  599. "first rotation must apply, got {other:?}"
  600. )));
  601. }
  602. };
  603. // Retry with the same request id but different candidates: the mapping
  604. // decides replay with the ORIGINAL tokens; the new candidates are ignored.
  605. let retry = rotate(
  606. &mut conn,
  607. &store,
  608. PEPPER,
  609. &rotation_input(
  610. "resilience-old-1",
  611. "client-a",
  612. "grant-a",
  613. "resilience-decoy-1",
  614. "resilience-decoy-acc-1",
  615. "req-lost-1",
  616. ),
  617. )
  618. .await?;
  619. match retry {
  620. RotateOutcome::Replay {
  621. new_refresh_hash,
  622. new_access_hash,
  623. } => {
  624. if new_refresh_hash != original_refresh || new_access_hash != original_access {
  625. return Err(TestError::Body(
  626. "replay must return the original tokens".to_owned(),
  627. ));
  628. }
  629. }
  630. other => {
  631. return Err(TestError::Body(format!(
  632. "lost-reply retry must replay, got {other:?}"
  633. )));
  634. }
  635. }
  636. // No double-mint: the decoy candidates were never stored.
  637. if get_refresh(&mut conn, &store, "resilience-decoy-1")
  638. .await?
  639. .is_some()
  640. {
  641. return Err(TestError::Body(
  642. "decoy candidate must never be minted".to_owned(),
  643. ));
  644. }
  645. if get_refresh(&mut conn, &store, &original_refresh)
  646. .await?
  647. .is_none()
  648. {
  649. return Err(TestError::Body(
  650. "original rotated token must exist once".to_owned(),
  651. ));
  652. }
  653. // Same old token WITHOUT a valid mapping is reuse (tombstone decides),
  654. // which cascade-revokes the grant family.
  655. match rotate(
  656. &mut conn,
  657. &store,
  658. PEPPER,
  659. &rotation_input(
  660. "resilience-old-1",
  661. "client-a",
  662. "grant-a",
  663. "resilience-new-2",
  664. "resilience-acc-2",
  665. "req-other-1",
  666. ),
  667. )
  668. .await?
  669. {
  670. RotateOutcome::Reused { .. } => Ok(()),
  671. other => Err(TestError::Body(format!(
  672. "unmapped re-presentation must be reuse, got {other:?}"
  673. ))),
  674. }
  675. }
  676. /// SIGKILL (no graceful flush) immediately after a rotation+tombstone write
  677. /// leaves no resurrected token: the tombstone and the idempotency mapping
  678. /// both survive through the `WAITAOF` barrier, so reuse is still detected.
  679. #[tokio::test]
  680. async fn resilience_sigkill_after_rotation_tombstone_no_resurrection() -> TestResult {
  681. let (_pg, redis, pg_url, _redis_url, state) = live_state(false).await?;
  682. let store = seed_refresh(&state, "resilience-old-2", "client-a", "grant-b").await?;
  683. let mut conn = state.redis.clone();
  684. let outcome = rotate(
  685. &mut conn,
  686. &store,
  687. PEPPER,
  688. &rotation_input(
  689. "resilience-old-2",
  690. "client-a",
  691. "grant-b",
  692. "resilience-new-9",
  693. "resilience-acc-9",
  694. "req-kill-1",
  695. ),
  696. )
  697. .await?;
  698. if !matches!(outcome, RotateOutcome::Rotated { .. }) {
  699. return Err(TestError::Body(format!(
  700. "rotation must apply, got {outcome:?}"
  701. )));
  702. }
  703. // The durability barrier ran inside `rotate` before it returned; SIGKILL
  704. // now (no `SAVE`, no graceful flush) exercises AOF durability, not a clean
  705. // restart.
  706. docker_kill(redis.id()).await?;
  707. redis.start().await?;
  708. let redis_url = redis_url_for(&redis).await?;
  709. poll_ping(&redis_url).await?;
  710. let state = reconnect(&pg_url, &redis_url, false).await?;
  711. let store = Store::from_config(&state.config.redis);
  712. let mut conn = state.redis.clone();
  713. match tombstone_of(&mut conn, &store, "resilience-old-2").await? {
  714. Some(rotated_to) if rotated_to == "resilience-new-9" => {}
  715. other => {
  716. return Err(TestError::Body(format!(
  717. "tombstone must survive SIGKILL (no resurrection), got {other:?}"
  718. )));
  719. }
  720. }
  721. // The mapping survived too: the original request id still replays.
  722. match rotate(
  723. &mut conn,
  724. &store,
  725. PEPPER,
  726. &rotation_input(
  727. "resilience-old-2",
  728. "client-a",
  729. "grant-b",
  730. "resilience-decoy-9",
  731. "resilience-decoy-acc-9",
  732. "req-kill-1",
  733. ),
  734. )
  735. .await?
  736. {
  737. RotateOutcome::Replay {
  738. new_refresh_hash, ..
  739. } if new_refresh_hash == "resilience-new-9" => {}
  740. other => {
  741. return Err(TestError::Body(format!(
  742. "post-kill retry with the valid mapping must replay, got {other:?}"
  743. )));
  744. }
  745. }
  746. // And an unmapped re-presentation is still reuse, not a live token.
  747. match rotate(
  748. &mut conn,
  749. &store,
  750. PEPPER,
  751. &rotation_input(
  752. "resilience-old-2",
  753. "client-a",
  754. "grant-b",
  755. "resilience-new-10",
  756. "resilience-acc-10",
  757. "req-kill-2",
  758. ),
  759. )
  760. .await?
  761. {
  762. RotateOutcome::Reused { .. } => Ok(()),
  763. other => Err(TestError::Body(format!(
  764. "old token must stay dead after SIGKILL, got {other:?}"
  765. ))),
  766. }
  767. }
  768. /// In-flight requests during an outage all fail clean (503, no hang, no
  769. /// 500/panic) instead of queueing into `PoolTimedOut`.
  770. #[tokio::test]
  771. async fn resilience_inflight_requests_fail_clean_during_outage() -> TestResult {
  772. let (_pg, redis, _pg_url, _redis_url, state) = live_state(true).await?;
  773. redis.stop_with_timeout(Some(0)).await?;
  774. let burst = async {
  775. let mut handles = Vec::new();
  776. for _ in 0..20 {
  777. let router = build_router(state.clone());
  778. handles.push(tokio::spawn(async move {
  779. drive(router, "GET", "/health/ready", &[], "").await
  780. }));
  781. }
  782. let mut statuses = Vec::new();
  783. for handle in handles {
  784. match handle.await {
  785. Ok(Ok((status, _, _))) => statuses.push(status),
  786. Ok(Err(_)) | Err(_) => statuses.push(0),
  787. }
  788. }
  789. statuses
  790. };
  791. let statuses = tokio::time::timeout(Duration::from_secs(30), burst)
  792. .await
  793. .map_err(|_| TestError::Body("in-flight burst hung".to_owned()))?;
  794. if statuses.len() != 20 {
  795. return Err(TestError::Body(
  796. "every in-flight request must settle".to_owned(),
  797. ));
  798. }
  799. for status in &statuses {
  800. if *status != 503 {
  801. return Err(TestError::Body(format!(
  802. "every in-flight request must fail closed with 503, got {status}"
  803. )));
  804. }
  805. }
  806. Ok(())
  807. }
  808. /// Topology failover contracts without a multi-node deployment: `MOVED`/`ASK`
  809. /// classify transient (slot-refresh recovery), failover episodes allow at
  810. /// most one caller-visible error, and atomic multi-key operations never span
  811. /// slots.
  812. #[tokio::test]
  813. async fn resilience_topology_failover_contracts_recover_with_single_error() -> TestResult {
  814. // `MOVED`/`ASK` (Cluster slot refresh) are transient: safe to retry an
  815. // idempotent read through them.
  816. for wire in [
  817. "-MOVED 1234 127.0.0.1:7000\r\n",
  818. "-ASK 5678 127.0.0.1:7001\r\n",
  819. "-TRYAGAIN Multiple keys request must be in the same slot\r\n",
  820. ] {
  821. let value = redis::parse_redis_value(wire.as_bytes())
  822. .map_err(|_| TestError::Body("wire parse failed".to_owned()))?;
  823. let server = match value {
  824. redis::Value::ServerError(error) => error,
  825. _ => return Err(TestError::Body("expected a server-error value".to_owned())),
  826. };
  827. let error = redis::RedisError::from(server);
  828. if classify_redis(&error) != ErrorClass::Transient {
  829. return Err(TestError::Body(format!("{wire:?} must classify transient")));
  830. }
  831. if !is_transient_redis(&error) {
  832. return Err(TestError::Body(
  833. "resilience predicate must agree".to_owned(),
  834. ));
  835. }
  836. if !d_oauth::store::may_retry_op(OpClass::IdempotentRead, &error) {
  837. return Err(TestError::Body(
  838. "idempotent reads must retry through slot moves".to_owned(),
  839. ));
  840. }
  841. }
  842. // A failover episode recovers transparently: transient faults retried
  843. // inside the budget surface zero caller-visible errors.
  844. let episode = FailoverEpisode::new();
  845. let attempts = Arc::new(AtomicU32::new(0));
  846. let probe = Arc::clone(&attempts);
  847. let moved = || {
  848. let value = redis::parse_redis_value(b"-MOVED 0 127.0.0.1:7000\r\n");
  849. match value {
  850. Ok(redis::Value::ServerError(error)) => redis::RedisError::from(error),
  851. _ => redis::RedisError::from((redis::ErrorKind::Io, "synthetic")),
  852. }
  853. };
  854. let budget = RetryBudget::new(8);
  855. let outcome = retry_idempotent(
  856. move || {
  857. let probe = Arc::clone(&probe);
  858. let moved = moved();
  859. async move {
  860. let attempt = probe.fetch_add(1, Ordering::SeqCst);
  861. if attempt < 2 {
  862. Err::<(), redis::RedisError>(moved)
  863. } else {
  864. Ok(())
  865. }
  866. }
  867. },
  868. is_transient_redis,
  869. &budget,
  870. 2,
  871. )
  872. .await;
  873. if outcome.is_err() {
  874. return Err(TestError::Body(
  875. "transient failover faults must recover".to_owned(),
  876. ));
  877. }
  878. if episode.caller_errors() != 0 || !episode.within_budget() {
  879. return Err(TestError::Body(
  880. "recovered episode must show no caller-visible error".to_owned(),
  881. ));
  882. }
  883. episode.record_caller_error();
  884. if !episode.within_budget() {
  885. return Err(TestError::Body(
  886. "one caller-visible error per episode is allowed".to_owned(),
  887. ));
  888. }
  889. // No atomic multi-key operation spans slots: the rotation key set shares
  890. // `{state}`, and the rate-limit keyspace never joins it.
  891. let store = Store::from_parts(
  892. "oauth:{state}:".to_owned(),
  893. "oauth:{rl}:".to_owned(),
  894. DurabilityMode::WaitAof,
  895. );
  896. let old_key = store.refresh_key("old");
  897. let new_key = store.refresh_key("new");
  898. let acc_key = store.access_key("acc");
  899. let set_key = store.grant_tokens_key("grant");
  900. let map_key = store.rotation_map_key("req");
  901. let rotation_keys = [
  902. old_key.as_str(),
  903. new_key.as_str(),
  904. acc_key.as_str(),
  905. set_key.as_str(),
  906. map_key.as_str(),
  907. ];
  908. if !is_single_slot(&rotation_keys) {
  909. return Err(TestError::Body(
  910. "rotation keys must share one slot".to_owned(),
  911. ));
  912. }
  913. let bucket_key = store.ratelimit_key("bucket");
  914. let split = [old_key.as_str(), bucket_key.as_str()];
  915. if is_single_slot(&split) {
  916. return Err(TestError::Body(
  917. "token + rate-limit keys must never share an atomic op".to_owned(),
  918. ));
  919. }
  920. Ok(())
  921. }
  922. /// Errors raised by the resilience harness itself (never rendered to a
  923. /// client; secrets never appear in these messages).
  924. #[derive(Debug, thiserror::Error)]
  925. enum TestError {
  926. /// A testcontainers operation failed.
  927. #[error("container error: {0}")]
  928. Containers(#[from] testcontainers::TestcontainersError),
  929. /// The crate configuration failed to load.
  930. #[error("config error: {0}")]
  931. Config(#[from] ConfigError),
  932. /// Application state failed to connect.
  933. #[error("state error: {0}")]
  934. State(#[from] StateError),
  935. /// The layered config source failed to load.
  936. #[error("config source error: {0}")]
  937. Source(#[from] ::config::ConfigError),
  938. /// A `docker kill` call failed.
  939. #[error("docker {0} failed")]
  940. Docker(String),
  941. /// An HTTP builder error while driving the router.
  942. #[error("http error: {0}")]
  943. Http(#[from] axum::http::Error),
  944. /// The in-process router returned its (uninhabited) error type.
  945. #[error("router error")]
  946. Router(#[from] std::convert::Infallible),
  947. /// A body/JSON error while reading a probe response.
  948. #[error("body error: {0}")]
  949. Body(String),
  950. /// A JSON error while reading a probe response.
  951. #[error("json error: {0}")]
  952. Json(#[from] serde_json::Error),
  953. /// A migration failure inside the test body.
  954. #[error("migration error: {0}")]
  955. Migrate(#[from] sqlx::migrate::MigrateError),
  956. /// A sqlx failure inside the test body.
  957. #[error("database error: {0}")]
  958. Database(#[from] sqlx::Error),
  959. /// A Redis failure inside the test body.
  960. #[error("redis error: {0}")]
  961. Redis(#[from] redis::RedisError),
  962. /// A store failure inside the test body.
  963. #[error("store error")]
  964. Store(#[from] StoreError),
  965. }