1use std::collections::HashMap;
8use std::sync::Arc;
9
10use bytes::Bytes;
11use serde_json::Value;
12
13use crate::context::{Context, GatewayError};
14use crate::plugins::resources::PluginResources;
15use crate::plugins::PluginExecutionError;
16use crate::vars::template::Template;
17
18#[derive(Debug)]
28pub struct StoreHandle {
29 pub name: String,
31 #[cfg(feature = "redis-store")]
32 client: Arc<crate::stores::redis_store::RedisStoreClient>,
33}
34
35impl StoreHandle {
36 #[cfg(feature = "redis-store")]
38 pub async fn conn(&self) -> Result<crate::stores::redis_store::StoreConn, String> {
39 self.client.conn().await
40 }
41
42 #[cfg(feature = "redis-store")]
44 pub fn key_for(&self, rendered: &str) -> String {
45 namespaced_key(self.client.key_prefix(), rendered)
46 }
47}
48
49#[cfg(feature = "redis-store")]
54pub fn namespaced_key(prefix: &str, rendered: &str) -> String {
55 format!(
56 "{}:{}:{}",
57 prefix,
58 crate::stores::namespaces::POLICY_KV,
59 rendered
60 )
61}
62
63fn prepare_error_response(ctx: &mut Context, status: u16, code: &str, message: &str) {
76 ctx.response.status_code = status;
77 ctx.response.body =
78 Bytes::from(serde_json::json!({ "error": code, "message": message }).to_string());
79 ctx.response.headers.insert(
80 "content-type".to_string(),
81 vec!["application/json".to_string()],
82 );
83}
84
85#[cfg(feature = "redis-store")]
96pub fn key_invalid(
97 mut ctx: Context,
98 node_type: &str,
99 op: &str,
100 store: &str,
101) -> PluginExecutionError {
102 let message = format!(
103 "{}: the 'key' template rendered to an empty string",
104 node_type
105 );
106 prepare_error_response(&mut ctx, 500, "STORE_KEY_INVALID", &message);
107 let mut metadata = HashMap::new();
108 metadata.insert("store".to_string(), Value::String(store.to_string()));
109 metadata.insert("op".to_string(), Value::String(op.to_string()));
110 PluginExecutionError {
111 context: ctx,
112 error: GatewayError {
113 node_id: String::new(),
114 code: "STORE_KEY_INVALID".to_string(),
115 message,
116 metadata,
117 },
118 }
119}
120
121#[cfg(feature = "redis-store")]
123pub fn resolve(
124 config: &HashMap<String, Value>,
125 resources: &Arc<PluginResources>,
126 node_type: &str,
127) -> Result<StoreHandle, String> {
128 let name = config
129 .get("store")
130 .and_then(|v| v.as_str())
131 .filter(|s| !s.is_empty())
132 .ok_or_else(|| {
133 format!(
134 "{}: 'store' is required and must name a declared stores: entry",
135 node_type
136 )
137 })?;
138 let client = resources.stores.load().client(name)?;
139 Ok(StoreHandle {
140 name: name.to_string(),
141 client,
142 })
143}
144
145#[cfg(not(feature = "redis-store"))]
149pub fn resolve(
150 config: &HashMap<String, Value>,
151 _resources: &Arc<PluginResources>,
152 node_type: &str,
153) -> Result<StoreHandle, String> {
154 let name = config
155 .get("store")
156 .and_then(|v| v.as_str())
157 .filter(|s| !s.is_empty())
158 .ok_or_else(|| {
159 format!(
160 "{}: 'store' is required and must name a declared stores: entry",
161 node_type
162 )
163 })?;
164 Err(format!(
165 "{}: store '{}': this binary was built without the redis-store feature",
166 node_type, name
167 ))
168}
169
170pub fn required_template(
172 config: &HashMap<String, Value>,
173 field: &str,
174 node_type: &str,
175) -> Result<Template, String> {
176 let raw = config
177 .get(field)
178 .and_then(|v| v.as_str())
179 .filter(|s| !s.is_empty())
180 .ok_or_else(|| format!("{}: '{}' is required", node_type, field))?;
181 let (tpl, warnings) = Template::parse(raw);
182 for w in warnings {
183 tracing::warn!(node_type = %node_type, field = %field, "{}", w);
184 }
185 Ok(tpl)
186}
187
188pub fn optional_ttl(
198 config: &HashMap<String, Value>,
199 node_type: &str,
200) -> Result<Option<u64>, String> {
201 optional_seconds(config, "ttl_seconds", node_type)
202}
203
204pub fn optional_seconds(
211 config: &HashMap<String, Value>,
212 field: &str,
213 node_type: &str,
214) -> Result<Option<u64>, String> {
215 match config.get(field) {
216 None | Some(Value::Null) => Ok(None),
217 Some(v) => {
218 let n = v.as_u64().ok_or_else(|| {
219 format!("{}: '{}' must be a non-negative integer", node_type, field)
220 })?;
221 if n == 0 {
222 return Err(format!(
223 "{}: '{}' must be greater than 0; omit the field to leave it unset",
224 node_type, field
225 ));
226 }
227 Ok(Some(n))
228 }
229 }
230}
231
232pub fn store_error(
240 mut ctx: Context,
241 node_type: &str,
242 op: &str,
243 store: &str,
244 msg: String,
245) -> PluginExecutionError {
246 let message = format!("{}: {} failed: {}", node_type, op, msg);
247 prepare_error_response(&mut ctx, 503, "STORE_ERROR", &message);
248 let mut metadata = HashMap::new();
249 metadata.insert("store".to_string(), Value::String(store.to_string()));
250 metadata.insert("op".to_string(), Value::String(op.to_string()));
251 PluginExecutionError {
252 context: ctx,
253 error: GatewayError {
254 node_id: String::new(),
255 code: "STORE_ERROR".to_string(),
256 message,
257 metadata,
258 },
259 }
260}
261
262#[cfg(feature = "redis-store")]
267pub fn value_invalid(
268 mut ctx: Context,
269 node_type: &str,
270 op: &str,
271 store: &str,
272 msg: String,
273) -> PluginExecutionError {
274 let message = format!("{}: {}", node_type, msg);
275 prepare_error_response(&mut ctx, 500, "STORE_VALUE_INVALID", &message);
276 let mut metadata = HashMap::new();
277 metadata.insert("store".to_string(), Value::String(store.to_string()));
278 metadata.insert("op".to_string(), Value::String(op.to_string()));
279 PluginExecutionError {
280 context: ctx,
281 error: GatewayError {
282 node_id: String::new(),
283 code: "STORE_VALUE_INVALID".to_string(),
284 message,
285 metadata,
286 },
287 }
288}
289
290#[cfg(feature = "redis-store")]
302pub fn is_value_type_error(e: &redis::RedisError) -> bool {
303 e.code() == Some("WRONGTYPE")
304 || (e.kind() == redis::ErrorKind::ResponseError && e.to_string().contains("not an integer"))
305}
306
307#[cfg(all(test, feature = "redis-store"))]
308mod tests {
309 use super::*;
310 use crate::context::{GatewayRequest, Protocol};
311 use std::collections::HashMap;
312
313 fn cfg(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
314 serde_json::from_value(json).unwrap()
315 }
316
317 fn test_ctx() -> Context {
318 Context::new(GatewayRequest {
319 method: "GET".to_string(),
320 path: "/".to_string(),
321 host: "example.com".to_string(),
322 scheme: "http".to_string(),
323 headers: HashMap::new(),
324 query_params: HashMap::new(),
325 body: bytes::Bytes::new(),
326 remote_addr: "10.1.2.3:44321".to_string(),
327 protocol: Protocol::Http1,
328 })
329 }
330
331 #[test]
334 fn test_key_for_namespaces_under_kv() {
335 assert_eq!(namespaced_key("fb", "retry:abc"), "fb:kv:retry:abc");
336 }
337
338 #[test]
343 fn test_a_rendered_key_cannot_escape_the_kv_namespace() {
344 let hostile = [
345 ":sess:{abc}",
346 "../sess:{abc}",
347 "fb:sess:{abc}",
348 "fb:acme:account",
349 "a\nfb:cnt:0:u1",
350 ];
351 for h in hostile {
352 let built = namespaced_key("fb", h);
353 assert!(
354 built.starts_with("fb:kv:"),
355 "escaped the namespace: {built}"
356 );
357 for ns in crate::stores::namespaces::MANAGED {
358 assert!(
359 !built.starts_with(&format!("fb:{ns}:")),
360 "{built} lands in the {ns} namespace"
361 );
362 }
363 }
364 }
365
366 #[test]
371 fn test_empty_key_has_its_own_code() {
372 let e = key_invalid(test_ctx(), "store-get", "GET", "sessions");
373 assert_eq!(e.error.code, "STORE_KEY_INVALID");
374 assert_eq!(e.context.response.status_code, 500);
375 assert!(!e.context.response.body.is_empty());
376 assert_eq!(e.error.metadata.get("store").unwrap(), "sessions");
377 assert_eq!(e.error.metadata.get("op").unwrap(), "GET");
378 }
379
380 #[test]
381 fn test_required_template_rejects_a_missing_key() {
382 let err = required_template(&cfg(serde_json::json!({})), "key", "store-get").unwrap_err();
383 assert!(
384 err.contains("store-get"),
385 "error must name the node type: {err}"
386 );
387 assert!(err.contains("key"), "error must name the field: {err}");
388 }
389
390 #[test]
391 fn test_required_template_parses_a_template() {
392 let t = required_template(
393 &cfg(serde_json::json!({ "key": "retry:{{request.path}}" })),
394 "key",
395 "store-get",
396 )
397 .unwrap();
398 assert!(!t.is_literal());
399 }
400
401 #[test]
404 fn test_optional_ttl_rejects_zero() {
405 let err =
406 optional_ttl(&cfg(serde_json::json!({ "ttl_seconds": 0 })), "store-set").unwrap_err();
407 assert!(err.contains("ttl_seconds"), "{err}");
408 }
409
410 #[test]
411 fn test_optional_ttl_absent_is_none_and_present_is_some() {
412 assert_eq!(
413 optional_ttl(&cfg(serde_json::json!({})), "store-set").unwrap(),
414 None
415 );
416 assert_eq!(
417 optional_ttl(&cfg(serde_json::json!({ "ttl_seconds": 300 })), "store-set").unwrap(),
418 Some(300)
419 );
420 }
421
422 #[test]
429 fn test_store_error_prepares_a_503_and_carries_code_store_and_op() {
430 let e = store_error(
431 test_ctx(),
432 "store-get",
433 "GET",
434 "sessions",
435 "connection refused".to_string(),
436 );
437 assert_eq!(e.error.code, "STORE_ERROR");
438 assert_eq!(e.error.metadata.get("store").unwrap(), "sessions");
439 assert_eq!(e.error.metadata.get("op").unwrap(), "GET");
440 assert!(
441 e.error.message.contains("connection refused"),
442 "{}",
443 e.error.message
444 );
445 assert_eq!(e.context.response.status_code, 503);
446 assert!(!e.context.response.body.is_empty());
447 assert_eq!(
448 e.context.response.headers.get("content-type").unwrap(),
449 &vec!["application/json".to_string()]
450 );
451 }
452
453 #[test]
456 fn test_value_invalid_prepares_a_500_and_carries_code_store_and_op() {
457 let e = value_invalid(
458 test_ctx(),
459 "store-get",
460 "GET",
461 "sessions",
462 "expected JSON".to_string(),
463 );
464 assert_eq!(e.error.code, "STORE_VALUE_INVALID");
465 assert_eq!(e.error.metadata.get("store").unwrap(), "sessions");
466 assert_eq!(e.error.metadata.get("op").unwrap(), "GET");
467 assert_eq!(e.context.response.status_code, 500);
468 assert!(!e.context.response.body.is_empty());
469 }
470
471 #[test]
476 fn test_is_value_type_error_covers_wrongtype_and_not_an_integer() {
477 let wrongtype = redis::make_extension_error(
481 "WRONGTYPE".to_string(),
482 Some("Operation against a key holding the wrong kind of value".to_string()),
483 );
484 assert!(is_value_type_error(&wrongtype));
485
486 let not_an_integer = redis::RedisError::from((
487 redis::ErrorKind::ResponseError,
488 "value is not an integer or out of range",
489 ));
490 assert!(is_value_type_error(¬_an_integer));
491
492 let outage = redis::RedisError::from((redis::ErrorKind::IoError, "connection refused"));
493 assert!(!is_value_type_error(&outage));
494 }
495}
496
497#[cfg(all(test, feature = "redis-store"))]
506mod live_tests {
507 use super::*;
508 use crate::config::StoreConfig;
509 use crate::context::{Context, GatewayRequest, Protocol};
510 use crate::plugins::native::store_delete::StoreDeletePlugin;
511 use crate::plugins::native::store_get::StoreGetPlugin;
512 use crate::plugins::native::store_incr::StoreIncrPlugin;
513 use crate::plugins::native::store_set::StoreSetPlugin;
514 use crate::plugins::Plugin;
515 use crate::stores::StoreRegistry;
516 use std::collections::HashMap;
517 use std::time::Duration;
518
519 const LIVE_TEST_TTL_SECONDS: u64 = 60;
524
525 fn store_url() -> Option<String> {
528 std::env::var("FEATHERBIT_TEST_REDIS_URL")
529 .ok()
530 .filter(|s| !s.is_empty())
531 }
532
533 fn resources_with_store(url: &str) -> Arc<PluginResources> {
536 let store_cfg: StoreConfig =
537 serde_yaml::from_str(&format!("name: test\ntype: redis\nurl: {url}\n"))
538 .expect("store config parses");
539 let registry = StoreRegistry::rebuild(&StoreRegistry::default(), &[store_cfg], None)
540 .expect("store registry builds against a reachable url");
541 let resources = PluginResources::empty();
542 resources.stores.store(Arc::new(registry));
543 resources
544 }
545
546 fn cfg(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
547 serde_json::from_value(json).unwrap()
548 }
549
550 fn test_ctx() -> Context {
551 Context::new(GatewayRequest {
552 method: "GET".to_string(),
553 path: "/".to_string(),
554 host: "example.com".to_string(),
555 scheme: "http".to_string(),
556 headers: HashMap::new(),
557 query_params: HashMap::new(),
558 body: bytes::Bytes::new(),
559 remote_addr: "10.1.2.3:44321".to_string(),
560 protocol: Protocol::Http1,
561 })
562 }
563
564 fn unique_key(label: &str) -> String {
568 format!("task6:{}:{}", label, uuid::Uuid::new_v4())
569 }
570
571 #[tokio::test]
572 async fn test_set_then_get_round_trips() {
573 let Some(url) = store_url() else {
574 eprintln!("skipping test_set_then_get_round_trips: FEATHERBIT_TEST_REDIS_URL not set");
575 return;
576 };
577 let resources = resources_with_store(&url);
578 let key = unique_key("roundtrip");
579
580 let set = StoreSetPlugin::from_config(
581 &cfg(serde_json::json!({ "store": "test", "key": key, "value": "1", "ttl_seconds": LIVE_TEST_TTL_SECONDS })),
582 &resources,
583 )
584 .unwrap();
585 let out = set.execute(test_ctx()).await.unwrap();
586 assert_eq!(out.port, None);
587
588 let get = StoreGetPlugin::from_config(
589 &cfg(serde_json::json!({ "store": "test", "key": key, "name": "v" })),
590 &resources,
591 )
592 .unwrap();
593 let out = get.execute(test_ctx()).await.unwrap();
594 assert_eq!(out.port, None);
595 assert_eq!(out.context.message.get("v").unwrap(), "1");
596 }
597
598 #[tokio::test]
599 async fn test_get_on_an_absent_key_exits_miss() {
600 let Some(url) = store_url() else {
601 eprintln!(
602 "skipping test_get_on_an_absent_key_exits_miss: FEATHERBIT_TEST_REDIS_URL not set"
603 );
604 return;
605 };
606 let resources = resources_with_store(&url);
607 let key = unique_key("absent");
608
609 let get = StoreGetPlugin::from_config(
610 &cfg(serde_json::json!({ "store": "test", "key": key, "name": "v" })),
611 &resources,
612 )
613 .unwrap();
614 let out = get.execute(test_ctx()).await.unwrap();
615 assert_eq!(out.port, Some("miss"));
616 }
617
618 #[tokio::test]
619 async fn test_json_true_flattens_an_object() {
620 let Some(url) = store_url() else {
621 eprintln!(
622 "skipping test_json_true_flattens_an_object: FEATHERBIT_TEST_REDIS_URL not set"
623 );
624 return;
625 };
626 let resources = resources_with_store(&url);
627 let key = unique_key("json");
628
629 let set = StoreSetPlugin::from_config(
630 &cfg(serde_json::json!({
631 "store": "test",
632 "key": key,
633 "value": r#"{"tier":"gold"}"#,
634 "ttl_seconds": LIVE_TEST_TTL_SECONDS,
635 })),
636 &resources,
637 )
638 .unwrap();
639 set.execute(test_ctx()).await.unwrap();
640
641 let get = StoreGetPlugin::from_config(
642 &cfg(serde_json::json!({
643 "store": "test",
644 "key": key,
645 "name": "profile",
646 "json": true,
647 })),
648 &resources,
649 )
650 .unwrap();
651 let out = get.execute(test_ctx()).await.unwrap();
652 assert_eq!(out.context.message.get("profile.tier").unwrap(), "gold");
653 }
654
655 #[tokio::test]
656 async fn test_json_true_on_invalid_json_exits_error() {
657 let Some(url) = store_url() else {
658 eprintln!(
659 "skipping test_json_true_on_invalid_json_exits_error: FEATHERBIT_TEST_REDIS_URL not set"
660 );
661 return;
662 };
663 let resources = resources_with_store(&url);
664 let key = unique_key("badjson");
665
666 let set = StoreSetPlugin::from_config(
667 &cfg(serde_json::json!({ "store": "test", "key": key, "value": "not json", "ttl_seconds": LIVE_TEST_TTL_SECONDS })),
668 &resources,
669 )
670 .unwrap();
671 set.execute(test_ctx()).await.unwrap();
672
673 let get = StoreGetPlugin::from_config(
674 &cfg(serde_json::json!({
675 "store": "test",
676 "key": key,
677 "name": "profile",
678 "json": true,
679 })),
680 &resources,
681 )
682 .unwrap();
683 let err = get.execute(test_ctx()).await.unwrap_err();
684 assert_eq!(err.error.code, "STORE_VALUE_INVALID");
685 }
686
687 #[tokio::test]
688 async fn test_delete_on_an_absent_key_succeeds() {
689 let Some(url) = store_url() else {
690 eprintln!(
691 "skipping test_delete_on_an_absent_key_succeeds: FEATHERBIT_TEST_REDIS_URL not set"
692 );
693 return;
694 };
695 let resources = resources_with_store(&url);
696 let key = unique_key("delete-absent");
697
698 let delete = StoreDeletePlugin::from_config(
699 &cfg(serde_json::json!({ "store": "test", "key": key })),
700 &resources,
701 )
702 .unwrap();
703 let out = delete.execute(test_ctx()).await.unwrap();
704 assert_eq!(out.port, None);
705 }
706
707 #[tokio::test]
713 async fn test_incr_does_not_refresh_the_ttl() {
714 let Some(url) = store_url() else {
715 eprintln!(
716 "skipping test_incr_does_not_refresh_the_ttl: FEATHERBIT_TEST_REDIS_URL not set"
717 );
718 return;
719 };
720 let resources = resources_with_store(&url);
721 let key = unique_key("ttl");
722 let redis_key = namespaced_key("fb", &key);
723
724 let client = resources.stores.load().client("test").unwrap();
725 let mut raw = client.conn().await.unwrap();
726
727 let incr = StoreIncrPlugin::from_config(
728 &cfg(serde_json::json!({
729 "store": "test",
730 "key": key,
731 "name": "n",
732 "ttl_seconds": LIVE_TEST_TTL_SECONDS,
733 })),
734 &resources,
735 )
736 .unwrap();
737
738 incr.execute(test_ctx()).await.unwrap();
739 let ttl1: i64 = redis::cmd("PTTL")
740 .arg(&redis_key)
741 .query_async(&mut raw)
742 .await
743 .unwrap();
744 assert!(ttl1 > 0, "key must have a TTL right after creation: {ttl1}");
745
746 tokio::time::sleep(Duration::from_millis(1100)).await;
747
748 incr.execute(test_ctx()).await.unwrap();
749 let ttl2: i64 = redis::cmd("PTTL")
750 .arg(&redis_key)
751 .query_async(&mut raw)
752 .await
753 .unwrap();
754
755 let drop = ttl1 - ttl2;
765 assert!(
766 drop >= 900,
767 "TTL must keep counting down by about the sleep duration, not be refreshed by a later increment: ttl1={ttl1} ttl2={ttl2} drop={drop}"
768 );
769 }
770
771 #[tokio::test]
807 async fn test_unreachable_store_exits_error_not_miss() {
808 let Some(url) = store_url() else {
809 eprintln!(
810 "skipping test_unreachable_store_exits_error_not_miss: FEATHERBIT_TEST_REDIS_URL not set"
811 );
812 return;
813 };
814 let resources = resources_with_store(&url);
815 let key = unique_key("backend-error");
816 let redis_key = namespaced_key("fb", &key);
817
818 let client = resources.stores.load().client("test").unwrap();
821 let mut raw = client.conn().await.unwrap();
822 let _: i64 = redis::cmd("LPUSH")
823 .arg(&redis_key)
824 .arg("x")
825 .query_async(&mut raw)
826 .await
827 .unwrap();
828 let _: bool = redis::cmd("EXPIRE")
831 .arg(&redis_key)
832 .arg(LIVE_TEST_TTL_SECONDS)
833 .query_async(&mut raw)
834 .await
835 .unwrap();
836
837 let get = StoreGetPlugin::from_config(
838 &cfg(serde_json::json!({ "store": "test", "key": key, "name": "v" })),
839 &resources,
840 )
841 .unwrap();
842 let err = get.execute(test_ctx()).await.unwrap_err();
843 assert_eq!(err.error.code, "STORE_ERROR");
844 }
845
846 #[tokio::test]
851 async fn test_incr_on_a_non_numeric_string_exits_value_invalid() {
852 let Some(url) = store_url() else {
853 eprintln!(
854 "skipping test_incr_on_a_non_numeric_string_exits_value_invalid: FEATHERBIT_TEST_REDIS_URL not set"
855 );
856 return;
857 };
858 let resources = resources_with_store(&url);
859 let key = unique_key("incr-non-numeric");
860
861 let set = StoreSetPlugin::from_config(
862 &cfg(serde_json::json!({ "store": "test", "key": key, "value": "not-a-number", "ttl_seconds": LIVE_TEST_TTL_SECONDS })),
863 &resources,
864 )
865 .unwrap();
866 set.execute(test_ctx()).await.unwrap();
867
868 let incr = StoreIncrPlugin::from_config(
869 &cfg(serde_json::json!({ "store": "test", "key": key, "name": "n" })),
870 &resources,
871 )
872 .unwrap();
873 let err = incr.execute(test_ctx()).await.unwrap_err();
874 assert_eq!(err.error.code, "STORE_VALUE_INVALID");
875 }
876
877 #[tokio::test]
882 async fn test_incr_on_a_list_value_exits_value_invalid_not_store_error() {
883 let Some(url) = store_url() else {
884 eprintln!(
885 "skipping test_incr_on_a_list_value_exits_value_invalid_not_store_error: FEATHERBIT_TEST_REDIS_URL not set"
886 );
887 return;
888 };
889 let resources = resources_with_store(&url);
890 let key = unique_key("incr-wrongtype");
891 let redis_key = namespaced_key("fb", &key);
892
893 let client = resources.stores.load().client("test").unwrap();
894 let mut raw = client.conn().await.unwrap();
895 let _: i64 = redis::cmd("LPUSH")
896 .arg(&redis_key)
897 .arg("x")
898 .query_async(&mut raw)
899 .await
900 .unwrap();
901 let _: bool = redis::cmd("EXPIRE")
902 .arg(&redis_key)
903 .arg(LIVE_TEST_TTL_SECONDS)
904 .query_async(&mut raw)
905 .await
906 .unwrap();
907
908 let incr = StoreIncrPlugin::from_config(
909 &cfg(serde_json::json!({ "store": "test", "key": key, "name": "n" })),
910 &resources,
911 )
912 .unwrap();
913 let err = incr.execute(test_ctx()).await.unwrap_err();
914 assert_eq!(err.error.code, "STORE_VALUE_INVALID");
915 }
916
917 #[tokio::test]
922 async fn test_empty_rendered_key_exits_store_key_invalid() {
923 let Some(url) = store_url() else {
924 eprintln!(
925 "skipping test_empty_rendered_key_exits_store_key_invalid: FEATHERBIT_TEST_REDIS_URL not set"
926 );
927 return;
928 };
929 let resources = resources_with_store(&url);
930
931 let get = StoreGetPlugin::from_config(
932 &cfg(serde_json::json!({ "store": "test", "key": "{{request.headers.x-absent}}", "name": "v" })),
933 &resources,
934 )
935 .unwrap();
936 let err = get.execute(test_ctx()).await.unwrap_err();
937 assert_eq!(err.error.code, "STORE_KEY_INVALID");
938 assert_eq!(err.context.response.status_code, 500);
939 assert!(!err.context.response.body.is_empty());
940 }
941
942 #[tokio::test]
952 async fn test_reads_response_body_reflects_a_response_body_reference() {
953 let Some(url) = store_url() else {
954 eprintln!(
955 "skipping test_reads_response_body_reflects_a_response_body_reference: FEATHERBIT_TEST_REDIS_URL not set"
956 );
957 return;
958 };
959 let resources = resources_with_store(&url);
960
961 let get_plain = StoreGetPlugin::from_config(
962 &cfg(serde_json::json!({ "store": "test", "key": "k", "name": "n" })),
963 &resources,
964 )
965 .unwrap();
966 let get_reads = StoreGetPlugin::from_config(
967 &cfg(serde_json::json!({ "store": "test", "key": "{{response.body}}", "name": "n" })),
968 &resources,
969 )
970 .unwrap();
971 assert!(!get_plain.reads_response_body());
972 assert!(get_reads.reads_response_body());
973
974 let set_plain = StoreSetPlugin::from_config(
975 &cfg(serde_json::json!({ "store": "test", "key": "k", "value": "1" })),
976 &resources,
977 )
978 .unwrap();
979 let set_reads = StoreSetPlugin::from_config(
980 &cfg(serde_json::json!({ "store": "test", "key": "k", "value": "{{response.body}}" })),
981 &resources,
982 )
983 .unwrap();
984 assert!(!set_plain.reads_response_body());
985 assert!(set_reads.reads_response_body());
986
987 let delete_plain = StoreDeletePlugin::from_config(
988 &cfg(serde_json::json!({ "store": "test", "key": "k" })),
989 &resources,
990 )
991 .unwrap();
992 let delete_reads = StoreDeletePlugin::from_config(
993 &cfg(serde_json::json!({ "store": "test", "key": "{{response.body}}" })),
994 &resources,
995 )
996 .unwrap();
997 assert!(!delete_plain.reads_response_body());
998 assert!(delete_reads.reads_response_body());
999
1000 let incr_plain = StoreIncrPlugin::from_config(
1001 &cfg(serde_json::json!({ "store": "test", "key": "k", "name": "n" })),
1002 &resources,
1003 )
1004 .unwrap();
1005 let incr_reads = StoreIncrPlugin::from_config(
1006 &cfg(serde_json::json!({ "store": "test", "key": "{{response.body}}", "name": "n" })),
1007 &resources,
1008 )
1009 .unwrap();
1010 assert!(!incr_plain.reads_response_body());
1011 assert!(incr_reads.reads_response_body());
1012 }
1013
1014 #[tokio::test]
1023 async fn test_incr_with_refresh_ttl_extends_the_expiry() {
1024 let Some(url) = store_url() else {
1025 eprintln!(
1026 "skipping test_incr_with_refresh_ttl_extends_the_expiry: FEATHERBIT_TEST_REDIS_URL not set"
1027 );
1028 return;
1029 };
1030 let resources = resources_with_store(&url);
1031 let key = unique_key("ttl-slide");
1032 let redis_key = namespaced_key("fb", &key);
1033
1034 let client = resources.stores.load().client("test").unwrap();
1035 let mut raw = client.conn().await.unwrap();
1036
1037 let incr = StoreIncrPlugin::from_config(
1038 &cfg(serde_json::json!({
1039 "store": "test",
1040 "key": key,
1041 "name": "n",
1042 "ttl_seconds": LIVE_TEST_TTL_SECONDS,
1043 "refresh_ttl": true,
1044 })),
1045 &resources,
1046 )
1047 .unwrap();
1048
1049 incr.execute(test_ctx()).await.unwrap();
1050 let ttl1: i64 = redis::cmd("PTTL")
1051 .arg(&redis_key)
1052 .query_async(&mut raw)
1053 .await
1054 .unwrap();
1055 assert!(ttl1 > 0, "key must have a TTL right after creation: {ttl1}");
1056
1057 tokio::time::sleep(Duration::from_millis(1100)).await;
1058
1059 incr.execute(test_ctx()).await.unwrap();
1060 let ttl2: i64 = redis::cmd("PTTL")
1061 .arg(&redis_key)
1062 .query_async(&mut raw)
1063 .await
1064 .unwrap();
1065
1066 let drop = ttl1 - ttl2;
1073 assert!(
1074 drop <= 200,
1075 "refresh_ttl must re-arm the expiry on each increment, so it must not count down: ttl1={ttl1} ttl2={ttl2} drop={drop}"
1076 );
1077 }
1078
1079 #[tokio::test]
1083 async fn test_get_with_extend_ttl_pushes_the_expiry_out() {
1084 let Some(url) = store_url() else {
1085 eprintln!(
1086 "skipping test_get_with_extend_ttl_pushes_the_expiry_out: FEATHERBIT_TEST_REDIS_URL not set"
1087 );
1088 return;
1089 };
1090 let resources = resources_with_store(&url);
1091 let key = unique_key("touch");
1092 let redis_key = namespaced_key("fb", &key);
1093
1094 let client = resources.stores.load().client("test").unwrap();
1095 let mut raw = client.conn().await.unwrap();
1096
1097 let set = StoreSetPlugin::from_config(
1098 &cfg(serde_json::json!({
1099 "store": "test",
1100 "key": key,
1101 "value": "alive",
1102 "ttl_seconds": LIVE_TEST_TTL_SECONDS,
1103 })),
1104 &resources,
1105 )
1106 .unwrap();
1107 set.execute(test_ctx()).await.unwrap();
1108
1109 let ttl1: i64 = redis::cmd("PTTL")
1110 .arg(&redis_key)
1111 .query_async(&mut raw)
1112 .await
1113 .unwrap();
1114
1115 tokio::time::sleep(Duration::from_millis(1100)).await;
1116
1117 let get = StoreGetPlugin::from_config(
1118 &cfg(serde_json::json!({
1119 "store": "test",
1120 "key": key,
1121 "name": "v",
1122 "extend_ttl_seconds": LIVE_TEST_TTL_SECONDS,
1123 })),
1124 &resources,
1125 )
1126 .unwrap();
1127 let out = get.execute(test_ctx()).await.unwrap();
1128
1129 assert_eq!(
1131 out.context.message.get("v").and_then(|v| v.as_str()),
1132 Some("alive")
1133 );
1134
1135 let ttl2: i64 = redis::cmd("PTTL")
1136 .arg(&redis_key)
1137 .query_async(&mut raw)
1138 .await
1139 .unwrap();
1140
1141 let drop = ttl1 - ttl2;
1146 assert!(
1147 drop <= 200,
1148 "a read with extend_ttl_seconds must re-arm the expiry, so it must not count down: ttl1={ttl1} ttl2={ttl2} drop={drop}"
1149 );
1150 }
1151
1152 #[tokio::test]
1157 async fn test_get_with_extend_ttl_on_an_absent_key_still_misses() {
1158 let Some(url) = store_url() else {
1159 eprintln!(
1160 "skipping test_get_with_extend_ttl_on_an_absent_key_still_misses: FEATHERBIT_TEST_REDIS_URL not set"
1161 );
1162 return;
1163 };
1164 let resources = resources_with_store(&url);
1165 let key = unique_key("touch-absent");
1166 let redis_key = namespaced_key("fb", &key);
1167
1168 let client = resources.stores.load().client("test").unwrap();
1169 let mut raw = client.conn().await.unwrap();
1170
1171 let get = StoreGetPlugin::from_config(
1172 &cfg(serde_json::json!({
1173 "store": "test",
1174 "key": key,
1175 "name": "v",
1176 "extend_ttl_seconds": LIVE_TEST_TTL_SECONDS,
1177 })),
1178 &resources,
1179 )
1180 .unwrap();
1181
1182 let out = get.execute(test_ctx()).await.unwrap();
1183 assert_eq!(out.port, Some("miss"));
1184
1185 let exists: i64 = redis::cmd("EXISTS")
1186 .arg(&redis_key)
1187 .query_async(&mut raw)
1188 .await
1189 .unwrap();
1190 assert_eq!(exists, 0, "a missing key must not be created by extending");
1191 }
1192}