resilience.rs 32 KB

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