| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607 |
- //! `test(store)` suite: atomic Lua, tombstones, grant index, durability.
- //!
- //! Every test here spins its own single-node Redis 7.2 container with AOF
- //! enabled (mirroring `state::test_support`), so persistence, barriers, and
- //! concurrent compare-and-set run against a real server.
- use std::{
- sync::atomic::{AtomicU64, Ordering},
- time::Duration,
- };
- use testcontainers::{
- GenericImage, ImageExt,
- core::{IntoContainerPort, WaitFor},
- runners::AsyncRunner,
- };
- use super::{
- ErrorClass, OpClass, Store, StoreError, classify_redis, codes, dcr, device, durability_barrier,
- grants, hget_record, hset_record, may_retry_op, par, pending, ratelimit, retry_op, sessions,
- sha256_hex, tokens,
- };
- use crate::{
- config::{ConfigError, DurabilityMode, RedisConfig, TtlConfig, redis::RawRedisConfig},
- state::RedisConn,
- test_guard::ContainerGuard,
- };
- /// Test body result (no `unwrap`/`expect` under the deny lints).
- type TestResult = Result<(), TestError>;
- /// Errors raised by the store test scaffolding itself.
- #[derive(Debug, thiserror::Error)]
- enum TestError {
- /// A testcontainers operation failed.
- #[error("container error: {0}")]
- Containers(#[from] testcontainers::TestcontainersError),
- /// The Redis configuration was rejected.
- #[error("config error: {0}")]
- Config(#[from] ConfigError),
- /// A Redis command failed.
- #[error("redis error: {0}")]
- Redis(#[from] redis::RedisError),
- /// A store operation failed.
- #[error("store error: {0}")]
- Store(#[from] StoreError),
- /// A spawned task failed to join.
- #[error("task join failed")]
- Join,
- /// A test assertion observed an unexpected value.
- #[error("unexpected test value: {0}")]
- Unexpected(String),
- }
- static TEST_NONCE: AtomicU64 = AtomicU64::new(10_000);
- fn unique(label: &str) -> String {
- let n = TEST_NONCE.fetch_add(1, Ordering::Relaxed);
- format!("store-test-{n}-{label}")
- }
- /// Start a single-node Redis with AOF on plus extra server flags.
- ///
- /// The guard removes the container synchronously when the test drops it.
- async fn redis_container(extra: Vec<&str>) -> Result<(ContainerGuard, String), TestError> {
- let mut args = vec!["redis-server", "--appendonly", "yes"];
- args.extend(extra);
- let container = GenericImage::new("redis", "7.2-alpine")
- .with_exposed_port(6379.tcp())
- .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
- .with_cmd(args)
- .start()
- .await?;
- let guarded = ContainerGuard::new(container);
- let port = guarded.get_host_port_ipv4(6379).await?;
- Ok((guarded, format!("redis://127.0.0.1:{port}")))
- }
- /// Connect a [`RedisConn`] plus [`Store`] directly (startup assertions live in
- /// task 4; here the store contract is what is under test).
- ///
- /// Timeouts are generous on purpose: the blocking `WAITAOF` barrier may wait
- /// out a full `everysec` fsync cycle, so the client response timeout must sit
- /// well above the server-side barrier wait.
- async fn direct_store(
- url: &str,
- durability: DurabilityMode,
- ) -> Result<(RedisConn, Store), TestError> {
- let client = redis::Client::open(url)?;
- let manager_config = redis::aio::ConnectionManagerConfig::new()
- .set_response_timeout(Some(Duration::from_secs(10)))
- .set_connection_timeout(Some(Duration::from_secs(5)));
- let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
- let raw = RawRedisConfig {
- url: Some(url.to_owned()),
- durability: Some(durability),
- response_timeout_secs: 10,
- connect_timeout_secs: 5,
- ..Default::default()
- };
- let config = RedisConfig::from_raw(raw)?;
- Ok((RedisConn::Single(manager), Store::from_config(&config)))
- }
- fn code_record(client: &str, grant: &str) -> codes::CodeRecord {
- codes::CodeRecord {
- client_id: client.to_owned(),
- redirect_uri: "https://client.example.test/cb".to_owned(),
- scope: "orders:read".to_owned(),
- code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".to_owned(),
- subject: "user-1".to_owned(),
- grant_id: grant.to_owned(),
- cnf: String::new(),
- }
- }
- fn refresh_record(client: &str, grant: &str) -> tokens::RefreshRecord {
- tokens::RefreshRecord {
- client_id: client.to_owned(),
- grant_id: grant.to_owned(),
- scope: "orders:read".to_owned(),
- subject: "user-1".to_owned(),
- cnf: String::new(),
- }
- }
- fn access_record(client: &str, grant: &str) -> tokens::AccessRecord {
- tokens::AccessRecord {
- client_id: client.to_owned(),
- grant_id: grant.to_owned(),
- scope: "orders:read".to_owned(),
- subject: "user-1".to_owned(),
- cnf: String::new(),
- }
- }
- fn rotation_input(
- old: &str,
- client: &str,
- new_refresh: &str,
- new_access: &str,
- grant: &str,
- request_id: Option<String>,
- ) -> tokens::RotationInput {
- tokens::RotationInput {
- old_refresh_hash: old.to_owned(),
- expected_client_id: client.to_owned(),
- new_refresh_hash: new_refresh.to_owned(),
- new_access_hash: new_access.to_owned(),
- new_refresh: refresh_record(client, grant),
- new_access: access_record(client, grant),
- new_refresh_ttl: Duration::from_secs(3600),
- new_access_ttl: Duration::from_secs(300),
- request_id,
- }
- }
- /// Remaining TTL of a key in milliseconds (`-1` persistent, `-2` absent).
- async fn pttl_ms(conn: &mut RedisConn, key: &str) -> Result<i64, TestError> {
- let pttl: i64 = redis::cmd("PTTL").arg(key).query_async(conn).await?;
- Ok(pttl)
- }
- /// Assert a key carries a live TTL inside the design window `(0, cap]`.
- ///
- /// `cap` comes from the configured/design lifetime, never from the observed
- /// value. A non-positive TTL means the key is persistent or absent (a defect:
- /// the artifact would outlive its intended window); the upper bound can only
- /// drift downward while the test runs, so a slow run cannot flake it.
- fn assert_ttl_within(pttl: i64, cap: Duration) -> TestResult {
- if pttl <= 0 || pttl > cap.as_millis() as i64 {
- return Err(TestError::Unexpected(format!(
- "PTTL {pttl} ms must be within (0, {} ms]",
- cap.as_millis()
- )));
- }
- Ok(())
- }
- #[tokio::test]
- async fn store_code_consume_exactly_once() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let hash = unique("code");
- let grant = unique("grant");
- let client = unique("client");
- codes::issue(
- &mut conn,
- &store,
- &hash,
- &code_record(&client, &grant),
- Duration::from_secs(60),
- )
- .await?;
- let first = codes::consume(&mut conn, &store, &hash).await?;
- let second = codes::consume(&mut conn, &store, &hash).await?;
- if first
- .as_ref()
- .is_none_or(|record| record.client_id != client)
- {
- return Err(TestError::Unexpected(
- "first consume must return the record".to_owned(),
- ));
- }
- if second.is_some() {
- return Err(TestError::Unexpected(
- "second consume must return None".to_owned(),
- ));
- }
- Ok(())
- }
- #[tokio::test]
- async fn store_refresh_rotate_idempotent_replay() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let grant = unique("grant");
- let client = unique("client");
- let old = unique("old-refresh");
- let new_refresh = unique("new-refresh");
- let new_access = unique("new-access");
- let pepper = b"store-test-pepper";
- grants::create(
- &mut conn,
- &store,
- &grants::GrantRecord {
- grant_id: grant.clone(),
- client_id: client.clone(),
- subject: "user-1".to_owned(),
- scope: "orders:read".to_owned(),
- },
- Duration::from_secs(3600),
- )
- .await?;
- tokens::put_refresh(
- &mut conn,
- &store,
- &old,
- &refresh_record(&client, &grant),
- Duration::from_secs(3600),
- )
- .await?;
- // Same old token presented twice (deterministic request id): the SAME new
- // tokens come back, never a double rotation.
- let first = tokens::rotate(
- &mut conn,
- &store,
- pepper,
- &rotation_input(&old, &client, &new_refresh, &new_access, &grant, None),
- )
- .await?;
- let second = tokens::rotate(
- &mut conn,
- &store,
- pepper,
- &rotation_input(&old, &client, &new_refresh, &new_access, &grant, None),
- )
- .await?;
- match (first, second) {
- (
- tokens::RotateOutcome::Rotated {
- new_refresh_hash: first_r,
- new_access_hash: first_a,
- },
- tokens::RotateOutcome::Replay {
- new_refresh_hash: second_r,
- new_access_hash: second_a,
- },
- ) if first_r == new_refresh
- && first_a == new_access
- && second_r == new_refresh
- && second_a == new_access =>
- {
- Ok(())
- }
- _ => Err(TestError::Unexpected(
- "expected Rotated then Replay with identical tokens".to_owned(),
- )),
- }
- }
- #[tokio::test]
- async fn store_refresh_concurrent_cas_one_rotated_one_reuse() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let grant = unique("grant");
- let client = unique("client");
- let old = unique("old-refresh");
- let pepper = b"store-test-pepper";
- grants::create(
- &mut conn,
- &store,
- &grants::GrantRecord {
- grant_id: grant.clone(),
- client_id: client.clone(),
- subject: "user-1".to_owned(),
- scope: "orders:read".to_owned(),
- },
- Duration::from_secs(3600),
- )
- .await?;
- tokens::put_refresh(
- &mut conn,
- &store,
- &old,
- &refresh_record(&client, &grant),
- Duration::from_secs(3600),
- )
- .await?;
- // Two concurrent rotations with DISTINCT request ids (two spend attempts):
- // exactly one rotates, the other observes reuse.
- let first_tag = unique("race-refresh");
- let second_tag = unique("race-refresh");
- let first = tokio::spawn({
- let conn = conn.clone();
- let store = store.clone();
- let grant = grant.clone();
- let client = client.clone();
- let old = old.clone();
- async move {
- let mut conn = conn;
- let input = rotation_input(
- &old,
- &client,
- &first_tag,
- &format!("{first_tag}-access"),
- &grant,
- Some("req-1".to_owned()),
- );
- tokens::rotate(&mut conn, &store, pepper, &input).await
- }
- });
- let second = tokio::spawn({
- let conn = conn.clone();
- let store = store.clone();
- async move {
- let mut conn = conn;
- let input = rotation_input(
- &old,
- &client,
- &second_tag,
- &format!("{second_tag}-access"),
- &grant,
- Some("req-2".to_owned()),
- );
- tokens::rotate(&mut conn, &store, pepper, &input).await
- }
- });
- let first = first.await.map_err(|_| TestError::Join)??;
- let second = second.await.map_err(|_| TestError::Join)??;
- let rotated = u8::from(matches!(first, tokens::RotateOutcome::Rotated { .. }))
- + u8::from(matches!(second, tokens::RotateOutcome::Rotated { .. }));
- let reused = u8::from(matches!(first, tokens::RotateOutcome::Reused { .. }))
- + u8::from(matches!(second, tokens::RotateOutcome::Reused { .. }));
- if rotated == 1 && reused == 1 {
- Ok(())
- } else {
- Err(TestError::Unexpected(
- "expected exactly one Rotated and one Reused".to_owned(),
- ))
- }
- }
- #[tokio::test]
- async fn store_refresh_reuse_without_mapping_cascades() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let grant = unique("grant");
- let client = unique("client");
- let old = unique("old-refresh");
- let new_refresh = unique("new-refresh");
- let new_access = unique("new-access");
- let pepper = b"store-test-pepper";
- grants::create(
- &mut conn,
- &store,
- &grants::GrantRecord {
- grant_id: grant.clone(),
- client_id: client.clone(),
- subject: "user-1".to_owned(),
- scope: "orders:read".to_owned(),
- },
- Duration::from_secs(3600),
- )
- .await?;
- tokens::put_refresh(
- &mut conn,
- &store,
- &old,
- &refresh_record(&client, &grant),
- Duration::from_secs(3600),
- )
- .await?;
- let first = tokens::rotate(
- &mut conn,
- &store,
- pepper,
- &rotation_input(&old, &client, &new_refresh, &new_access, &grant, None),
- )
- .await?;
- if !matches!(first, tokens::RotateOutcome::Rotated { .. }) {
- return Err(TestError::Unexpected(
- "first rotation must apply".to_owned(),
- ));
- }
- // Expire the grace mapping (grace window elapsed), then replay the old
- // token: reuse, and the whole family dies.
- let request_id = tokens::derive_rotation_id(pepper, &old)?;
- let map_key = store.rotation_map_key(&request_id);
- let _: () = redis::cmd("DEL")
- .arg(&map_key)
- .query_async(&mut conn)
- .await?;
- let replay = tokens::rotate(
- &mut conn,
- &store,
- pepper,
- &rotation_input(
- &old,
- &client,
- &unique("other"),
- &unique("other-a"),
- &grant,
- None,
- ),
- )
- .await?;
- match replay {
- tokens::RotateOutcome::Reused { rotated_to } if rotated_to == new_refresh => {}
- _ => {
- return Err(TestError::Unexpected(
- "expected Reused pointing at the rotated token".to_owned(),
- ));
- }
- }
- if tokens::get_refresh(&mut conn, &store, &new_refresh)
- .await?
- .is_some()
- {
- return Err(TestError::Unexpected(
- "cascade must delete the rotated refresh token".to_owned(),
- ));
- }
- if tokens::get_access(&mut conn, &store, &new_access)
- .await?
- .is_some()
- {
- return Err(TestError::Unexpected(
- "cascade must delete the rotated access token".to_owned(),
- ));
- }
- if grants::get(&mut conn, &store, &grant).await?.is_some() {
- return Err(TestError::Unexpected(
- "cascade must delete the grant record".to_owned(),
- ));
- }
- if !grants::members(&mut conn, &store, &grant).await?.is_empty() {
- return Err(TestError::Unexpected(
- "cascade must empty the grant set".to_owned(),
- ));
- }
- Ok(())
- }
- #[tokio::test]
- async fn store_grant_revoke_cascade() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let grant = unique("grant");
- let client = unique("client");
- let refresh = unique("refresh");
- let access = unique("access");
- grants::create(
- &mut conn,
- &store,
- &grants::GrantRecord {
- grant_id: grant.clone(),
- client_id: client.clone(),
- subject: "user-1".to_owned(),
- scope: "orders:read".to_owned(),
- },
- Duration::from_secs(3600),
- )
- .await?;
- tokens::put_refresh(
- &mut conn,
- &store,
- &refresh,
- &refresh_record(&client, &grant),
- Duration::from_secs(3600),
- )
- .await?;
- tokens::put_access(
- &mut conn,
- &store,
- &access,
- &access_record(&client, &grant),
- Duration::from_secs(300),
- )
- .await?;
- if grants::members(&mut conn, &store, &grant).await?.len() != 2 {
- return Err(TestError::Unexpected(
- "grant set must index both tokens".to_owned(),
- ));
- }
- let deleted = grants::revoke(&mut conn, &store, &grant).await?;
- if deleted < 2 {
- return Err(TestError::Unexpected(
- "revoke must delete both token keys".to_owned(),
- ));
- }
- if tokens::get_refresh(&mut conn, &store, &refresh)
- .await?
- .is_some()
- || tokens::get_access(&mut conn, &store, &access)
- .await?
- .is_some()
- {
- return Err(TestError::Unexpected(
- "revoke must delete member records".to_owned(),
- ));
- }
- // Unknown grants revoke to zero (idempotent).
- if grants::revoke(&mut conn, &store, &unique("no-such-grant")).await? != 0 {
- return Err(TestError::Unexpected(
- "unknown grant must revoke to zero".to_owned(),
- ));
- }
- Ok(())
- }
- #[tokio::test]
- async fn store_tombstone_detectable_after_rotation() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let grant = unique("grant");
- let client = unique("client");
- let old = unique("old-refresh");
- let new_refresh = unique("new-refresh");
- let new_access = unique("new-access");
- let pepper = b"store-test-pepper";
- grants::create(
- &mut conn,
- &store,
- &grants::GrantRecord {
- grant_id: grant.clone(),
- client_id: client.clone(),
- subject: "user-1".to_owned(),
- scope: "orders:read".to_owned(),
- },
- Duration::from_secs(3600),
- )
- .await?;
- tokens::put_refresh(
- &mut conn,
- &store,
- &old,
- &refresh_record(&client, &grant),
- Duration::from_secs(3600),
- )
- .await?;
- // Live token: no tombstone; unknown hash: no tombstone.
- if tokens::tombstone_of(&mut conn, &store, &old)
- .await?
- .is_some()
- {
- return Err(TestError::Unexpected(
- "live token must not look tombstoned".to_owned(),
- ));
- }
- if tokens::tombstone_of(&mut conn, &store, &unique("absent"))
- .await?
- .is_some()
- {
- return Err(TestError::Unexpected(
- "absent token must not look tombstoned".to_owned(),
- ));
- }
- let outcome = tokens::rotate(
- &mut conn,
- &store,
- pepper,
- &rotation_input(&old, &client, &new_refresh, &new_access, &grant, None),
- )
- .await?;
- if !matches!(outcome, tokens::RotateOutcome::Rotated { .. }) {
- return Err(TestError::Unexpected("rotation must apply".to_owned()));
- }
- match tokens::tombstone_of(&mut conn, &store, &old).await? {
- Some(rotated_to) if rotated_to == new_refresh => Ok(()),
- _ => Err(TestError::Unexpected(
- "tombstone must point at the new token".to_owned(),
- )),
- }
- }
- #[tokio::test]
- async fn store_persistence_survives_restart() -> TestResult {
- let (container, url) = redis_container(vec!["--appendfsync", "always"]).await?;
- let grant = unique("grant");
- let client = unique("client");
- let code_hash = unique("code");
- let refresh_hash = unique("refresh");
- let jti = unique("jti");
- {
- let (mut conn, store) = direct_store(&url, DurabilityMode::AppendFsyncAlways).await?;
- codes::issue(
- &mut conn,
- &store,
- &code_hash,
- &code_record(&client, &grant),
- Duration::from_secs(600),
- )
- .await?;
- tokens::put_refresh(
- &mut conn,
- &store,
- &refresh_hash,
- &refresh_record(&client, &grant),
- Duration::from_secs(3600),
- )
- .await?;
- if !dcr::claim_dpop_jti(&mut conn, &store, &jti, Duration::from_secs(600)).await? {
- return Err(TestError::Unexpected("marker claim must win".to_owned()));
- }
- }
- container.stop_with_timeout(Some(10)).await?;
- container.start().await?;
- let port = container.get_host_port_ipv4(6379).await?;
- let (mut conn, store) = direct_store(
- &format!("redis://127.0.0.1:{port}"),
- DurabilityMode::AppendFsyncAlways,
- )
- .await?;
- let code = codes::consume(&mut conn, &store, &code_hash).await?;
- if code
- .as_ref()
- .is_none_or(|record| record.client_id != client)
- {
- return Err(TestError::Unexpected(
- "code must survive the restart".to_owned(),
- ));
- }
- let refresh = tokens::get_refresh(&mut conn, &store, &refresh_hash).await?;
- if refresh
- .as_ref()
- .is_none_or(|record| record.grant_id != grant)
- {
- return Err(TestError::Unexpected(
- "refresh record must survive the restart".to_owned(),
- ));
- }
- if dcr::claim_dpop_jti(&mut conn, &store, &jti, Duration::from_secs(600)).await? {
- return Err(TestError::Unexpected(
- "replay marker must survive the restart".to_owned(),
- ));
- }
- Ok(())
- }
- #[tokio::test]
- async fn store_durability_barrier_modes() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- // Single-node: WAITAOF barrier succeeds after a write.
- let (mut conn, waitaof) = direct_store(&url, DurabilityMode::WaitAof).await?;
- durability_barrier(&mut conn, &waitaof).await?;
- // `WAIT 1` on a replica-less node acknowledges zero: fail closed.
- let wait = Store::from_parts(
- "oauth:{state}:".to_owned(),
- "oauth:{rl}:".to_owned(),
- DurabilityMode::Wait,
- );
- if durability_barrier(&mut conn, &wait).await.is_ok() {
- return Err(TestError::Unexpected(
- "WAIT without replicas must fail closed".to_owned(),
- ));
- }
- // `appendfsync always` assertion fails on an everysec node: fail closed.
- let always = Store::from_parts(
- "oauth:{state}:".to_owned(),
- "oauth:{rl}:".to_owned(),
- DurabilityMode::AppendFsyncAlways,
- );
- if durability_barrier(&mut conn, &always).await.is_ok() {
- return Err(TestError::Unexpected(
- "everysec node must fail the always barrier".to_owned(),
- ));
- }
- // ...and succeeds where the server actually fsyncs always.
- let (_always_container, always_url) = redis_container(vec!["--appendfsync", "always"]).await?;
- let (mut always_conn, _) = direct_store(&always_url, DurabilityMode::AppendFsyncAlways).await?;
- durability_barrier(&mut always_conn, &always).await?;
- Ok(())
- }
- #[tokio::test]
- async fn store_ratelimit_fixed_window() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let bucket = unique("bucket");
- let window = Duration::from_secs(60);
- for _ in 0..3 {
- let decision = ratelimit::check_and_incr(&mut conn, &store, &bucket, window, 3).await?;
- if !decision.allowed {
- return Err(TestError::Unexpected(
- "first three hits must be allowed".to_owned(),
- ));
- }
- }
- let limited = ratelimit::check_and_incr(&mut conn, &store, &bucket, window, 3).await?;
- if limited.allowed || limited.count != 4 || limited.retry_after_secs == 0 {
- return Err(TestError::Unexpected(
- "fourth hit must be limited with Retry-After".to_owned(),
- ));
- }
- // Buckets are hashed under the `{rl}` keyspace, never raw labels.
- let key = store.ratelimit_key(&ratelimit::hash_bucket(&bucket));
- if !key.contains("{rl}") || key.contains(&bucket) {
- return Err(TestError::Unexpected(
- "counter key must be hashed and {rl}-tagged".to_owned(),
- ));
- }
- if ratelimit::hash_bucket(&bucket) != ratelimit::hash_bucket(&bucket) {
- return Err(TestError::Unexpected(
- "bucket hashing must be deterministic".to_owned(),
- ));
- }
- Ok(())
- }
- #[tokio::test]
- async fn store_replay_markers_first_claim_wins() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let jti = unique("jti");
- let assertion = unique("assertion");
- if !dcr::claim_dpop_jti(&mut conn, &store, &jti, Duration::from_secs(300)).await? {
- return Err(TestError::Unexpected(
- "first DPoP claim must win".to_owned(),
- ));
- }
- if dcr::claim_dpop_jti(&mut conn, &store, &jti, Duration::from_secs(300)).await? {
- return Err(TestError::Unexpected(
- "second DPoP claim must lose".to_owned(),
- ));
- }
- if !dcr::claim_client_assertion_jti(&mut conn, &store, &assertion, Duration::from_secs(300))
- .await?
- {
- return Err(TestError::Unexpected(
- "first assertion claim must win".to_owned(),
- ));
- }
- if dcr::claim_client_assertion_jti(&mut conn, &store, &assertion, Duration::from_secs(300))
- .await?
- {
- return Err(TestError::Unexpected(
- "second assertion claim must lose".to_owned(),
- ));
- }
- Ok(())
- }
- #[tokio::test]
- async fn store_sessions_and_login_counters() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let old_session = unique("session");
- let new_session = unique("session");
- let record = sessions::SessionRecord {
- subject: "user-1".to_owned(),
- mfa_verified: true,
- restricted: false,
- };
- sessions::put(
- &mut conn,
- &store,
- &old_session,
- &record,
- Duration::from_secs(3600),
- )
- .await?;
- sessions::rotate(
- &mut conn,
- &store,
- &old_session,
- &new_session,
- &record,
- Duration::from_secs(3600),
- )
- .await?;
- if sessions::get(&mut conn, &store, &old_session)
- .await?
- .is_some()
- {
- return Err(TestError::Unexpected(
- "old session must be gone after rotate".to_owned(),
- ));
- }
- let current = sessions::get(&mut conn, &store, &new_session).await?;
- if current.as_ref().is_none_or(|session| !session.mfa_verified) {
- return Err(TestError::Unexpected(
- "new session must verify MFA".to_owned(),
- ));
- }
- sessions::destroy(&mut conn, &store, &new_session).await?;
- if sessions::get(&mut conn, &store, &new_session)
- .await?
- .is_some()
- {
- return Err(TestError::Unexpected(
- "destroyed session must be gone".to_owned(),
- ));
- }
- let account = unique("alice@example.test");
- let first =
- sessions::record_failed_login(&mut conn, &store, &account, Duration::from_secs(300)).await?;
- let second =
- sessions::record_failed_login(&mut conn, &store, &account, Duration::from_secs(300)).await?;
- if (first, second) != (1, 2) {
- return Err(TestError::Unexpected(
- "failed-login counter must increment".to_owned(),
- ));
- }
- if sessions::failed_login_count(&mut conn, &store, &account).await? != 2 {
- return Err(TestError::Unexpected(
- "failed-login count must read back".to_owned(),
- ));
- }
- sessions::clear_failed_logins(&mut conn, &store, &account).await?;
- if sessions::failed_login_count(&mut conn, &store, &account).await? != 0 {
- return Err(TestError::Unexpected(
- "cleared counter must read zero".to_owned(),
- ));
- }
- Ok(())
- }
- #[tokio::test]
- async fn store_device_par_and_dcr_round_trip() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let client = unique("client");
- // Device flow: put, poll, approve, consume exactly once.
- let device_hash = unique("device");
- let user_code = unique("user-code");
- device::put(
- &mut conn,
- &store,
- &device_hash,
- &user_code,
- &device::DeviceRecord {
- client_id: client.clone(),
- scope: "orders:read".to_owned(),
- status: device::DeviceStatus::Pending,
- subject: String::new(),
- grant_id: String::new(),
- },
- Duration::from_secs(600),
- )
- .await?;
- if device::resolve_user_code(&mut conn, &store, &user_code)
- .await?
- .as_deref()
- != Some(device_hash.as_str())
- {
- return Err(TestError::Unexpected(
- "user code must resolve to the device".to_owned(),
- ));
- }
- let grant = unique("grant");
- if !device::decide(
- &mut conn,
- &store,
- &device_hash,
- device::DeviceStatus::Approved,
- "user-1",
- &grant,
- )
- .await?
- {
- return Err(TestError::Unexpected("approve must apply".to_owned()));
- }
- let approved = device::poll(&mut conn, &store, &device_hash).await?;
- if approved
- .as_ref()
- .is_none_or(|record| record.status != device::DeviceStatus::Approved)
- {
- return Err(TestError::Unexpected(
- "poll must observe approval".to_owned(),
- ));
- }
- if device::consume(&mut conn, &store, &device_hash)
- .await?
- .is_none()
- {
- return Err(TestError::Unexpected(
- "first device consume must return the record".to_owned(),
- ));
- }
- if device::consume(&mut conn, &store, &device_hash)
- .await?
- .is_some()
- {
- return Err(TestError::Unexpected(
- "second device consume must return None".to_owned(),
- ));
- }
- // PAR: put, consume exactly once.
- let request_id = unique("par");
- par::put(
- &mut conn,
- &store,
- &request_id,
- &par::ParRecord {
- client_id: client.clone(),
- request: "{\"response_type\":\"code\"}".to_owned(),
- },
- Duration::from_secs(90),
- )
- .await?;
- if par::consume(&mut conn, &store, &request_id)
- .await?
- .is_none()
- {
- return Err(TestError::Unexpected(
- "first PAR consume must return the record".to_owned(),
- ));
- }
- if par::consume(&mut conn, &store, &request_id)
- .await?
- .is_some()
- {
- return Err(TestError::Unexpected(
- "second PAR consume must return None".to_owned(),
- ));
- }
- // DCR initial-access token: two uses, then exhausted.
- let iat = unique("iat");
- dcr::issue(
- &mut conn,
- &store,
- &iat,
- &dcr::InitialAccessRecord {
- uses_remaining: 2,
- label: "ops-token".to_owned(),
- },
- Duration::from_secs(3600),
- )
- .await?;
- let first_use = dcr::consume_one_use(&mut conn, &store, &iat).await?;
- if first_use
- .as_ref()
- .is_none_or(|record| record.uses_remaining != 2)
- {
- return Err(TestError::Unexpected(
- "first use must report the pre-decrement budget".to_owned(),
- ));
- }
- if dcr::consume_one_use(&mut conn, &store, &iat)
- .await?
- .is_none()
- {
- return Err(TestError::Unexpected(
- "second (final) use must succeed".to_owned(),
- ));
- }
- if dcr::consume_one_use(&mut conn, &store, &iat)
- .await?
- .is_some()
- {
- return Err(TestError::Unexpected(
- "exhausted token must return None".to_owned(),
- ));
- }
- Ok(())
- }
- /// docs/REDIS.md: `oauth:{state}:code:<hash>` carries the authorization-code
- /// lifetime, capped at [`crate::protocol::code::CODE_TTL_CAP`] (60 s).
- #[tokio::test]
- async fn store_code_ttl_within_configured_window() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let ttl = TtlConfig::default();
- let hash = unique("code");
- codes::issue(
- &mut conn,
- &store,
- &hash,
- &code_record(&unique("client"), &unique("grant")),
- ttl.code,
- )
- .await?;
- let cap = ttl.code.min(crate::protocol::code::CODE_TTL_CAP);
- assert_ttl_within(pttl_ms(&mut conn, &store.code_key(&hash)).await?, cap)
- }
- /// docs/REDIS.md: the PAR handle lives under `ttl.par` (90 s) and both the
- /// device-code record and its user-code mapping under `ttl.device` (600 s).
- #[tokio::test]
- async fn store_par_device_and_user_code_ttl_within_configured_windows() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let ttl = TtlConfig::default();
- let client = unique("client");
- let request_id = unique("par");
- par::put(
- &mut conn,
- &store,
- &request_id,
- &par::ParRecord {
- client_id: client.clone(),
- request: "{\"response_type\":\"code\"}".to_owned(),
- },
- ttl.par,
- )
- .await?;
- assert_ttl_within(
- pttl_ms(&mut conn, &store.par_key(&request_id)).await?,
- ttl.par,
- )?;
- let device_hash = unique("device");
- let user_code = unique("user-code");
- device::put(
- &mut conn,
- &store,
- &device_hash,
- &user_code,
- &device::DeviceRecord {
- client_id: client,
- scope: "orders:read".to_owned(),
- status: device::DeviceStatus::Pending,
- subject: String::new(),
- grant_id: String::new(),
- },
- ttl.device,
- )
- .await?;
- assert_ttl_within(
- pttl_ms(&mut conn, &store.device_key(&device_hash)).await?,
- ttl.device,
- )?;
- assert_ttl_within(
- pttl_ms(&mut conn, &store.user_code_key(&user_code)).await?,
- ttl.device,
- )
- }
- /// docs/REDIS.md: the DCR initial-access-token record lives under the CLI
- /// `--ttl`, defaulting to the configured `ttl.dcr_initial_access` (24 h).
- #[tokio::test]
- async fn store_dcr_initial_access_ttl_within_configured_window() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let ttl = TtlConfig::default();
- let iat = unique("iat");
- dcr::issue(
- &mut conn,
- &store,
- &iat,
- &dcr::InitialAccessRecord {
- uses_remaining: 2,
- label: "ops-token".to_owned(),
- },
- ttl.dcr_initial_access,
- )
- .await?;
- assert_ttl_within(
- pttl_ms(&mut conn, &store.dcr_iat_key(&iat)).await?,
- ttl.dcr_initial_access,
- )
- }
- /// docs/REDIS.md: the rotation idempotency mapping is bounded by
- /// `min(900 s, refresh TTL)`, the old refresh record becomes a tombstone with
- /// the refresh lifetime, and the per-grant token index lives with the grant.
- #[tokio::test]
- async fn store_rotation_grace_tombstone_and_grant_index_ttl() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let grant = unique("grant");
- let client = unique("client");
- let old = unique("old-refresh");
- let new_refresh = unique("new-refresh");
- let new_access = unique("new-access");
- let pepper = b"store-test-pepper";
- let refresh_ttl = Duration::from_secs(3600);
- let access_ttl = Duration::from_secs(300);
- let rotation_grace = Duration::from_secs(900);
- grants::create(
- &mut conn,
- &store,
- &grants::GrantRecord {
- grant_id: grant.clone(),
- client_id: client.clone(),
- subject: "user-1".to_owned(),
- scope: "orders:read".to_owned(),
- },
- refresh_ttl,
- )
- .await?;
- assert_ttl_within(
- pttl_ms(&mut conn, &store.grant_key(&grant)).await?,
- refresh_ttl,
- )?;
- // The index is born with the access member, then extended by the refresh
- // member: it must outlive the short access TTL and stay within the grant's.
- tokens::put_access(
- &mut conn,
- &store,
- &unique("access-seed"),
- &access_record(&client, &grant),
- access_ttl,
- )
- .await?;
- tokens::put_refresh(
- &mut conn,
- &store,
- &old,
- &refresh_record(&client, &grant),
- refresh_ttl,
- )
- .await?;
- let index_key = store.grant_tokens_key(&grant);
- let index_ttl = pttl_ms(&mut conn, &index_key).await?;
- if index_ttl <= access_ttl.as_millis() as i64 {
- return Err(TestError::Unexpected(
- "grant index must outlive the access-token TTL".to_owned(),
- ));
- }
- assert_ttl_within(index_ttl, refresh_ttl)?;
- // Simulate the index having expired: the rotation script must re-arm it
- // rather than recreate it persistent.
- let _: () = redis::cmd("DEL")
- .arg(&index_key)
- .query_async(&mut conn)
- .await?;
- let outcome = tokens::rotate(
- &mut conn,
- &store,
- pepper,
- &rotation_input(&old, &client, &new_refresh, &new_access, &grant, None),
- )
- .await?;
- if !matches!(outcome, tokens::RotateOutcome::Rotated { .. }) {
- return Err(TestError::Unexpected("rotation must apply".to_owned()));
- }
- assert_ttl_within(pttl_ms(&mut conn, &index_key).await?, refresh_ttl)?;
- let request_id = tokens::derive_rotation_id(pepper, &old)?;
- assert_ttl_within(
- pttl_ms(&mut conn, &store.rotation_map_key(&request_id)).await?,
- rotation_grace.min(refresh_ttl),
- )?;
- assert_ttl_within(
- pttl_ms(&mut conn, &store.refresh_key(&old)).await?,
- refresh_ttl,
- )
- }
- /// docs/REDIS.md: `oauth:{state}:session:<id>` lives under `ttl.session`
- /// (12 h); a presession is the same record before login binds a subject.
- #[tokio::test]
- async fn store_session_and_presession_ttl_within_configured_window() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let session_ttl = TtlConfig::default().session;
- let session_id = unique("session");
- sessions::put(
- &mut conn,
- &store,
- &session_id,
- &sessions::SessionRecord {
- subject: "user-1".to_owned(),
- mfa_verified: true,
- restricted: false,
- },
- session_ttl,
- )
- .await?;
- assert_ttl_within(
- pttl_ms(&mut conn, &store.session_key(&session_id)).await?,
- session_ttl,
- )?;
- let presession_id = unique("presession");
- sessions::put(
- &mut conn,
- &store,
- &presession_id,
- &sessions::SessionRecord {
- subject: String::new(),
- mfa_verified: false,
- restricted: false,
- },
- session_ttl,
- )
- .await?;
- assert_ttl_within(
- pttl_ms(&mut conn, &store.session_key(&presession_id)).await?,
- session_ttl,
- )
- }
- /// docs/REDIS.md: `oauth:{state}:pending:<id>` is short, request-scoped
- /// (`routes::PENDING_TTL`, 300 s).
- #[tokio::test]
- async fn store_pending_auth_ttl_within_request_window() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let request_id = unique("pending");
- pending::put(
- &mut conn,
- &store,
- &request_id,
- &pending::PendingAuth {
- client_id: unique("client"),
- redirect_uri: "https://client.example.test/cb".to_owned(),
- scope: "orders:read".to_owned(),
- code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".to_owned(),
- state: Some("state-xyz".to_owned()),
- dpop_jkt: None,
- iss: "https://as.example.test".to_owned(),
- post_logout_uri: None,
- },
- crate::routes::PENDING_TTL,
- )
- .await?;
- assert_ttl_within(
- pttl_ms(&mut conn, &store.token_key("pending", &request_id)).await?,
- crate::routes::PENDING_TTL,
- )
- }
- /// docs/REDIS.md: the failed-login counter lives under the lockout window
- /// (`ui::login::LOCKOUT_WINDOW`, 15 min) and keys a hash of the account label.
- #[tokio::test]
- async fn store_login_failure_counter_ttl_within_lockout_window() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let window = crate::ui::login::LOCKOUT_WINDOW;
- let account = unique("alice@example.test");
- sessions::record_failed_login(&mut conn, &store, &account, window).await?;
- let key = store.login_fail_key(&sha256_hex(account.as_bytes()));
- assert_ttl_within(pttl_ms(&mut conn, &key).await?, window)
- }
- /// docs/REDIS.md: the DPoP replay marker is bounded by the proof window
- /// ([`crate::jose::dpop::DPOP_JTI_TTL`]); the `private_key_jwt` marker is
- /// bounded by the assertion's remaining validity (at most 360 s).
- #[tokio::test]
- async fn store_replay_marker_ttl_within_proof_window() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let dpop_ttl = crate::jose::dpop::DPOP_JTI_TTL;
- let jti = unique("jti");
- if !dcr::claim_dpop_jti(&mut conn, &store, &jti, dpop_ttl).await? {
- return Err(TestError::Unexpected(
- "first DPoP claim must win".to_owned(),
- ));
- }
- assert_ttl_within(
- pttl_ms(&mut conn, &store.dpop_jti_key(&jti)).await?,
- dpop_ttl,
- )?;
- let assertion_ttl = Duration::from_secs(300);
- let assertion = unique("assertion");
- if !dcr::claim_client_assertion_jti(&mut conn, &store, &assertion, assertion_ttl).await? {
- return Err(TestError::Unexpected(
- "first assertion claim must win".to_owned(),
- ));
- }
- assert_ttl_within(
- pttl_ms(&mut conn, &store.client_assertion_jti_key(&assertion)).await?,
- assertion_ttl,
- )
- }
- #[test]
- fn store_keys_share_hash_tag_and_keyspaces_split() -> TestResult {
- let store = Store::from_parts(
- "oauth:{state}:".to_owned(),
- "oauth:{rl}:".to_owned(),
- DurabilityMode::WaitAof,
- );
- store.assert_keyspace_separation()?;
- for key in [
- store.code_key("h"),
- store.access_key("h"),
- store.refresh_key("h"),
- store.grant_key("g"),
- store.grant_tokens_key("g"),
- store.session_key("s"),
- store.par_key("p"),
- store.device_key("d"),
- store.user_code_key("u"),
- store.dcr_iat_key("i"),
- store.dpop_jti_key("j"),
- store.client_assertion_jti_key("j"),
- store.rotation_map_key("r"),
- store.login_fail_key("l"),
- ] {
- if !key.contains("{state}") {
- return Err(TestError::Unexpected(
- "token key must carry the {state} tag".to_owned(),
- ));
- }
- }
- if !store.ratelimit_key("b").contains("{rl}") {
- return Err(TestError::Unexpected(
- "counter key must carry the {rl} tag".to_owned(),
- ));
- }
- // Colliding prefixes are rejected so counters can never evict tokens.
- let colliding = Store::from_parts(
- "oauth:{state}:".to_owned(),
- "oauth:{state}:".to_owned(),
- DurabilityMode::WaitAof,
- );
- if colliding.assert_keyspace_separation().is_ok() {
- return Err(TestError::Unexpected(
- "colliding keyspaces must be rejected".to_owned(),
- ));
- }
- Ok(())
- }
- #[test]
- fn store_durability_selected_from_topology() -> TestResult {
- // Single-node topologies must barrier on WAITAOF, never WAIT.
- let single = RawRedisConfig {
- url: Some("redis://127.0.0.1:6379".to_owned()),
- durability: Some(DurabilityMode::Wait),
- ..Default::default()
- };
- if RedisConfig::from_raw(single).is_ok() {
- return Err(TestError::Unexpected(
- "single-node WAIT must be rejected".to_owned(),
- ));
- }
- let single = RawRedisConfig {
- url: Some("redis://127.0.0.1:6379".to_owned()),
- durability: Some(DurabilityMode::WaitAof),
- ..Default::default()
- };
- let config = RedisConfig::from_raw(single)?;
- if Store::from_config(&config).durability() != DurabilityMode::WaitAof {
- return Err(TestError::Unexpected(
- "single-node store must barrier on WAITAOF".to_owned(),
- ));
- }
- Ok(())
- }
- #[test]
- fn store_rotation_id_deterministic_per_old_token() -> TestResult {
- let pepper = b"store-test-pepper";
- let first = tokens::derive_rotation_id(pepper, "old-hash")?;
- let second = tokens::derive_rotation_id(pepper, "old-hash")?;
- if first != second || first.is_empty() {
- return Err(TestError::Unexpected(
- "rotation id must be deterministic".to_owned(),
- ));
- }
- if tokens::derive_rotation_id(pepper, "other-hash")? == first {
- return Err(TestError::Unexpected(
- "rotation id must differ per token".to_owned(),
- ));
- }
- if tokens::derive_rotation_id(b"other-pepper", "old-hash")? == first {
- return Err(TestError::Unexpected(
- "rotation id must differ per pepper".to_owned(),
- ));
- }
- Ok(())
- }
- #[test]
- fn store_error_classification_and_retry_scope() -> TestResult {
- let transient_io = redis::RedisError::from(std::io::Error::new(
- std::io::ErrorKind::TimedOut,
- "timed out",
- ));
- let loading = redis::RedisError::from((
- redis::ErrorKind::Server(redis::ServerErrorKind::BusyLoading),
- "busy",
- ));
- let try_again = redis::RedisError::from((
- redis::ErrorKind::Server(redis::ServerErrorKind::TryAgain),
- "retry",
- ));
- let master_down = redis::RedisError::from((
- redis::ErrorKind::Server(redis::ServerErrorKind::MasterDown),
- "down",
- ));
- let cluster_down = redis::RedisError::from((
- redis::ErrorKind::Server(redis::ServerErrorKind::ClusterDown),
- "down",
- ));
- for error in [
- &transient_io,
- &loading,
- &try_again,
- &master_down,
- &cluster_down,
- ] {
- if classify_redis(error) != ErrorClass::Transient {
- return Err(TestError::Unexpected(
- "expected a transient class".to_owned(),
- ));
- }
- if !may_retry_op(OpClass::IdempotentRead, error)
- || !may_retry_op(OpClass::ConditionalWrite, error)
- || may_retry_op(OpClass::NonIdempotent, error)
- {
- return Err(TestError::Unexpected(
- "retry scope must exclude non-idempotent ops".to_owned(),
- ));
- }
- }
- let cross_slot = redis::RedisError::from((
- redis::ErrorKind::Server(redis::ServerErrorKind::CrossSlot),
- "slot",
- ));
- let readonly = redis::RedisError::from((
- redis::ErrorKind::Server(redis::ServerErrorKind::ReadOnly),
- "ro",
- ));
- let type_error = redis::RedisError::from((redis::ErrorKind::UnexpectedReturnType, "type"));
- let auth = redis::RedisError::from((redis::ErrorKind::AuthenticationFailed, "auth"));
- for error in [&cross_slot, &readonly, &type_error, &auth] {
- if classify_redis(error) != ErrorClass::Permanent {
- return Err(TestError::Unexpected(
- "expected a permanent class".to_owned(),
- ));
- }
- if may_retry_op(OpClass::IdempotentRead, error) {
- return Err(TestError::Unexpected(
- "permanent errors must never retry".to_owned(),
- ));
- }
- }
- // Server-extension codes (`OOM`, ...) classify by code: fail closed.
- let oom_value = redis::parse_redis_value(b"-OOM command not allowed\r\n");
- match oom_value {
- Ok(redis::Value::ServerError(server)) => {
- let error = redis::RedisError::from(server);
- if classify_redis(&error) != ErrorClass::Permanent {
- return Err(TestError::Unexpected("OOM must fail closed".to_owned()));
- }
- }
- _ => {
- return Err(TestError::Unexpected(
- "expected an OOM server error value".to_owned(),
- ));
- }
- }
- Ok(())
- }
- #[tokio::test]
- async fn store_retry_budget_bounds_idempotent_reads() -> TestResult {
- use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
- static ATTEMPTS: AtomicU32 = AtomicU32::new(0);
- ATTEMPTS.store(0, AtomicOrdering::Relaxed);
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut _conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let outcome: Result<(), redis::RedisError> = retry_op!(&mut _conn, store.retry_budget(), async {
- ATTEMPTS.fetch_add(1, AtomicOrdering::Relaxed);
- Err::<(), redis::RedisError>(redis::RedisError::from((
- redis::ErrorKind::Server(redis::ServerErrorKind::CrossSlot),
- "slot",
- )))
- });
- match outcome {
- Err(error)
- if classify_redis(&error) == ErrorClass::Permanent
- && ATTEMPTS.load(AtomicOrdering::Relaxed) == 1 => {}
- _ => {
- return Err(TestError::Unexpected(
- "permanent errors must not retry".to_owned(),
- ));
- }
- }
- Ok(())
- }
- /// The record write is a single atomic `HSET`+`PEXPIRE` script: a record
- /// observed after [`hset_record`] always carries both its fields AND a live
- /// TTL, and a rewrite re-arms the expiry (never a persistent key).
- #[tokio::test]
- async fn store_hset_record_writes_fields_and_ttl() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, _store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let key = unique("hset-record");
- let ttl = Duration::from_secs(300);
- let pairs = vec![
- ("client_id".to_owned(), "client-1".to_owned()),
- ("scope".to_owned(), "orders:read".to_owned()),
- ];
- hset_record(&mut conn, &key, &pairs, ttl).await?;
- let record = hget_record(&mut conn, &key).await?;
- if record
- .as_ref()
- .is_none_or(|fields| fields.get("client_id").map(String::as_str) != Some("client-1"))
- {
- return Err(TestError::Unexpected(
- "written record must carry its fields".to_owned(),
- ));
- }
- assert_ttl_within(pttl_ms(&mut conn, &key).await?, ttl)?;
- // Rewrite: a shorter lifetime must be observable, proving the write
- // re-arms the TTL and does not leave the previous expiry in place.
- let rewrite = Duration::from_secs(120);
- let updated = vec![
- ("client_id".to_owned(), "client-2".to_owned()),
- ("scope".to_owned(), "orders:read".to_owned()),
- ];
- hset_record(&mut conn, &key, &updated, rewrite).await?;
- let record = hget_record(&mut conn, &key).await?;
- if record
- .as_ref()
- .is_none_or(|fields| fields.get("client_id").map(String::as_str) != Some("client-2"))
- {
- return Err(TestError::Unexpected(
- "rewrite must replace the field value".to_owned(),
- ));
- }
- assert_ttl_within(pttl_ms(&mut conn, &key).await?, rewrite)?;
- // A key whose TTL was stripped must regain a live expiry when rewritten
- // through the atomic write, so no record can remain persistent.
- let _: () = redis::cmd("PERSIST")
- .arg(&key)
- .query_async(&mut conn)
- .await?;
- if pttl_ms(&mut conn, &key).await? != -1 {
- return Err(TestError::Unexpected(
- "PERSIST must strip the TTL for the simulation".to_owned(),
- ));
- }
- hset_record(&mut conn, &key, &pairs, ttl).await?;
- assert_ttl_within(pttl_ms(&mut conn, &key).await?, ttl)
- }
- /// A field-less `HSET` is a Redis arity error; the empty-pairs guard falls
- /// back to the TTL touch alone, so an existing record keeps its fields and a
- /// live expiry instead of becoming persistent (or erroring out).
- #[tokio::test]
- async fn store_hset_record_empty_pairs_keeps_ttl() -> TestResult {
- let (_container, url) = redis_container(Vec::new()).await?;
- let (mut conn, _store) = direct_store(&url, DurabilityMode::WaitAof).await?;
- let key = unique("hset-empty");
- let ttl = Duration::from_secs(300);
- let pairs = vec![("client_id".to_owned(), "client-1".to_owned())];
- hset_record(&mut conn, &key, &pairs, ttl).await?;
- hset_record(&mut conn, &key, &[], ttl).await?;
- let record = hget_record(&mut conn, &key).await?;
- if record
- .as_ref()
- .is_none_or(|fields| fields.get("client_id").map(String::as_str) != Some("client-1"))
- {
- return Err(TestError::Unexpected(
- "empty rewrite must preserve the fields".to_owned(),
- ));
- }
- assert_ttl_within(pttl_ms(&mut conn, &key).await?, ttl)
- }
|