1use std::collections::{BTreeMap, BTreeSet};
47use std::time::Duration;
48
49use serde::Serialize;
50use zeroize::{Zeroize, ZeroizeOnDrop};
51
52use crate::error::{Error, Result};
53use crate::moderation::types::ActionType;
54
55const REQUIRED_ACTION_TYPES: &[ActionType] = &[
58 ActionType::Takedown,
59 ActionType::IndefSuspension,
60 ActionType::TempSuspension,
61 ActionType::Warning,
62 ActionType::Note,
63];
64
65#[derive(Debug, Clone)]
69pub struct PdsAdminPolicy {
70 pub enabled: bool,
75 pub backend: Option<PdsAdminBackendConfig>,
81 pub action_map: BTreeMap<ActionType, ActionMapEntry>,
86}
87
88#[derive(Debug, Clone)]
92pub enum PdsAdminBackendConfig {
93 Ozone(OzoneBackendConfig),
95}
96
97#[derive(Debug, Clone)]
99pub struct OzoneBackendConfig {
100 pub pds_url: url::Url,
102 pub admin_password: AdminPassword,
106 pub request_timeout: Duration,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum ActionMapEntry {
124 Skip,
128 Method(BackendMethod),
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
140#[serde(rename_all = "snake_case")]
141pub enum BackendMethod {
142 TakedownAccount,
144 SuspendAccount,
148 RestoreAccount,
150 ApplyLabel,
157 NegateLabel,
160}
161
162impl BackendMethod {
163 pub(crate) fn from_wire_str(s: &str) -> Option<Self> {
173 match s {
174 "takedown_account" => Some(Self::TakedownAccount),
175 "suspend_account" => Some(Self::SuspendAccount),
176 "restore_account" => Some(Self::RestoreAccount),
177 "apply_label" => Some(Self::ApplyLabel),
178 "negate_label" => Some(Self::NegateLabel),
179 _ => None,
180 }
181 }
182
183 pub fn as_wire_str(self) -> &'static str {
187 match self {
188 Self::TakedownAccount => "takedown_account",
189 Self::SuspendAccount => "suspend_account",
190 Self::RestoreAccount => "restore_account",
191 Self::ApplyLabel => "apply_label",
192 Self::NegateLabel => "negate_label",
193 }
194 }
195
196 pub fn is_implemented_by_ozone_v1_7(self) -> bool {
202 match self {
203 Self::TakedownAccount | Self::SuspendAccount | Self::RestoreAccount => true,
204 Self::ApplyLabel | Self::NegateLabel => false,
205 }
206 }
207
208 pub fn returns_action_id(self) -> bool {
225 match self {
226 Self::TakedownAccount | Self::SuspendAccount => true,
227 Self::RestoreAccount | Self::ApplyLabel | Self::NegateLabel => false,
228 }
229 }
230}
231
232#[derive(Clone, Zeroize, ZeroizeOnDrop)]
247pub struct AdminPassword(String);
248
249impl AdminPassword {
250 pub fn new(s: String) -> Self {
263 Self(s)
264 }
265
266 pub fn as_str(&self) -> &str {
270 &self.0
271 }
272}
273
274impl std::fmt::Debug for AdminPassword {
275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276 f.debug_tuple("AdminPassword").field(&"<redacted>").finish()
277 }
278}
279
280impl PdsAdminPolicy {
281 pub fn from_config(cfg: &crate::config::Config) -> Result<Self> {
287 let Some(toml) = cfg.pds_admin.as_ref() else {
288 return Ok(Self::defaults());
289 };
290 Self::validated_from_toml(toml, |name| std::env::var(name))
296 }
297
298 #[cfg(test)]
306 pub(crate) fn from_config_with_env_reader<F>(
307 cfg: &crate::config::Config,
308 read_env: F,
309 ) -> Result<Self>
310 where
311 F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
312 {
313 let Some(toml) = cfg.pds_admin.as_ref() else {
314 return Ok(Self::defaults());
315 };
316 Self::validated_from_toml(toml, read_env)
317 }
318
319 pub fn defaults() -> Self {
323 Self {
324 enabled: false,
325 backend: None,
326 action_map: BTreeMap::new(),
327 }
328 }
329
330 fn validated_from_toml<F>(toml: &crate::config::PdsAdminConfigToml, read_env: F) -> Result<Self>
331 where
332 F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
333 {
334 reject_unsupported_backend_subsections(toml)?;
341
342 let resolved_ozone = toml
347 .ozone
348 .as_ref()
349 .map(|t| validated_ozone_from_toml(t, &read_env))
350 .transpose()?;
351
352 if !toml.enabled {
353 return Ok(Self {
354 enabled: false,
355 backend: None,
356 action_map: BTreeMap::new(),
357 });
358 }
359
360 let backend = match resolved_ozone {
362 Some(ozone) => PdsAdminBackendConfig::Ozone(ozone),
363 None => {
364 return Err(Error::Signing(
365 "config: [pds_admin].enabled = true but no backend subsection is present \
366 (v1.7 supports [pds_admin.ozone] only)"
367 .into(),
368 ));
369 }
370 };
371
372 let action_map_toml = toml.action_map.as_ref().ok_or_else(|| {
373 Error::Signing(
374 "config: [pds_admin].enabled = true but [pds_admin.action_map] is absent \
375 (every cairn-mod action type must be mapped to a backend method or \"skip\")"
376 .into(),
377 )
378 })?;
379 let action_map = validated_action_map(action_map_toml)?;
380
381 Ok(Self {
382 enabled: true,
383 backend: Some(backend),
384 action_map,
385 })
386 }
387}
388
389fn reject_unsupported_backend_subsections(toml: &crate::config::PdsAdminConfigToml) -> Result<()> {
394 if toml.locus.is_some() {
395 return Err(Error::Signing(
396 "config: backend not supported in v1.7: locus \
397 (Aurora-Locus support is deferred to v1.8; remove [pds_admin.locus] \
398 or wait for v1.8)"
399 .into(),
400 ));
401 }
402 if let Some(unknown) = toml.other_backends.keys().next() {
403 return Err(Error::Signing(format!(
404 "config: backend not supported in v1.7: {unknown} \
405 (v1.7 supports [pds_admin.ozone] only; check for typos in subsection name)"
406 )));
407 }
408 Ok(())
409}
410
411fn validated_ozone_from_toml<F>(
412 toml: &crate::config::PdsAdminOzoneToml,
413 read_env: &F,
414) -> Result<OzoneBackendConfig>
415where
416 F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
417{
418 let pds_url = url::Url::parse(&toml.pds_url).map_err(|e| {
420 Error::Signing(format!(
421 "config: [pds_admin.ozone].pds_url is not a valid URL: {e}"
422 ))
423 })?;
424 if pds_url.scheme() != "https" {
425 return Err(Error::Signing(format!(
426 "config: [pds_admin.ozone].pds_url must use https scheme; got: {}",
427 pds_url.scheme()
428 )));
429 }
430
431 if toml.admin_password_env.is_empty() {
434 return Err(Error::Signing(
435 "config: [pds_admin.ozone].admin_password_env must name an env var \
436 (got empty string)"
437 .into(),
438 ));
439 }
440 let env_value = read_env(&toml.admin_password_env).map_err(|_| {
441 Error::Signing(format!(
442 "config: env var ${} referenced by [pds_admin.ozone].admin_password_env is not set",
443 toml.admin_password_env
444 ))
445 })?;
446 if env_value.is_empty() {
447 return Err(Error::Signing(format!(
448 "config: env var ${} referenced by [pds_admin.ozone].admin_password_env is not set",
449 toml.admin_password_env
450 )));
451 }
452
453 if !(1..=60).contains(&toml.request_timeout_seconds) {
455 return Err(Error::Signing(format!(
456 "config: [pds_admin.ozone].request_timeout_seconds = {} is out of range (1..=60)",
457 toml.request_timeout_seconds
458 )));
459 }
460
461 Ok(OzoneBackendConfig {
462 pds_url,
463 admin_password: AdminPassword::new(env_value),
464 request_timeout: Duration::from_secs(u64::from(toml.request_timeout_seconds)),
465 })
466}
467
468fn validated_action_map(
469 toml: &BTreeMap<String, crate::config::PdsAdminActionMapValueToml>,
470) -> Result<BTreeMap<ActionType, ActionMapEntry>> {
471 let mut resolved: BTreeMap<ActionType, ActionMapEntry> = BTreeMap::new();
472 let mut warned_methods: BTreeSet<(ActionType, BackendMethod)> = BTreeSet::new();
473
474 for (raw_key, raw_value) in toml {
475 let action_type = ActionType::from_db_str(raw_key).ok_or_else(|| {
476 Error::Signing(format!(
477 "config: [pds_admin.action_map].{raw_key} is not a valid action_type \
478 (expected one of warning / note / temp_suspension / indef_suspension / takedown)"
479 ))
480 })?;
481
482 let entry = match raw_value {
483 crate::config::PdsAdminActionMapValueToml::Bare(s) => parse_method_string(s, || {
484 format!("[pds_admin.action_map].{}", action_type.as_db_str())
485 })?,
486 crate::config::PdsAdminActionMapValueToml::Table(table) => {
487 if table.with_lift_after && !matches!(action_type, ActionType::TempSuspension) {
491 return Err(Error::Signing(format!(
492 "config: [pds_admin.action_map].{} sets with_lift_after = true; \
493 this option is only valid for temp_suspension",
494 action_type.as_db_str()
495 )));
496 }
497 if table.with_lift_after {
498 return Err(Error::Signing(
499 "config: [pds_admin.action_map] with_lift_after = true requires a \
500 deferred-execution layer not present in v1.7; deferred to v1.8. \
501 Configure temp_suspension as bare \"takedown_account\" and lift \
502 manually via CLI in v1.7."
503 .into(),
504 ));
505 }
506 parse_method_string(&table.method, || {
507 format!("[pds_admin.action_map].{}.method", action_type.as_db_str())
508 })?
509 }
510 };
511
512 if let ActionMapEntry::Method(method) = entry
516 && !method.is_implemented_by_ozone_v1_7()
517 && warned_methods.insert((action_type, method))
518 {
519 tracing::warn!(
520 "config: [pds_admin.action_map].{} maps to {}, but OzoneBackend does not \
521 implement label methods in v1.7; this action will not propagate to the PDS \
522 (#89: returns BackendError::Unsupported at runtime)",
523 action_type.as_db_str(),
524 method.as_wire_str(),
525 );
526 }
527
528 resolved.insert(action_type, entry);
529 }
530
531 let missing: Vec<&'static str> = REQUIRED_ACTION_TYPES
534 .iter()
535 .filter(|at| !resolved.contains_key(at))
536 .map(|at| at.as_db_str())
537 .collect();
538 if !missing.is_empty() {
539 return Err(Error::Signing(format!(
540 "config: [pds_admin.action_map] is missing entries for the following action types: \
541 {} (every cairn-mod action type must be mapped; use \"skip\" to bypass the bridge \
542 for an action type)",
543 missing.join(", "),
544 )));
545 }
546
547 Ok(resolved)
548}
549
550fn parse_method_string(s: &str, path_for_error: impl Fn() -> String) -> Result<ActionMapEntry> {
555 if s == "skip" {
556 return Ok(ActionMapEntry::Skip);
557 }
558 BackendMethod::from_wire_str(s)
559 .map(ActionMapEntry::Method)
560 .ok_or_else(|| {
561 Error::Signing(format!(
562 "config: {} is not a valid backend method: {:?} \
563 (expected one of takedown_account / suspend_account / restore_account / \
564 apply_label / negate_label / skip)",
565 path_for_error(),
566 s,
567 ))
568 })
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use crate::config::{
575 Config, PdsAdminActionMapTableToml, PdsAdminActionMapValueToml, PdsAdminConfigToml,
576 PdsAdminOzoneToml,
577 };
578
579 const TEST_ENV_VAR: &str = "CAIRN_TEST_PDS_ADMIN_PASSWORD_FIXTURE";
585
586 fn env_reader_with_value(
592 value: &str,
593 ) -> impl Fn(&str) -> std::result::Result<String, std::env::VarError> + use<'_> {
594 move |name: &str| -> std::result::Result<String, std::env::VarError> {
595 if name == TEST_ENV_VAR {
596 Ok(value.to_string())
597 } else {
598 Err(std::env::VarError::NotPresent)
599 }
600 }
601 }
602
603 fn env_reader_unset(_name: &str) -> std::result::Result<String, std::env::VarError> {
607 Err(std::env::VarError::NotPresent)
608 }
609
610 fn from_config_test(cfg: &Config, env_value: &str) -> Result<PdsAdminPolicy> {
615 PdsAdminPolicy::from_config_with_env_reader(cfg, env_reader_with_value(env_value))
616 }
617
618 fn config_with_pds_admin(toml: PdsAdminConfigToml) -> Config {
619 Config {
623 service_did: "did:plc:test".into(),
624 service_endpoint: "https://example.test".into(),
625 bind_addr: crate::config::DEFAULT_BIND_ADDR.parse().unwrap(),
626 db_path: "/tmp/cairn-test.db".into(),
627 signing_key_path: "/tmp/cairn-test.key".into(),
628 admin: Default::default(),
629 labeler: None,
630 operator: None,
631 retention: Default::default(),
632 moderation_reasons: None,
633 strike_policy: None,
634 label_emission: None,
635 policy_automation: None,
636 pds_admin: Some(toml),
637 xrpc_gateway: None,
638 }
639 }
640
641 fn full_action_map_skip_all() -> BTreeMap<String, PdsAdminActionMapValueToml> {
642 let mut m = BTreeMap::new();
643 for at in REQUIRED_ACTION_TYPES {
644 m.insert(
645 at.as_db_str().to_string(),
646 PdsAdminActionMapValueToml::Bare("skip".into()),
647 );
648 }
649 m
650 }
651
652 fn ozone_toml() -> PdsAdminOzoneToml {
653 PdsAdminOzoneToml {
654 pds_url: "https://bsky.example.test".into(),
655 admin_password_env: TEST_ENV_VAR.into(),
656 request_timeout_seconds: 10,
657 }
658 }
659
660 #[test]
665 fn no_block_returns_disabled_defaults() {
666 let mut cfg = config_with_pds_admin(PdsAdminConfigToml::default());
667 cfg.pds_admin = None;
668 let p = PdsAdminPolicy::from_config(&cfg).expect("disabled-default loads");
669 assert!(!p.enabled);
670 assert!(p.backend.is_none());
671 assert!(p.action_map.is_empty());
672 }
673
674 #[test]
675 fn enabled_false_with_subsections_validates_and_returns_disabled() {
676 let cfg = config_with_pds_admin(PdsAdminConfigToml {
681 enabled: false,
682 ozone: Some(ozone_toml()),
683 action_map: None,
684 locus: None,
685 other_backends: BTreeMap::new(),
686 });
687 let p = from_config_test(&cfg, "secret").expect("disabled-with-ozone loads");
688 assert!(!p.enabled);
689 assert!(p.backend.is_none());
690 }
691
692 #[test]
697 fn enabled_with_full_config_resolves() {
698 let cfg = config_with_pds_admin(PdsAdminConfigToml {
699 enabled: true,
700 ozone: Some(ozone_toml()),
701 action_map: Some(full_action_map_skip_all()),
702 locus: None,
703 other_backends: BTreeMap::new(),
704 });
705 let p = from_config_test(&cfg, "secret-value").expect("full config loads");
706 assert!(p.enabled);
707 let PdsAdminBackendConfig::Ozone(ozone) = p.backend.as_ref().expect("backend present");
708 assert_eq!(ozone.pds_url.scheme(), "https");
709 assert_eq!(ozone.pds_url.host_str(), Some("bsky.example.test"));
710 assert_eq!(ozone.admin_password.as_str(), "secret-value");
711 assert_eq!(ozone.request_timeout, Duration::from_secs(10));
712 assert_eq!(p.action_map.len(), 5);
714 for at in REQUIRED_ACTION_TYPES {
715 assert_eq!(p.action_map.get(at), Some(&ActionMapEntry::Skip));
716 }
717 }
718
719 #[test]
720 fn enabled_with_method_mappings_resolves() {
721 let mut m = full_action_map_skip_all();
722 m.insert(
723 "takedown".into(),
724 PdsAdminActionMapValueToml::Bare("takedown_account".into()),
725 );
726 m.insert(
727 "indef_suspension".into(),
728 PdsAdminActionMapValueToml::Bare("takedown_account".into()),
729 );
730 let cfg = config_with_pds_admin(PdsAdminConfigToml {
731 enabled: true,
732 ozone: Some(ozone_toml()),
733 action_map: Some(m),
734 locus: None,
735 other_backends: BTreeMap::new(),
736 });
737 let p = from_config_test(&cfg, "secret").expect("method mappings load");
738 assert_eq!(
739 p.action_map.get(&ActionType::Takedown),
740 Some(&ActionMapEntry::Method(BackendMethod::TakedownAccount)),
741 );
742 assert_eq!(
743 p.action_map.get(&ActionType::IndefSuspension),
744 Some(&ActionMapEntry::Method(BackendMethod::TakedownAccount)),
745 );
746 assert_eq!(
747 p.action_map.get(&ActionType::Warning),
748 Some(&ActionMapEntry::Skip)
749 );
750 }
751
752 #[test]
753 fn enabled_with_table_form_no_lift_resolves() {
754 let mut m = full_action_map_skip_all();
757 m.insert(
758 "temp_suspension".into(),
759 PdsAdminActionMapValueToml::Table(PdsAdminActionMapTableToml {
760 method: "takedown_account".into(),
761 with_lift_after: false,
762 }),
763 );
764 let cfg = config_with_pds_admin(PdsAdminConfigToml {
765 enabled: true,
766 ozone: Some(ozone_toml()),
767 action_map: Some(m),
768 locus: None,
769 other_backends: BTreeMap::new(),
770 });
771 let p = from_config_test(&cfg, "secret").expect("table form no-lift loads");
772 assert_eq!(
773 p.action_map.get(&ActionType::TempSuspension),
774 Some(&ActionMapEntry::Method(BackendMethod::TakedownAccount)),
775 );
776 }
777
778 #[test]
783 fn enabled_without_ozone_subsection_rejects() {
784 let cfg = config_with_pds_admin(PdsAdminConfigToml {
785 enabled: true,
786 ozone: None,
787 action_map: Some(full_action_map_skip_all()),
788 locus: None,
789 other_backends: BTreeMap::new(),
790 });
791 let err = from_config_test(&cfg, "secret").expect_err("no backend rejects");
792 assert!(format!("{err}").contains("no backend subsection"));
793 }
794
795 #[test]
796 fn locus_subsection_rejects_with_v1_8_pointer() {
797 let cfg = config_with_pds_admin(PdsAdminConfigToml {
798 enabled: false, ozone: None,
800 action_map: None,
801 locus: Some(serde_json::json!({"some_field": "value"})),
802 other_backends: BTreeMap::new(),
803 });
804 let err = PdsAdminPolicy::from_config_with_env_reader(&cfg, env_reader_unset)
805 .expect_err("locus rejects");
806 let msg = format!("{err}");
807 assert!(
808 msg.contains("backend not supported in v1.7: locus"),
809 "msg={msg}"
810 );
811 assert!(msg.contains("v1.8"), "msg={msg}");
812 }
813
814 #[test]
815 fn unknown_backend_subsection_rejects() {
816 let mut other = BTreeMap::new();
817 other.insert("ozonee".to_string(), serde_json::json!({})); let cfg = config_with_pds_admin(PdsAdminConfigToml {
819 enabled: false,
820 ozone: None,
821 action_map: None,
822 locus: None,
823 other_backends: other,
824 });
825 let err = PdsAdminPolicy::from_config_with_env_reader(&cfg, env_reader_unset)
826 .expect_err("unknown backend rejects");
827 let msg = format!("{err}");
828 assert!(
829 msg.contains("backend not supported in v1.7: ozonee"),
830 "msg={msg}"
831 );
832 }
833
834 #[test]
839 fn pds_url_must_use_https() {
840 let cfg = config_with_pds_admin(PdsAdminConfigToml {
841 enabled: true,
842 ozone: Some(PdsAdminOzoneToml {
843 pds_url: "http://bsky.example.test".into(),
844 admin_password_env: TEST_ENV_VAR.into(),
845 request_timeout_seconds: 10,
846 }),
847 action_map: Some(full_action_map_skip_all()),
848 locus: None,
849 other_backends: BTreeMap::new(),
850 });
851 let err = from_config_test(&cfg, "secret").expect_err("http rejects");
852 let msg = format!("{err}");
853 assert!(msg.contains("must use https scheme"), "msg={msg}");
854 assert!(msg.contains("got: http"), "msg={msg}");
855 }
856
857 #[test]
858 fn pds_url_malformed_rejects() {
859 let cfg = config_with_pds_admin(PdsAdminConfigToml {
860 enabled: true,
861 ozone: Some(PdsAdminOzoneToml {
862 pds_url: "not a url".into(),
863 admin_password_env: TEST_ENV_VAR.into(),
864 request_timeout_seconds: 10,
865 }),
866 action_map: Some(full_action_map_skip_all()),
867 locus: None,
868 other_backends: BTreeMap::new(),
869 });
870 let err = from_config_test(&cfg, "secret").expect_err("bad url rejects");
871 assert!(format!("{err}").contains("not a valid URL"));
872 }
873
874 #[test]
875 fn admin_password_env_unset_rejects() {
876 let cfg = config_with_pds_admin(PdsAdminConfigToml {
880 enabled: true,
881 ozone: Some(PdsAdminOzoneToml {
882 pds_url: "https://bsky.example.test".into(),
883 admin_password_env: "CAIRN_TEST_PDS_ADMIN_NEVER_SET_8c9f1a".into(),
884 request_timeout_seconds: 10,
885 }),
886 action_map: Some(full_action_map_skip_all()),
887 locus: None,
888 other_backends: BTreeMap::new(),
889 });
890 let err = PdsAdminPolicy::from_config_with_env_reader(&cfg, env_reader_unset)
894 .expect_err("unset env rejects");
895 let msg = format!("{err}");
896 assert!(
897 msg.contains("CAIRN_TEST_PDS_ADMIN_NEVER_SET_8c9f1a"),
898 "msg={msg}"
899 );
900 assert!(msg.contains("is not set"), "msg={msg}");
901 }
902
903 #[test]
904 fn admin_password_env_empty_rejects() {
905 let cfg = config_with_pds_admin(PdsAdminConfigToml {
906 enabled: true,
907 ozone: Some(ozone_toml()),
908 action_map: Some(full_action_map_skip_all()),
909 locus: None,
910 other_backends: BTreeMap::new(),
911 });
912 let err = from_config_test(&cfg, "").expect_err("empty env rejects");
916 assert!(format!("{err}").contains("is not set"));
917 }
918
919 #[test]
920 fn admin_password_env_name_empty_rejects() {
921 let cfg = config_with_pds_admin(PdsAdminConfigToml {
922 enabled: true,
923 ozone: Some(PdsAdminOzoneToml {
924 pds_url: "https://bsky.example.test".into(),
925 admin_password_env: "".into(),
926 request_timeout_seconds: 10,
927 }),
928 action_map: Some(full_action_map_skip_all()),
929 locus: None,
930 other_backends: BTreeMap::new(),
931 });
932 let err = PdsAdminPolicy::from_config_with_env_reader(&cfg, env_reader_unset)
933 .expect_err("empty env name rejects");
934 assert!(format!("{err}").contains("must name an env var"));
935 }
936
937 #[test]
938 fn request_timeout_below_range_rejects() {
939 let cfg = config_with_pds_admin(PdsAdminConfigToml {
940 enabled: true,
941 ozone: Some(PdsAdminOzoneToml {
942 pds_url: "https://bsky.example.test".into(),
943 admin_password_env: TEST_ENV_VAR.into(),
944 request_timeout_seconds: 0,
945 }),
946 action_map: Some(full_action_map_skip_all()),
947 locus: None,
948 other_backends: BTreeMap::new(),
949 });
950 let err = from_config_test(&cfg, "secret").expect_err("0s timeout rejects");
951 assert!(format!("{err}").contains("out of range"));
952 }
953
954 #[test]
955 fn request_timeout_above_range_rejects() {
956 let cfg = config_with_pds_admin(PdsAdminConfigToml {
957 enabled: true,
958 ozone: Some(PdsAdminOzoneToml {
959 pds_url: "https://bsky.example.test".into(),
960 admin_password_env: TEST_ENV_VAR.into(),
961 request_timeout_seconds: 61,
962 }),
963 action_map: Some(full_action_map_skip_all()),
964 locus: None,
965 other_backends: BTreeMap::new(),
966 });
967 let err = from_config_test(&cfg, "secret").expect_err("61s timeout rejects");
968 assert!(format!("{err}").contains("out of range"));
969 }
970
971 #[test]
976 fn action_map_missing_keys_rejects_listing_missing() {
977 let mut m = BTreeMap::new();
978 m.insert(
979 "takedown".into(),
980 PdsAdminActionMapValueToml::Bare("takedown_account".into()),
981 );
982 let cfg = config_with_pds_admin(PdsAdminConfigToml {
984 enabled: true,
985 ozone: Some(ozone_toml()),
986 action_map: Some(m),
987 locus: None,
988 other_backends: BTreeMap::new(),
989 });
990 let err = from_config_test(&cfg, "secret").expect_err("missing keys rejects");
991 let msg = format!("{err}");
992 assert!(msg.contains("missing entries"), "msg={msg}");
993 assert!(msg.contains("indef_suspension"), "msg={msg}");
994 assert!(msg.contains("temp_suspension"), "msg={msg}");
995 assert!(msg.contains("warning"), "msg={msg}");
996 assert!(msg.contains("note"), "msg={msg}");
997 assert!(
998 !msg.contains(": takedown,"),
999 "takedown was supplied; msg={msg}"
1000 );
1001 }
1002
1003 #[test]
1004 fn action_map_invalid_action_type_key_rejects() {
1005 let mut m = full_action_map_skip_all();
1006 m.insert(
1007 "spam".into(), PdsAdminActionMapValueToml::Bare("skip".into()),
1009 );
1010 let cfg = config_with_pds_admin(PdsAdminConfigToml {
1011 enabled: true,
1012 ozone: Some(ozone_toml()),
1013 action_map: Some(m),
1014 locus: None,
1015 other_backends: BTreeMap::new(),
1016 });
1017 let err = from_config_test(&cfg, "secret").expect_err("bad key rejects");
1018 assert!(format!("{err}").contains("not a valid action_type"));
1019 }
1020
1021 #[test]
1022 fn action_map_unknown_method_rejects() {
1023 let mut m = full_action_map_skip_all();
1024 m.insert(
1025 "takedown".into(),
1026 PdsAdminActionMapValueToml::Bare("blast_account".into()),
1027 );
1028 let cfg = config_with_pds_admin(PdsAdminConfigToml {
1029 enabled: true,
1030 ozone: Some(ozone_toml()),
1031 action_map: Some(m),
1032 locus: None,
1033 other_backends: BTreeMap::new(),
1034 });
1035 let err = from_config_test(&cfg, "secret").expect_err("bad method rejects");
1036 let msg = format!("{err}");
1037 assert!(msg.contains("not a valid backend method"), "msg={msg}");
1038 assert!(msg.contains("blast_account"), "msg={msg}");
1039 }
1040
1041 #[test]
1042 fn action_map_label_method_accepted_but_warns() {
1043 let mut m = full_action_map_skip_all();
1049 m.insert(
1050 "warning".into(),
1051 PdsAdminActionMapValueToml::Bare("apply_label".into()),
1052 );
1053 let cfg = config_with_pds_admin(PdsAdminConfigToml {
1054 enabled: true,
1055 ozone: Some(ozone_toml()),
1056 action_map: Some(m),
1057 locus: None,
1058 other_backends: BTreeMap::new(),
1059 });
1060 let p = from_config_test(&cfg, "secret").expect("apply_label accepts");
1061 assert_eq!(
1062 p.action_map.get(&ActionType::Warning),
1063 Some(&ActionMapEntry::Method(BackendMethod::ApplyLabel)),
1064 );
1065 }
1066
1067 #[test]
1072 fn action_map_with_lift_after_true_on_temp_suspension_rejects_v1_7() {
1073 let mut m = full_action_map_skip_all();
1074 m.insert(
1075 "temp_suspension".into(),
1076 PdsAdminActionMapValueToml::Table(PdsAdminActionMapTableToml {
1077 method: "takedown_account".into(),
1078 with_lift_after: true,
1079 }),
1080 );
1081 let cfg = config_with_pds_admin(PdsAdminConfigToml {
1082 enabled: true,
1083 ozone: Some(ozone_toml()),
1084 action_map: Some(m),
1085 locus: None,
1086 other_backends: BTreeMap::new(),
1087 });
1088 let err =
1089 from_config_test(&cfg, "secret").expect_err("with_lift_after = true rejects in v1.7");
1090 let msg = format!("{err}");
1091 assert!(msg.contains("deferred-execution layer"), "msg={msg}");
1092 assert!(msg.contains("v1.8"), "msg={msg}");
1093 }
1094
1095 #[test]
1096 fn action_map_with_lift_after_true_on_non_temp_rejects() {
1097 let mut m = full_action_map_skip_all();
1102 m.insert(
1103 "takedown".into(),
1104 PdsAdminActionMapValueToml::Table(PdsAdminActionMapTableToml {
1105 method: "takedown_account".into(),
1106 with_lift_after: true,
1107 }),
1108 );
1109 let cfg = config_with_pds_admin(PdsAdminConfigToml {
1110 enabled: true,
1111 ozone: Some(ozone_toml()),
1112 action_map: Some(m),
1113 locus: None,
1114 other_backends: BTreeMap::new(),
1115 });
1116 let err =
1117 from_config_test(&cfg, "secret").expect_err("with_lift_after on takedown rejects");
1118 let msg = format!("{err}");
1119 assert!(msg.contains("only valid for temp_suspension"), "msg={msg}");
1120 }
1121
1122 #[test]
1127 fn enabled_without_action_map_rejects() {
1128 let cfg = config_with_pds_admin(PdsAdminConfigToml {
1129 enabled: true,
1130 ozone: Some(ozone_toml()),
1131 action_map: None,
1132 locus: None,
1133 other_backends: BTreeMap::new(),
1134 });
1135 let err = from_config_test(&cfg, "secret").expect_err("missing action_map rejects");
1136 assert!(format!("{err}").contains("[pds_admin.action_map] is absent"));
1137 }
1138
1139 #[test]
1144 fn admin_password_debug_redacts() {
1145 let pw = AdminPassword::new("very-secret-password".into());
1146 let dbg = format!("{pw:?}");
1147 assert!(!dbg.contains("very-secret-password"));
1148 assert!(dbg.contains("redacted"));
1149 }
1150
1151 #[test]
1152 fn admin_password_as_str_returns_value() {
1153 let pw = AdminPassword::new("my-secret".into());
1154 assert_eq!(pw.as_str(), "my-secret");
1155 }
1156
1157 #[test]
1162 fn backend_method_string_roundtrip() {
1163 for m in [
1164 BackendMethod::TakedownAccount,
1165 BackendMethod::SuspendAccount,
1166 BackendMethod::RestoreAccount,
1167 BackendMethod::ApplyLabel,
1168 BackendMethod::NegateLabel,
1169 ] {
1170 assert_eq!(BackendMethod::from_wire_str(m.as_wire_str()), Some(m));
1171 }
1172 }
1173
1174 #[test]
1175 fn backend_method_unknown_returns_none() {
1176 assert!(BackendMethod::from_wire_str("ban_user").is_none());
1177 assert!(BackendMethod::from_wire_str("").is_none());
1178 assert!(BackendMethod::from_wire_str("skip").is_none()); }
1180}