use serde::{Deserialize, Serialize};
use crate::membership::{
CONTRACT_VERSION, GrantLog, MembershipDecision, MembershipRequest, Verdict,
};
use crate::rights::{Mode, Resource};
pub const WORLD: &str = include_str!("../fixtures/membership/world.json");
pub const CASES: &str = include_str!("../fixtures/membership/cases.json");
pub const CASE_COUNT: usize = 20;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Kind {
pub kind: String,
pub actions: Vec<String>,
pub parents: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorldSubject {
pub label: String,
pub kind: String,
pub responsible: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Placement {
pub child: Resource,
pub parent: Resource,
pub restricted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorldGrant {
pub label: String,
pub holder: String,
pub resource: Resource,
pub actions: Vec<String>,
pub mode: Mode,
pub revoked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct World {
pub log: GrantLog,
pub kinds: Vec<Kind>,
pub subjects: Vec<WorldSubject>,
pub placements: Vec<Placement>,
pub grants: Vec<WorldGrant>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
pub enum Expected {
Allowed {
grant: String,
scope: Resource,
},
Held {
grant: String,
scope: Resource,
mode: Mode,
},
Refused {
refusal: String,
decided: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Case {
pub name: String,
pub request: MembershipRequest,
pub expected: Expected,
}
pub fn world() -> Result<World, serde_json::Error> {
serde_json::from_str(WORLD)
}
pub fn cases() -> Result<Vec<Case>, serde_json::Error> {
serde_json::from_str(CASES)
}
#[must_use]
pub fn expected_decision(case: &Case, revision: u64) -> MembershipDecision {
let decided = match &case.expected {
Expected::Allowed { .. } | Expected::Held { .. } => true,
Expected::Refused { decided, .. } => *decided,
};
let verdict = match &case.expected {
Expected::Allowed { grant, scope } => Verdict::Allowed {
grant: grant.clone(),
path: Vec::new(),
scope: scope.clone(),
},
Expected::Held { grant, scope, mode } => Verdict::Held {
grant: grant.clone(),
scope: scope.clone(),
mode: *mode,
},
Expected::Refused { refusal, .. } => Verdict::Refused {
refusal: refusal.clone(),
reason: String::new(),
},
};
let log = world().map_or_else(|_| case.request.log.clone(), |world| world.log);
MembershipDecision {
contract: CONTRACT_VERSION,
log,
request: case.request.clone(),
revision: decided.then_some(revision.max(case.request.at_least)),
verdict,
}
}
fn mismatch(case: &Case, served: &GrantLog, actual: &MembershipDecision) -> Option<String> {
if actual.contract != CONTRACT_VERSION {
return Some(format!("answered under contract {}", actual.contract));
}
if &actual.log != served {
return Some(format!("answered from log {:?}", actual.log));
}
if actual.request != case.request {
return Some("the answer echoes another request".to_owned());
}
let (verdict_matches, decided) = match (&case.expected, &actual.verdict) {
(
Expected::Allowed { grant, scope },
Verdict::Allowed {
grant: got,
scope: reached,
..
},
) => (grant == got && scope == reached, true),
(
Expected::Held { grant, scope, mode },
Verdict::Held {
grant: got,
scope: reached,
mode: held,
},
) => (grant == got && scope == reached && mode == held, true),
(Expected::Refused { refusal, decided }, Verdict::Refused { refusal: got, .. }) => {
(refusal == got, *decided)
}
_ => (false, true),
};
if !verdict_matches {
return Some(format!(
"expected {:?}, received {:?}",
case.expected, actual.verdict
));
}
let revision_holds = if decided {
actual
.revision
.is_some_and(|revision| revision >= case.request.at_least)
} else {
actual.revision.is_none()
};
if revision_holds {
None
} else {
Some(format!(
"decided {decided} with revision {:?} for at_least {}",
actual.revision, case.request.at_least
))
}
}
#[derive(Debug)]
pub struct Failure {
pub case: String,
pub reason: String,
}
#[derive(Debug)]
pub struct Report {
pub run: usize,
pub passed: usize,
pub failures: Vec<Failure>,
}
impl Report {
pub fn require_conformant(&self) -> Result<(), Nonconformant> {
if self.passed == self.run && self.run == CASE_COUNT && self.failures.is_empty() {
Ok(())
} else {
Err(Nonconformant {
run: self.run,
passed: self.passed,
})
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("membership_nonconformance: {passed}/{run} vectors matched")]
pub struct Nonconformant {
pub run: usize,
pub passed: usize,
}
pub fn run<E: std::fmt::Display>(
mut observe: impl FnMut(&Case) -> Result<MembershipDecision, E>,
) -> Result<Report, serde_json::Error> {
let served = world()?.log;
let cases = cases()?;
let mut report = Report {
run: cases.len(),
passed: 0,
failures: Vec::new(),
};
for case in &cases {
match observe(case) {
Ok(actual) => match mismatch(case, &served, &actual) {
None => report.passed += 1,
Some(reason) => report.failures.push(Failure {
case: case.name.clone(),
reason,
}),
},
Err(error) => report.failures.push(Failure {
case: case.name.clone(),
reason: error.to_string(),
}),
}
}
Ok(report)
}