use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;
use serde::Serialize;
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::error::{Error, Result};
use crate::moderation::types::ActionType;
const REQUIRED_ACTION_TYPES: &[ActionType] = &[
ActionType::Takedown,
ActionType::IndefSuspension,
ActionType::TempSuspension,
ActionType::Warning,
ActionType::Note,
];
#[derive(Debug, Clone)]
pub struct PdsAdminPolicy {
pub enabled: bool,
pub backend: Option<PdsAdminBackendConfig>,
pub action_map: BTreeMap<ActionType, ActionMapEntry>,
}
#[derive(Debug, Clone)]
pub enum PdsAdminBackendConfig {
Ozone(OzoneBackendConfig),
}
#[derive(Debug, Clone)]
pub struct OzoneBackendConfig {
pub pds_url: url::Url,
pub admin_password: AdminPassword,
pub request_timeout: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionMapEntry {
Skip,
Method(BackendMethod),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BackendMethod {
TakedownAccount,
SuspendAccount,
RestoreAccount,
ApplyLabel,
NegateLabel,
}
impl BackendMethod {
pub(crate) fn from_wire_str(s: &str) -> Option<Self> {
match s {
"takedown_account" => Some(Self::TakedownAccount),
"suspend_account" => Some(Self::SuspendAccount),
"restore_account" => Some(Self::RestoreAccount),
"apply_label" => Some(Self::ApplyLabel),
"negate_label" => Some(Self::NegateLabel),
_ => None,
}
}
pub fn as_wire_str(self) -> &'static str {
match self {
Self::TakedownAccount => "takedown_account",
Self::SuspendAccount => "suspend_account",
Self::RestoreAccount => "restore_account",
Self::ApplyLabel => "apply_label",
Self::NegateLabel => "negate_label",
}
}
pub fn is_implemented_by_ozone_v1_7(self) -> bool {
match self {
Self::TakedownAccount | Self::SuspendAccount | Self::RestoreAccount => true,
Self::ApplyLabel | Self::NegateLabel => false,
}
}
pub fn returns_action_id(self) -> bool {
match self {
Self::TakedownAccount | Self::SuspendAccount => true,
Self::RestoreAccount | Self::ApplyLabel | Self::NegateLabel => false,
}
}
}
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct AdminPassword(String);
impl AdminPassword {
pub fn new(s: String) -> Self {
Self(s)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for AdminPassword {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("AdminPassword").field(&"<redacted>").finish()
}
}
impl PdsAdminPolicy {
pub fn from_config(cfg: &crate::config::Config) -> Result<Self> {
let Some(toml) = cfg.pds_admin.as_ref() else {
return Ok(Self::defaults());
};
Self::validated_from_toml(toml, |name| std::env::var(name))
}
#[cfg(test)]
pub(crate) fn from_config_with_env_reader<F>(
cfg: &crate::config::Config,
read_env: F,
) -> Result<Self>
where
F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
{
let Some(toml) = cfg.pds_admin.as_ref() else {
return Ok(Self::defaults());
};
Self::validated_from_toml(toml, read_env)
}
pub fn defaults() -> Self {
Self {
enabled: false,
backend: None,
action_map: BTreeMap::new(),
}
}
fn validated_from_toml<F>(toml: &crate::config::PdsAdminConfigToml, read_env: F) -> Result<Self>
where
F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
{
reject_unsupported_backend_subsections(toml)?;
let resolved_ozone = toml
.ozone
.as_ref()
.map(|t| validated_ozone_from_toml(t, &read_env))
.transpose()?;
if !toml.enabled {
return Ok(Self {
enabled: false,
backend: None,
action_map: BTreeMap::new(),
});
}
let backend = match resolved_ozone {
Some(ozone) => PdsAdminBackendConfig::Ozone(ozone),
None => {
return Err(Error::Signing(
"config: [pds_admin].enabled = true but no backend subsection is present \
(v1.7 supports [pds_admin.ozone] only)"
.into(),
));
}
};
let action_map_toml = toml.action_map.as_ref().ok_or_else(|| {
Error::Signing(
"config: [pds_admin].enabled = true but [pds_admin.action_map] is absent \
(every cairn-mod action type must be mapped to a backend method or \"skip\")"
.into(),
)
})?;
let action_map = validated_action_map(action_map_toml)?;
Ok(Self {
enabled: true,
backend: Some(backend),
action_map,
})
}
}
fn reject_unsupported_backend_subsections(toml: &crate::config::PdsAdminConfigToml) -> Result<()> {
if toml.locus.is_some() {
return Err(Error::Signing(
"config: backend not supported in v1.7: locus \
(Aurora-Locus support is deferred to v1.8; remove [pds_admin.locus] \
or wait for v1.8)"
.into(),
));
}
if let Some(unknown) = toml.other_backends.keys().next() {
return Err(Error::Signing(format!(
"config: backend not supported in v1.7: {unknown} \
(v1.7 supports [pds_admin.ozone] only; check for typos in subsection name)"
)));
}
Ok(())
}
fn validated_ozone_from_toml<F>(
toml: &crate::config::PdsAdminOzoneToml,
read_env: &F,
) -> Result<OzoneBackendConfig>
where
F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
{
let pds_url = url::Url::parse(&toml.pds_url).map_err(|e| {
Error::Signing(format!(
"config: [pds_admin.ozone].pds_url is not a valid URL: {e}"
))
})?;
if pds_url.scheme() != "https" {
return Err(Error::Signing(format!(
"config: [pds_admin.ozone].pds_url must use https scheme; got: {}",
pds_url.scheme()
)));
}
if toml.admin_password_env.is_empty() {
return Err(Error::Signing(
"config: [pds_admin.ozone].admin_password_env must name an env var \
(got empty string)"
.into(),
));
}
let env_value = read_env(&toml.admin_password_env).map_err(|_| {
Error::Signing(format!(
"config: env var ${} referenced by [pds_admin.ozone].admin_password_env is not set",
toml.admin_password_env
))
})?;
if env_value.is_empty() {
return Err(Error::Signing(format!(
"config: env var ${} referenced by [pds_admin.ozone].admin_password_env is not set",
toml.admin_password_env
)));
}
if !(1..=60).contains(&toml.request_timeout_seconds) {
return Err(Error::Signing(format!(
"config: [pds_admin.ozone].request_timeout_seconds = {} is out of range (1..=60)",
toml.request_timeout_seconds
)));
}
Ok(OzoneBackendConfig {
pds_url,
admin_password: AdminPassword::new(env_value),
request_timeout: Duration::from_secs(u64::from(toml.request_timeout_seconds)),
})
}
fn validated_action_map(
toml: &BTreeMap<String, crate::config::PdsAdminActionMapValueToml>,
) -> Result<BTreeMap<ActionType, ActionMapEntry>> {
let mut resolved: BTreeMap<ActionType, ActionMapEntry> = BTreeMap::new();
let mut warned_methods: BTreeSet<(ActionType, BackendMethod)> = BTreeSet::new();
for (raw_key, raw_value) in toml {
let action_type = ActionType::from_db_str(raw_key).ok_or_else(|| {
Error::Signing(format!(
"config: [pds_admin.action_map].{raw_key} is not a valid action_type \
(expected one of warning / note / temp_suspension / indef_suspension / takedown)"
))
})?;
let entry = match raw_value {
crate::config::PdsAdminActionMapValueToml::Bare(s) => parse_method_string(s, || {
format!("[pds_admin.action_map].{}", action_type.as_db_str())
})?,
crate::config::PdsAdminActionMapValueToml::Table(table) => {
if table.with_lift_after && !matches!(action_type, ActionType::TempSuspension) {
return Err(Error::Signing(format!(
"config: [pds_admin.action_map].{} sets with_lift_after = true; \
this option is only valid for temp_suspension",
action_type.as_db_str()
)));
}
if table.with_lift_after {
return Err(Error::Signing(
"config: [pds_admin.action_map] with_lift_after = true requires a \
deferred-execution layer not present in v1.7; deferred to v1.8. \
Configure temp_suspension as bare \"takedown_account\" and lift \
manually via CLI in v1.7."
.into(),
));
}
parse_method_string(&table.method, || {
format!("[pds_admin.action_map].{}.method", action_type.as_db_str())
})?
}
};
if let ActionMapEntry::Method(method) = entry
&& !method.is_implemented_by_ozone_v1_7()
&& warned_methods.insert((action_type, method))
{
tracing::warn!(
"config: [pds_admin.action_map].{} maps to {}, but OzoneBackend does not \
implement label methods in v1.7; this action will not propagate to the PDS \
(#89: returns BackendError::Unsupported at runtime)",
action_type.as_db_str(),
method.as_wire_str(),
);
}
resolved.insert(action_type, entry);
}
let missing: Vec<&'static str> = REQUIRED_ACTION_TYPES
.iter()
.filter(|at| !resolved.contains_key(at))
.map(|at| at.as_db_str())
.collect();
if !missing.is_empty() {
return Err(Error::Signing(format!(
"config: [pds_admin.action_map] is missing entries for the following action types: \
{} (every cairn-mod action type must be mapped; use \"skip\" to bypass the bridge \
for an action type)",
missing.join(", "),
)));
}
Ok(resolved)
}
fn parse_method_string(s: &str, path_for_error: impl Fn() -> String) -> Result<ActionMapEntry> {
if s == "skip" {
return Ok(ActionMapEntry::Skip);
}
BackendMethod::from_wire_str(s)
.map(ActionMapEntry::Method)
.ok_or_else(|| {
Error::Signing(format!(
"config: {} is not a valid backend method: {:?} \
(expected one of takedown_account / suspend_account / restore_account / \
apply_label / negate_label / skip)",
path_for_error(),
s,
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
Config, PdsAdminActionMapTableToml, PdsAdminActionMapValueToml, PdsAdminConfigToml,
PdsAdminOzoneToml,
};
const TEST_ENV_VAR: &str = "CAIRN_TEST_PDS_ADMIN_PASSWORD_FIXTURE";
fn env_reader_with_value(
value: &str,
) -> impl Fn(&str) -> std::result::Result<String, std::env::VarError> + use<'_> {
move |name: &str| -> std::result::Result<String, std::env::VarError> {
if name == TEST_ENV_VAR {
Ok(value.to_string())
} else {
Err(std::env::VarError::NotPresent)
}
}
}
fn env_reader_unset(_name: &str) -> std::result::Result<String, std::env::VarError> {
Err(std::env::VarError::NotPresent)
}
fn from_config_test(cfg: &Config, env_value: &str) -> Result<PdsAdminPolicy> {
PdsAdminPolicy::from_config_with_env_reader(cfg, env_reader_with_value(env_value))
}
fn config_with_pds_admin(toml: PdsAdminConfigToml) -> Config {
Config {
service_did: "did:plc:test".into(),
service_endpoint: "https://example.test".into(),
bind_addr: crate::config::DEFAULT_BIND_ADDR.parse().unwrap(),
db_path: "/tmp/cairn-test.db".into(),
signing_key_path: "/tmp/cairn-test.key".into(),
admin: Default::default(),
labeler: None,
operator: None,
retention: Default::default(),
moderation_reasons: None,
strike_policy: None,
label_emission: None,
policy_automation: None,
pds_admin: Some(toml),
xrpc_gateway: None,
}
}
fn full_action_map_skip_all() -> BTreeMap<String, PdsAdminActionMapValueToml> {
let mut m = BTreeMap::new();
for at in REQUIRED_ACTION_TYPES {
m.insert(
at.as_db_str().to_string(),
PdsAdminActionMapValueToml::Bare("skip".into()),
);
}
m
}
fn ozone_toml() -> PdsAdminOzoneToml {
PdsAdminOzoneToml {
pds_url: "https://bsky.example.test".into(),
admin_password_env: TEST_ENV_VAR.into(),
request_timeout_seconds: 10,
}
}
#[test]
fn no_block_returns_disabled_defaults() {
let mut cfg = config_with_pds_admin(PdsAdminConfigToml::default());
cfg.pds_admin = None;
let p = PdsAdminPolicy::from_config(&cfg).expect("disabled-default loads");
assert!(!p.enabled);
assert!(p.backend.is_none());
assert!(p.action_map.is_empty());
}
#[test]
fn enabled_false_with_subsections_validates_and_returns_disabled() {
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: false,
ozone: Some(ozone_toml()),
action_map: None,
locus: None,
other_backends: BTreeMap::new(),
});
let p = from_config_test(&cfg, "secret").expect("disabled-with-ozone loads");
assert!(!p.enabled);
assert!(p.backend.is_none());
}
#[test]
fn enabled_with_full_config_resolves() {
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(ozone_toml()),
action_map: Some(full_action_map_skip_all()),
locus: None,
other_backends: BTreeMap::new(),
});
let p = from_config_test(&cfg, "secret-value").expect("full config loads");
assert!(p.enabled);
let PdsAdminBackendConfig::Ozone(ozone) = p.backend.as_ref().expect("backend present");
assert_eq!(ozone.pds_url.scheme(), "https");
assert_eq!(ozone.pds_url.host_str(), Some("bsky.example.test"));
assert_eq!(ozone.admin_password.as_str(), "secret-value");
assert_eq!(ozone.request_timeout, Duration::from_secs(10));
assert_eq!(p.action_map.len(), 5);
for at in REQUIRED_ACTION_TYPES {
assert_eq!(p.action_map.get(at), Some(&ActionMapEntry::Skip));
}
}
#[test]
fn enabled_with_method_mappings_resolves() {
let mut m = full_action_map_skip_all();
m.insert(
"takedown".into(),
PdsAdminActionMapValueToml::Bare("takedown_account".into()),
);
m.insert(
"indef_suspension".into(),
PdsAdminActionMapValueToml::Bare("takedown_account".into()),
);
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(ozone_toml()),
action_map: Some(m),
locus: None,
other_backends: BTreeMap::new(),
});
let p = from_config_test(&cfg, "secret").expect("method mappings load");
assert_eq!(
p.action_map.get(&ActionType::Takedown),
Some(&ActionMapEntry::Method(BackendMethod::TakedownAccount)),
);
assert_eq!(
p.action_map.get(&ActionType::IndefSuspension),
Some(&ActionMapEntry::Method(BackendMethod::TakedownAccount)),
);
assert_eq!(
p.action_map.get(&ActionType::Warning),
Some(&ActionMapEntry::Skip)
);
}
#[test]
fn enabled_with_table_form_no_lift_resolves() {
let mut m = full_action_map_skip_all();
m.insert(
"temp_suspension".into(),
PdsAdminActionMapValueToml::Table(PdsAdminActionMapTableToml {
method: "takedown_account".into(),
with_lift_after: false,
}),
);
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(ozone_toml()),
action_map: Some(m),
locus: None,
other_backends: BTreeMap::new(),
});
let p = from_config_test(&cfg, "secret").expect("table form no-lift loads");
assert_eq!(
p.action_map.get(&ActionType::TempSuspension),
Some(&ActionMapEntry::Method(BackendMethod::TakedownAccount)),
);
}
#[test]
fn enabled_without_ozone_subsection_rejects() {
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: None,
action_map: Some(full_action_map_skip_all()),
locus: None,
other_backends: BTreeMap::new(),
});
let err = from_config_test(&cfg, "secret").expect_err("no backend rejects");
assert!(format!("{err}").contains("no backend subsection"));
}
#[test]
fn locus_subsection_rejects_with_v1_8_pointer() {
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: false, ozone: None,
action_map: None,
locus: Some(serde_json::json!({"some_field": "value"})),
other_backends: BTreeMap::new(),
});
let err = PdsAdminPolicy::from_config_with_env_reader(&cfg, env_reader_unset)
.expect_err("locus rejects");
let msg = format!("{err}");
assert!(
msg.contains("backend not supported in v1.7: locus"),
"msg={msg}"
);
assert!(msg.contains("v1.8"), "msg={msg}");
}
#[test]
fn unknown_backend_subsection_rejects() {
let mut other = BTreeMap::new();
other.insert("ozonee".to_string(), serde_json::json!({})); let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: false,
ozone: None,
action_map: None,
locus: None,
other_backends: other,
});
let err = PdsAdminPolicy::from_config_with_env_reader(&cfg, env_reader_unset)
.expect_err("unknown backend rejects");
let msg = format!("{err}");
assert!(
msg.contains("backend not supported in v1.7: ozonee"),
"msg={msg}"
);
}
#[test]
fn pds_url_must_use_https() {
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(PdsAdminOzoneToml {
pds_url: "http://bsky.example.test".into(),
admin_password_env: TEST_ENV_VAR.into(),
request_timeout_seconds: 10,
}),
action_map: Some(full_action_map_skip_all()),
locus: None,
other_backends: BTreeMap::new(),
});
let err = from_config_test(&cfg, "secret").expect_err("http rejects");
let msg = format!("{err}");
assert!(msg.contains("must use https scheme"), "msg={msg}");
assert!(msg.contains("got: http"), "msg={msg}");
}
#[test]
fn pds_url_malformed_rejects() {
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(PdsAdminOzoneToml {
pds_url: "not a url".into(),
admin_password_env: TEST_ENV_VAR.into(),
request_timeout_seconds: 10,
}),
action_map: Some(full_action_map_skip_all()),
locus: None,
other_backends: BTreeMap::new(),
});
let err = from_config_test(&cfg, "secret").expect_err("bad url rejects");
assert!(format!("{err}").contains("not a valid URL"));
}
#[test]
fn admin_password_env_unset_rejects() {
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(PdsAdminOzoneToml {
pds_url: "https://bsky.example.test".into(),
admin_password_env: "CAIRN_TEST_PDS_ADMIN_NEVER_SET_8c9f1a".into(),
request_timeout_seconds: 10,
}),
action_map: Some(full_action_map_skip_all()),
locus: None,
other_backends: BTreeMap::new(),
});
let err = PdsAdminPolicy::from_config_with_env_reader(&cfg, env_reader_unset)
.expect_err("unset env rejects");
let msg = format!("{err}");
assert!(
msg.contains("CAIRN_TEST_PDS_ADMIN_NEVER_SET_8c9f1a"),
"msg={msg}"
);
assert!(msg.contains("is not set"), "msg={msg}");
}
#[test]
fn admin_password_env_empty_rejects() {
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(ozone_toml()),
action_map: Some(full_action_map_skip_all()),
locus: None,
other_backends: BTreeMap::new(),
});
let err = from_config_test(&cfg, "").expect_err("empty env rejects");
assert!(format!("{err}").contains("is not set"));
}
#[test]
fn admin_password_env_name_empty_rejects() {
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(PdsAdminOzoneToml {
pds_url: "https://bsky.example.test".into(),
admin_password_env: "".into(),
request_timeout_seconds: 10,
}),
action_map: Some(full_action_map_skip_all()),
locus: None,
other_backends: BTreeMap::new(),
});
let err = PdsAdminPolicy::from_config_with_env_reader(&cfg, env_reader_unset)
.expect_err("empty env name rejects");
assert!(format!("{err}").contains("must name an env var"));
}
#[test]
fn request_timeout_below_range_rejects() {
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(PdsAdminOzoneToml {
pds_url: "https://bsky.example.test".into(),
admin_password_env: TEST_ENV_VAR.into(),
request_timeout_seconds: 0,
}),
action_map: Some(full_action_map_skip_all()),
locus: None,
other_backends: BTreeMap::new(),
});
let err = from_config_test(&cfg, "secret").expect_err("0s timeout rejects");
assert!(format!("{err}").contains("out of range"));
}
#[test]
fn request_timeout_above_range_rejects() {
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(PdsAdminOzoneToml {
pds_url: "https://bsky.example.test".into(),
admin_password_env: TEST_ENV_VAR.into(),
request_timeout_seconds: 61,
}),
action_map: Some(full_action_map_skip_all()),
locus: None,
other_backends: BTreeMap::new(),
});
let err = from_config_test(&cfg, "secret").expect_err("61s timeout rejects");
assert!(format!("{err}").contains("out of range"));
}
#[test]
fn action_map_missing_keys_rejects_listing_missing() {
let mut m = BTreeMap::new();
m.insert(
"takedown".into(),
PdsAdminActionMapValueToml::Bare("takedown_account".into()),
);
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(ozone_toml()),
action_map: Some(m),
locus: None,
other_backends: BTreeMap::new(),
});
let err = from_config_test(&cfg, "secret").expect_err("missing keys rejects");
let msg = format!("{err}");
assert!(msg.contains("missing entries"), "msg={msg}");
assert!(msg.contains("indef_suspension"), "msg={msg}");
assert!(msg.contains("temp_suspension"), "msg={msg}");
assert!(msg.contains("warning"), "msg={msg}");
assert!(msg.contains("note"), "msg={msg}");
assert!(
!msg.contains(": takedown,"),
"takedown was supplied; msg={msg}"
);
}
#[test]
fn action_map_invalid_action_type_key_rejects() {
let mut m = full_action_map_skip_all();
m.insert(
"spam".into(), PdsAdminActionMapValueToml::Bare("skip".into()),
);
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(ozone_toml()),
action_map: Some(m),
locus: None,
other_backends: BTreeMap::new(),
});
let err = from_config_test(&cfg, "secret").expect_err("bad key rejects");
assert!(format!("{err}").contains("not a valid action_type"));
}
#[test]
fn action_map_unknown_method_rejects() {
let mut m = full_action_map_skip_all();
m.insert(
"takedown".into(),
PdsAdminActionMapValueToml::Bare("blast_account".into()),
);
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(ozone_toml()),
action_map: Some(m),
locus: None,
other_backends: BTreeMap::new(),
});
let err = from_config_test(&cfg, "secret").expect_err("bad method rejects");
let msg = format!("{err}");
assert!(msg.contains("not a valid backend method"), "msg={msg}");
assert!(msg.contains("blast_account"), "msg={msg}");
}
#[test]
fn action_map_label_method_accepted_but_warns() {
let mut m = full_action_map_skip_all();
m.insert(
"warning".into(),
PdsAdminActionMapValueToml::Bare("apply_label".into()),
);
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(ozone_toml()),
action_map: Some(m),
locus: None,
other_backends: BTreeMap::new(),
});
let p = from_config_test(&cfg, "secret").expect("apply_label accepts");
assert_eq!(
p.action_map.get(&ActionType::Warning),
Some(&ActionMapEntry::Method(BackendMethod::ApplyLabel)),
);
}
#[test]
fn action_map_with_lift_after_true_on_temp_suspension_rejects_v1_7() {
let mut m = full_action_map_skip_all();
m.insert(
"temp_suspension".into(),
PdsAdminActionMapValueToml::Table(PdsAdminActionMapTableToml {
method: "takedown_account".into(),
with_lift_after: true,
}),
);
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(ozone_toml()),
action_map: Some(m),
locus: None,
other_backends: BTreeMap::new(),
});
let err =
from_config_test(&cfg, "secret").expect_err("with_lift_after = true rejects in v1.7");
let msg = format!("{err}");
assert!(msg.contains("deferred-execution layer"), "msg={msg}");
assert!(msg.contains("v1.8"), "msg={msg}");
}
#[test]
fn action_map_with_lift_after_true_on_non_temp_rejects() {
let mut m = full_action_map_skip_all();
m.insert(
"takedown".into(),
PdsAdminActionMapValueToml::Table(PdsAdminActionMapTableToml {
method: "takedown_account".into(),
with_lift_after: true,
}),
);
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(ozone_toml()),
action_map: Some(m),
locus: None,
other_backends: BTreeMap::new(),
});
let err =
from_config_test(&cfg, "secret").expect_err("with_lift_after on takedown rejects");
let msg = format!("{err}");
assert!(msg.contains("only valid for temp_suspension"), "msg={msg}");
}
#[test]
fn enabled_without_action_map_rejects() {
let cfg = config_with_pds_admin(PdsAdminConfigToml {
enabled: true,
ozone: Some(ozone_toml()),
action_map: None,
locus: None,
other_backends: BTreeMap::new(),
});
let err = from_config_test(&cfg, "secret").expect_err("missing action_map rejects");
assert!(format!("{err}").contains("[pds_admin.action_map] is absent"));
}
#[test]
fn admin_password_debug_redacts() {
let pw = AdminPassword::new("very-secret-password".into());
let dbg = format!("{pw:?}");
assert!(!dbg.contains("very-secret-password"));
assert!(dbg.contains("redacted"));
}
#[test]
fn admin_password_as_str_returns_value() {
let pw = AdminPassword::new("my-secret".into());
assert_eq!(pw.as_str(), "my-secret");
}
#[test]
fn backend_method_string_roundtrip() {
for m in [
BackendMethod::TakedownAccount,
BackendMethod::SuspendAccount,
BackendMethod::RestoreAccount,
BackendMethod::ApplyLabel,
BackendMethod::NegateLabel,
] {
assert_eq!(BackendMethod::from_wire_str(m.as_wire_str()), Some(m));
}
}
#[test]
fn backend_method_unknown_returns_none() {
assert!(BackendMethod::from_wire_str("ban_user").is_none());
assert!(BackendMethod::from_wire_str("").is_none());
assert!(BackendMethod::from_wire_str("skip").is_none()); }
}