resilience.rs 33 KB

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