resilience.rs 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026
  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", "18-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", "8-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. aud: Vec::new(),
  306. },
  307. Duration::from_secs(3600),
  308. )
  309. .await?;
  310. create_grant(
  311. &mut conn,
  312. &store,
  313. &GrantRecord {
  314. grant_id: grant.to_owned(),
  315. client_id: client.to_owned(),
  316. subject: "user-1".to_owned(),
  317. scope: "api:read".to_owned(),
  318. aud: Vec::new(),
  319. },
  320. Duration::from_secs(3600),
  321. )
  322. .await?;
  323. Ok(store)
  324. }
  325. /// One rotation input over fixed candidate hashes.
  326. fn rotation_input(
  327. old_hash: &str,
  328. client: &str,
  329. grant: &str,
  330. new_refresh_hash: &str,
  331. new_access_hash: &str,
  332. request_id: &str,
  333. ) -> RotationInput {
  334. RotationInput {
  335. old_refresh_hash: old_hash.to_owned(),
  336. expected_client_id: client.to_owned(),
  337. new_refresh_hash: new_refresh_hash.to_owned(),
  338. new_access_hash: new_access_hash.to_owned(),
  339. new_refresh: RefreshRecord {
  340. client_id: client.to_owned(),
  341. grant_id: grant.to_owned(),
  342. scope: "api:read".to_owned(),
  343. subject: "user-1".to_owned(),
  344. cnf: String::new(),
  345. aud: Vec::new(),
  346. },
  347. new_access: AccessRecord {
  348. client_id: client.to_owned(),
  349. grant_id: grant.to_owned(),
  350. scope: "api:read".to_owned(),
  351. subject: "user-1".to_owned(),
  352. cnf: String::new(),
  353. aud: Vec::new(),
  354. },
  355. new_refresh_ttl: Duration::from_secs(3600),
  356. new_access_ttl: Duration::from_secs(600),
  357. request_id: Some(request_id.to_owned()),
  358. jwt_jti_hash: None,
  359. }
  360. }
  361. /// Stopping Redis mid-run fails protected surfaces closed (503 +
  362. /// `Retry-After`, `temporarily_unavailable`) while `/health/live` stays 200;
  363. /// restarting the same container recovers with no process restart.
  364. #[tokio::test]
  365. async fn resilience_redis_stop_token_503_and_ready_flips_then_recovers() -> TestResult {
  366. let (_pg, redis, pg_url, _redis_url, state) = live_state(true).await?;
  367. let form = [("content-type", "application/x-www-form-urlencoded")];
  368. let (baseline, _, _) =
  369. drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
  370. if baseline != 200 {
  371. return Err(TestError::Body(format!(
  372. "ready baseline must be 200, got {baseline}"
  373. )));
  374. }
  375. redis.stop_with_timeout(Some(0)).await?;
  376. let (token_status, token_headers, token_body) = drive(
  377. build_router(state.clone()),
  378. "POST",
  379. "/token",
  380. &form,
  381. "grant_type=refresh_token",
  382. )
  383. .await?;
  384. if token_status != 503 {
  385. return Err(TestError::Body(format!(
  386. "POST /token with Redis down must be 503, got {token_status}: {token_body}"
  387. )));
  388. }
  389. if !token_headers.contains_key(axum::http::header::RETRY_AFTER) {
  390. return Err(TestError::Body("503 must carry Retry-After".to_owned()));
  391. }
  392. if !token_body.contains("temporarily_unavailable") {
  393. return Err(TestError::Body(format!(
  394. "503 body must carry temporarily_unavailable: {token_body}"
  395. )));
  396. }
  397. let (ready_status, _, _) =
  398. drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
  399. if ready_status != 503 {
  400. return Err(TestError::Body(format!(
  401. "readiness with Redis down must be 503, got {ready_status}"
  402. )));
  403. }
  404. let (live_status, _, _) =
  405. drive(build_router(state.clone()), "GET", "/health/live", &[], "").await?;
  406. if live_status != 200 {
  407. return Err(TestError::Body(format!(
  408. "liveness must stay 200, got {live_status}"
  409. )));
  410. }
  411. redis.start().await?;
  412. // Docker rebinds the host port on restart: re-resolve and reconnect
  413. // in-process (the same test process keeps running — no process restart).
  414. let redis_url = redis_url_for(&redis).await?;
  415. let state = reconnect(&pg_url, &redis_url, true).await?;
  416. poll_ready(&state, 200).await?;
  417. // Past the limiter again: the real dispatcher authenticates the (absent)
  418. // client and answers 400 invalid_client, proving recovery without a
  419. // process restart and that /token no longer stubs 501.
  420. let (recovered, _, recovered_body) = drive(
  421. build_router(state.clone()),
  422. "POST",
  423. "/token",
  424. &form,
  425. "grant_type=refresh_token",
  426. )
  427. .await?;
  428. if recovered != 400 {
  429. return Err(TestError::Body(format!(
  430. "POST /token after recovery must reach the dispatcher (400 invalid_client), got {recovered}"
  431. )));
  432. }
  433. if !recovered_body.contains("invalid_client") {
  434. return Err(TestError::Body(format!(
  435. "POST /token after recovery must answer invalid_client, got {recovered_body}"
  436. )));
  437. }
  438. Ok(())
  439. }
  440. /// Stopping Postgres mid-run fails readiness and the cold-catalog metadata
  441. /// document closed; restarting recovers both with no process restart.
  442. #[tokio::test]
  443. async fn resilience_postgres_stop_ready_503_and_metadata_fail_closed_then_recovers() -> TestResult {
  444. let (pg, _redis, _pg_url, redis_url, state) = live_state(false).await?;
  445. let (baseline, _, _) =
  446. drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
  447. if baseline != 200 {
  448. return Err(TestError::Body(format!(
  449. "ready baseline must be 200, got {baseline}"
  450. )));
  451. }
  452. pg.stop_with_timeout(Some(0)).await?;
  453. let (ready_status, _, _) =
  454. drive(build_router(state.clone()), "GET", "/health/ready", &[], "").await?;
  455. if ready_status != 503 {
  456. return Err(TestError::Body(format!(
  457. "readiness with Postgres down must be 503, got {ready_status}"
  458. )));
  459. }
  460. let (live_status, _, _) =
  461. drive(build_router(state.clone()), "GET", "/health/live", &[], "").await?;
  462. if live_status != 200 {
  463. return Err(TestError::Body(format!(
  464. "liveness must stay 200, got {live_status}"
  465. )));
  466. }
  467. // The scope catalog was never populated in this process and Postgres is
  468. // down: the metadata document fails closed rather than advertising an
  469. // unknown scope list.
  470. let (meta_status, _, meta_body) = drive(
  471. build_router(state.clone()),
  472. "GET",
  473. "/.well-known/oauth-authorization-server",
  474. &[],
  475. "",
  476. )
  477. .await?;
  478. if meta_status != 503 || !meta_body.contains("temporarily_unavailable") {
  479. return Err(TestError::Body(format!(
  480. "metadata with cold catalog and Postgres down must be 503 temporarily_unavailable, got {meta_status}: {meta_body}"
  481. )));
  482. }
  483. // A Postgres-backed token grant fails closed as 503 through the
  484. // connectivity mapping: never a 500, and never an unauthenticated mint.
  485. let form = [("content-type", "application/x-www-form-urlencoded")];
  486. let (token_status, token_headers, token_body) = drive(
  487. build_router(state.clone()),
  488. "POST",
  489. "/token",
  490. &form,
  491. "grant_type=client_credentials&client_id=pg-down&client_secret=pg-down",
  492. )
  493. .await?;
  494. if token_status != 503 || !token_body.contains("temporarily_unavailable") {
  495. return Err(TestError::Body(format!(
  496. "POST /token with Postgres down must be 503 temporarily_unavailable, got {token_status}: {token_body}"
  497. )));
  498. }
  499. if !token_headers.contains_key(axum::http::header::RETRY_AFTER) {
  500. return Err(TestError::Body(
  501. "token 503 must carry Retry-After".to_owned(),
  502. ));
  503. }
  504. if token_body.contains("access_token") {
  505. return Err(TestError::Body("token 503 must mint nothing".to_owned()));
  506. }
  507. pg.start().await?;
  508. let pg_url = pg_url_for(pg.get_host_port_ipv4(5432).await?);
  509. wait_pg(&pg_url).await?;
  510. let state = reconnect(&pg_url, &redis_url, false).await?;
  511. poll_ready(&state, 200).await?;
  512. let (meta_recovered, _, meta_doc) = drive(
  513. build_router(state.clone()),
  514. "GET",
  515. "/.well-known/oauth-authorization-server",
  516. &[],
  517. "",
  518. )
  519. .await?;
  520. if meta_recovered != 200 || !meta_doc.contains("https://auth.example.test") {
  521. return Err(TestError::Body(format!(
  522. "metadata after recovery must be 200, got {meta_recovered}: {meta_doc}"
  523. )));
  524. }
  525. Ok(())
  526. }
  527. /// A DPoP replay-store outage rejects proofs (fail closed, 503) instead of
  528. /// accepting them; after recovery the first claim wins and the replay loses.
  529. #[tokio::test]
  530. async fn resilience_dpop_replay_store_failure_rejects_proof() -> TestResult {
  531. let (_pg, redis, pg_url, _redis_url, state) = live_state(false).await?;
  532. let store = Store::from_config(&state.config.redis);
  533. redis.stop_with_timeout(Some(0)).await?;
  534. let mut conn = state.redis.clone();
  535. match claim_dpop_jti(
  536. &mut conn,
  537. &store,
  538. "resilience-jti-hash",
  539. Duration::from_secs(300),
  540. )
  541. .await
  542. {
  543. Ok(_) => {
  544. return Err(TestError::Body(
  545. "DPoP claim with Redis down must fail closed".to_owned(),
  546. ));
  547. }
  548. Err(error) => {
  549. let (status, retry_after, body) = render(AppError::from(error)).await?;
  550. if status != 503 || !retry_after || !body.contains("temporarily_unavailable") {
  551. return Err(TestError::Body(format!(
  552. "replay-store failure must render 503 + Retry-After + temporarily_unavailable, got {status}: {body}"
  553. )));
  554. }
  555. }
  556. }
  557. redis.start().await?;
  558. let redis_url = redis_url_for(&redis).await?;
  559. poll_ping(&redis_url).await?;
  560. let state = reconnect(&pg_url, &redis_url, false).await?;
  561. let store = Store::from_config(&state.config.redis);
  562. let mut conn = state.redis.clone();
  563. if !claim_dpop_jti(
  564. &mut conn,
  565. &store,
  566. "resilience-jti-hash",
  567. Duration::from_secs(300),
  568. )
  569. .await?
  570. {
  571. return Err(TestError::Body(
  572. "first claim after recovery must win".to_owned(),
  573. ));
  574. }
  575. if claim_dpop_jti(
  576. &mut conn,
  577. &store,
  578. "resilience-jti-hash",
  579. Duration::from_secs(300),
  580. )
  581. .await?
  582. {
  583. return Err(TestError::Body("replayed jti must lose".to_owned()));
  584. }
  585. Ok(())
  586. }
  587. /// A lost reply / indeterminate post-`EXEC` result is resolved by the rotation
  588. /// idempotency mapping (replay with the original tokens), never double-minted
  589. /// and never confused with reuse.
  590. #[tokio::test]
  591. async fn resilience_rotation_lost_reply_resolved_by_mapping_not_reuse() -> TestResult {
  592. let (_pg, _redis, _pg_url, _redis_url, state) = live_state(false).await?;
  593. let store = seed_refresh(&state, "resilience-old-1", "client-a", "grant-a").await?;
  594. let mut conn = state.redis.clone();
  595. // First rotation succeeds; the reply is "lost" (indeterminate outcome).
  596. let first = rotate(
  597. &mut conn,
  598. &store,
  599. PEPPER,
  600. &rotation_input(
  601. "resilience-old-1",
  602. "client-a",
  603. "grant-a",
  604. "resilience-new-1",
  605. "resilience-acc-1",
  606. "req-lost-1",
  607. ),
  608. )
  609. .await?;
  610. let (original_refresh, original_access) = match first {
  611. RotateOutcome::Rotated {
  612. new_refresh_hash,
  613. new_access_hash,
  614. } => (new_refresh_hash, new_access_hash),
  615. other => {
  616. return Err(TestError::Body(format!(
  617. "first rotation must apply, got {other:?}"
  618. )));
  619. }
  620. };
  621. // Retry with the same request id but different candidates: the mapping
  622. // decides replay with the ORIGINAL tokens; the new candidates are ignored.
  623. let retry = rotate(
  624. &mut conn,
  625. &store,
  626. PEPPER,
  627. &rotation_input(
  628. "resilience-old-1",
  629. "client-a",
  630. "grant-a",
  631. "resilience-decoy-1",
  632. "resilience-decoy-acc-1",
  633. "req-lost-1",
  634. ),
  635. )
  636. .await?;
  637. match retry {
  638. RotateOutcome::Replay {
  639. new_refresh_hash,
  640. new_access_hash,
  641. } => {
  642. if new_refresh_hash != original_refresh || new_access_hash != original_access {
  643. return Err(TestError::Body(
  644. "replay must return the original tokens".to_owned(),
  645. ));
  646. }
  647. }
  648. other => {
  649. return Err(TestError::Body(format!(
  650. "lost-reply retry must replay, got {other:?}"
  651. )));
  652. }
  653. }
  654. // No double-mint: the decoy candidates were never stored.
  655. if get_refresh(&mut conn, &store, "resilience-decoy-1")
  656. .await?
  657. .is_some()
  658. {
  659. return Err(TestError::Body(
  660. "decoy candidate must never be minted".to_owned(),
  661. ));
  662. }
  663. if get_refresh(&mut conn, &store, &original_refresh)
  664. .await?
  665. .is_none()
  666. {
  667. return Err(TestError::Body(
  668. "original rotated token must exist once".to_owned(),
  669. ));
  670. }
  671. // Same old token WITHOUT a valid mapping is reuse (tombstone decides),
  672. // which cascade-revokes the grant family.
  673. match rotate(
  674. &mut conn,
  675. &store,
  676. PEPPER,
  677. &rotation_input(
  678. "resilience-old-1",
  679. "client-a",
  680. "grant-a",
  681. "resilience-new-2",
  682. "resilience-acc-2",
  683. "req-other-1",
  684. ),
  685. )
  686. .await?
  687. {
  688. RotateOutcome::Reused { .. } => Ok(()),
  689. other => Err(TestError::Body(format!(
  690. "unmapped re-presentation must be reuse, got {other:?}"
  691. ))),
  692. }
  693. }
  694. /// SIGKILL (no graceful flush) immediately after a rotation+tombstone write
  695. /// leaves no resurrected token: the tombstone and the idempotency mapping
  696. /// both survive through the `WAITAOF` barrier, so reuse is still detected.
  697. #[tokio::test]
  698. async fn resilience_sigkill_after_rotation_tombstone_no_resurrection() -> TestResult {
  699. let (_pg, redis, pg_url, _redis_url, state) = live_state(false).await?;
  700. let store = seed_refresh(&state, "resilience-old-2", "client-a", "grant-b").await?;
  701. let mut conn = state.redis.clone();
  702. let outcome = rotate(
  703. &mut conn,
  704. &store,
  705. PEPPER,
  706. &rotation_input(
  707. "resilience-old-2",
  708. "client-a",
  709. "grant-b",
  710. "resilience-new-9",
  711. "resilience-acc-9",
  712. "req-kill-1",
  713. ),
  714. )
  715. .await?;
  716. if !matches!(outcome, RotateOutcome::Rotated { .. }) {
  717. return Err(TestError::Body(format!(
  718. "rotation must apply, got {outcome:?}"
  719. )));
  720. }
  721. // The durability barrier ran inside `rotate` before it returned; SIGKILL
  722. // now (no `SAVE`, no graceful flush) exercises AOF durability, not a clean
  723. // restart.
  724. docker_kill(redis.id()).await?;
  725. redis.start().await?;
  726. let redis_url = redis_url_for(&redis).await?;
  727. poll_ping(&redis_url).await?;
  728. let state = reconnect(&pg_url, &redis_url, false).await?;
  729. let store = Store::from_config(&state.config.redis);
  730. let mut conn = state.redis.clone();
  731. match tombstone_of(&mut conn, &store, "resilience-old-2").await? {
  732. Some(rotated_to) if rotated_to == "resilience-new-9" => {}
  733. other => {
  734. return Err(TestError::Body(format!(
  735. "tombstone must survive SIGKILL (no resurrection), got {other:?}"
  736. )));
  737. }
  738. }
  739. // The mapping survived too: the original request id still replays.
  740. match rotate(
  741. &mut conn,
  742. &store,
  743. PEPPER,
  744. &rotation_input(
  745. "resilience-old-2",
  746. "client-a",
  747. "grant-b",
  748. "resilience-decoy-9",
  749. "resilience-decoy-acc-9",
  750. "req-kill-1",
  751. ),
  752. )
  753. .await?
  754. {
  755. RotateOutcome::Replay {
  756. new_refresh_hash, ..
  757. } if new_refresh_hash == "resilience-new-9" => {}
  758. other => {
  759. return Err(TestError::Body(format!(
  760. "post-kill retry with the valid mapping must replay, got {other:?}"
  761. )));
  762. }
  763. }
  764. // And an unmapped re-presentation is still reuse, not a live token.
  765. match rotate(
  766. &mut conn,
  767. &store,
  768. PEPPER,
  769. &rotation_input(
  770. "resilience-old-2",
  771. "client-a",
  772. "grant-b",
  773. "resilience-new-10",
  774. "resilience-acc-10",
  775. "req-kill-2",
  776. ),
  777. )
  778. .await?
  779. {
  780. RotateOutcome::Reused { .. } => Ok(()),
  781. other => Err(TestError::Body(format!(
  782. "old token must stay dead after SIGKILL, got {other:?}"
  783. ))),
  784. }
  785. }
  786. /// In-flight requests during an outage all fail clean (503, no hang, no
  787. /// 500/panic) instead of queueing into `PoolTimedOut`.
  788. #[tokio::test]
  789. async fn resilience_inflight_requests_fail_clean_during_outage() -> TestResult {
  790. let (_pg, redis, _pg_url, _redis_url, state) = live_state(true).await?;
  791. redis.stop_with_timeout(Some(0)).await?;
  792. let burst = async {
  793. let mut handles = Vec::new();
  794. for _ in 0..20 {
  795. let router = build_router(state.clone());
  796. handles.push(tokio::spawn(async move {
  797. drive(router, "GET", "/health/ready", &[], "").await
  798. }));
  799. }
  800. let mut statuses = Vec::new();
  801. for handle in handles {
  802. match handle.await {
  803. Ok(Ok((status, _, _))) => statuses.push(status),
  804. Ok(Err(_)) | Err(_) => statuses.push(0),
  805. }
  806. }
  807. statuses
  808. };
  809. let statuses = tokio::time::timeout(Duration::from_secs(30), burst)
  810. .await
  811. .map_err(|_| TestError::Body("in-flight burst hung".to_owned()))?;
  812. if statuses.len() != 20 {
  813. return Err(TestError::Body(
  814. "every in-flight request must settle".to_owned(),
  815. ));
  816. }
  817. for status in &statuses {
  818. if *status != 503 {
  819. return Err(TestError::Body(format!(
  820. "every in-flight request must fail closed with 503, got {status}"
  821. )));
  822. }
  823. }
  824. Ok(())
  825. }
  826. /// Topology failover contracts without a multi-node deployment: `MOVED`/`ASK`
  827. /// classify transient (slot-refresh recovery), failover episodes allow at
  828. /// most one caller-visible error, and atomic multi-key operations never span
  829. /// slots.
  830. #[tokio::test]
  831. async fn resilience_topology_failover_contracts_recover_with_single_error() -> TestResult {
  832. // `MOVED`/`ASK` (Cluster slot refresh) are transient: safe to retry an
  833. // idempotent read through them.
  834. for wire in [
  835. "-MOVED 1234 127.0.0.1:7000\r\n",
  836. "-ASK 5678 127.0.0.1:7001\r\n",
  837. "-TRYAGAIN Multiple keys request must be in the same slot\r\n",
  838. ] {
  839. let value = redis::parse_redis_value(wire.as_bytes())
  840. .map_err(|_| TestError::Body("wire parse failed".to_owned()))?;
  841. let server = match value {
  842. redis::Value::ServerError(error) => error,
  843. _ => return Err(TestError::Body("expected a server-error value".to_owned())),
  844. };
  845. let error = redis::RedisError::from(server);
  846. if classify_redis(&error) != ErrorClass::Transient {
  847. return Err(TestError::Body(format!("{wire:?} must classify transient")));
  848. }
  849. if !is_transient_redis(&error) {
  850. return Err(TestError::Body(
  851. "resilience predicate must agree".to_owned(),
  852. ));
  853. }
  854. if !d_oauth::store::may_retry_op(OpClass::IdempotentRead, &error) {
  855. return Err(TestError::Body(
  856. "idempotent reads must retry through slot moves".to_owned(),
  857. ));
  858. }
  859. }
  860. // A failover episode recovers transparently: transient faults retried
  861. // inside the budget surface zero caller-visible errors.
  862. let episode = FailoverEpisode::new();
  863. let attempts = Arc::new(AtomicU32::new(0));
  864. let probe = Arc::clone(&attempts);
  865. let moved = || {
  866. let value = redis::parse_redis_value(b"-MOVED 0 127.0.0.1:7000\r\n");
  867. match value {
  868. Ok(redis::Value::ServerError(error)) => redis::RedisError::from(error),
  869. _ => redis::RedisError::from((redis::ErrorKind::Io, "synthetic")),
  870. }
  871. };
  872. let budget = RetryBudget::new(8);
  873. let outcome = retry_idempotent(
  874. move || {
  875. let probe = Arc::clone(&probe);
  876. let moved = moved();
  877. async move {
  878. let attempt = probe.fetch_add(1, Ordering::SeqCst);
  879. if attempt < 2 {
  880. Err::<(), redis::RedisError>(moved)
  881. } else {
  882. Ok(())
  883. }
  884. }
  885. },
  886. is_transient_redis,
  887. &budget,
  888. 2,
  889. )
  890. .await;
  891. if outcome.is_err() {
  892. return Err(TestError::Body(
  893. "transient failover faults must recover".to_owned(),
  894. ));
  895. }
  896. if episode.caller_errors() != 0 || !episode.within_budget() {
  897. return Err(TestError::Body(
  898. "recovered episode must show no caller-visible error".to_owned(),
  899. ));
  900. }
  901. episode.record_caller_error();
  902. if !episode.within_budget() {
  903. return Err(TestError::Body(
  904. "one caller-visible error per episode is allowed".to_owned(),
  905. ));
  906. }
  907. // No atomic multi-key operation spans slots: the rotation key set shares
  908. // `{state}`, and the rate-limit keyspace never joins it.
  909. let store = Store::from_parts(
  910. "oauth:{state}:".to_owned(),
  911. "oauth:{rl}:".to_owned(),
  912. DurabilityMode::WaitAof,
  913. );
  914. let old_key = store.refresh_key("old");
  915. let new_key = store.refresh_key("new");
  916. let acc_key = store.access_key("acc");
  917. let set_key = store.grant_tokens_key("grant");
  918. let map_key = store.rotation_map_key("req");
  919. let rotation_keys = [
  920. old_key.as_str(),
  921. new_key.as_str(),
  922. acc_key.as_str(),
  923. set_key.as_str(),
  924. map_key.as_str(),
  925. ];
  926. if !is_single_slot(&rotation_keys) {
  927. return Err(TestError::Body(
  928. "rotation keys must share one slot".to_owned(),
  929. ));
  930. }
  931. let bucket_key = store.ratelimit_key("bucket");
  932. let split = [old_key.as_str(), bucket_key.as_str()];
  933. if is_single_slot(&split) {
  934. return Err(TestError::Body(
  935. "token + rate-limit keys must never share an atomic op".to_owned(),
  936. ));
  937. }
  938. Ok(())
  939. }
  940. /// Errors raised by the resilience harness itself (never rendered to a
  941. /// client; secrets never appear in these messages).
  942. #[derive(Debug, thiserror::Error)]
  943. enum TestError {
  944. /// A testcontainers operation failed.
  945. #[error("container error: {0}")]
  946. Containers(#[from] testcontainers::TestcontainersError),
  947. /// The crate configuration failed to load.
  948. #[error("config error: {0}")]
  949. Config(#[from] ConfigError),
  950. /// Application state failed to connect.
  951. #[error("state error: {0}")]
  952. State(#[from] StateError),
  953. /// The layered config source failed to load.
  954. #[error("config source error: {0}")]
  955. Source(#[from] ::config::ConfigError),
  956. /// A `docker kill` call failed.
  957. #[error("docker {0} failed")]
  958. Docker(String),
  959. /// An HTTP builder error while driving the router.
  960. #[error("http error: {0}")]
  961. Http(#[from] axum::http::Error),
  962. /// The in-process router returned its (uninhabited) error type.
  963. #[error("router error")]
  964. Router(#[from] std::convert::Infallible),
  965. /// A body/JSON error while reading a probe response.
  966. #[error("body error: {0}")]
  967. Body(String),
  968. /// A JSON error while reading a probe response.
  969. #[error("json error: {0}")]
  970. Json(#[from] serde_json::Error),
  971. /// A migration failure inside the test body.
  972. #[error("migration error: {0}")]
  973. Migrate(#[from] sqlx::migrate::MigrateError),
  974. /// A sqlx failure inside the test body.
  975. #[error("database error: {0}")]
  976. Database(#[from] sqlx::Error),
  977. /// A Redis failure inside the test body.
  978. #[error("redis error: {0}")]
  979. Redis(#[from] redis::RedisError),
  980. /// A store failure inside the test body.
  981. #[error("store error")]
  982. Store(#[from] StoreError),
  983. }