use ppoppo_sdk_core::scopes::ConsentScopes;
#[derive(Debug, Clone, thiserror::Error)]
#[error(
"granted scope does not cover the requested tier — missing [{}] (requested [{}], granted [{}])",
missing.join(" "),
requested.join(" "),
granted.join(" ")
)]
pub struct ScopeNotCovered {
pub requested: Vec<String>,
pub granted: Vec<String>,
pub missing: Vec<String>,
}
pub fn ensure_covers<S: ConsentScopes>(granted: Option<&str>) -> Result<(), ScopeNotCovered> {
let Some(granted) = granted else {
return Ok(());
};
let granted_atoms: Vec<&str> = granted.split_whitespace().collect();
let missing: Vec<String> = S::SCOPES
.iter()
.filter(|requested| !granted_atoms.contains(*requested))
.map(|s| (*s).to_owned())
.collect();
if missing.is_empty() {
return Ok(());
}
Err(ScopeNotCovered {
requested: S::SCOPES.iter().map(|s| (*s).to_owned()).collect(),
granted: granted_atoms.into_iter().map(str::to_owned).collect(),
missing,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
struct Notify;
impl ConsentScopes for Notify {
const SCOPES: &'static [&'static str] = &["chat.read", "contact.read", "chat.ack"];
}
#[test]
fn absent_echo_passes_as_granted_identically() {
assert!(ensure_covers::<Notify>(None).is_ok());
}
#[test]
fn exact_echo_passes() {
assert!(ensure_covers::<Notify>(Some("chat.read contact.read chat.ack")).is_ok());
}
#[test]
fn reordered_echo_passes() {
assert!(ensure_covers::<Notify>(Some("chat.ack chat.read contact.read")).is_ok());
}
#[test]
fn irregular_whitespace_passes() {
assert!(ensure_covers::<Notify>(Some(" chat.read contact.read\tchat.ack ")).is_ok());
}
#[test]
fn superset_grant_passes() {
assert!(
ensure_covers::<Notify>(Some("chat.read contact.read chat.ack chat.send")).is_ok()
);
}
#[test]
fn narrowed_grant_is_a_typed_error_naming_what_is_missing() {
let err = ensure_covers::<Notify>(Some("chat.read contact.read"))
.expect_err("a grant missing chat.ack must not pass");
assert_eq!(err.missing, ["chat.ack"]);
assert_eq!(err.granted, ["chat.read", "contact.read"]);
assert_eq!(err.requested, ["chat.read", "contact.read", "chat.ack"]);
assert!(err.to_string().contains("chat.ack"), "{err}");
}
#[test]
fn empty_echo_fails_for_a_non_empty_request() {
let err = ensure_covers::<Notify>(Some("")).expect_err("empty grant covers nothing");
assert_eq!(err.missing.len(), 3);
}
#[test]
fn atoms_match_whole_not_by_prefix() {
struct ReadOnly;
impl ConsentScopes for ReadOnly {
const SCOPES: &'static [&'static str] = &["chat.read"];
}
assert!(ensure_covers::<ReadOnly>(Some("chat.readonly")).is_err());
assert!(ensure_covers::<ReadOnly>(Some("chat.read")).is_ok());
}
}