1use crate::refusal::Refusal;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Outcome {
8 Allowed(String),
10 NamedRefusal(String),
12 PermissionRefusal(String),
14}
15
16#[derive(Debug, Clone)]
18pub struct Case {
19 pub name: &'static str,
21 pub token: Option<&'static str>,
23 pub kind: &'static str,
25 pub id: &'static str,
27 pub action: &'static str,
29 pub expected: Outcome,
31}
32
33pub const ISSUER: &str = "https://issuer.example";
35pub const AUDIENCE: &str = "sample";
37pub const NOW: u64 = 100;
39pub const KEYS: &str = include_str!("../fixtures/passes/keys.json");
41pub const REFUSAL: &str = include_str!("../fixtures/refusals/missing-grant.json");
43
44#[must_use]
46pub fn cases() -> Vec<Case> {
47 let valid = include_str!("../fixtures/passes/valid.jwt").trim();
48 vec![
49 Case {
50 name: "valid",
51 token: Some(valid),
52 kind: "sample.file",
53 id: "child",
54 action: "read",
55 expected: Outcome::Allowed("parent-grant".to_owned()),
56 },
57 Case {
58 name: "wrong-audience",
59 token: Some(include_str!("../fixtures/passes/wrong-audience.jwt").trim()),
60 kind: "sample.file",
61 id: "child",
62 action: "read",
63 expected: Outcome::NamedRefusal("wrong_audience".to_owned()),
64 },
65 Case {
66 name: "expired",
67 token: Some(include_str!("../fixtures/passes/expired.jwt").trim()),
68 kind: "sample.file",
69 id: "child",
70 action: "read",
71 expected: Outcome::NamedRefusal("pass_expired".to_owned()),
72 },
73 Case {
74 name: "unpublished-key",
75 token: Some(include_str!("../fixtures/passes/unpublished-key.jwt").trim()),
76 kind: "sample.file",
77 id: "child",
78 action: "read",
79 expected: Outcome::NamedRefusal("unpublished_key".to_owned()),
80 },
81 Case {
82 name: "parent-reaches-child",
83 token: Some(valid),
84 kind: "sample.file",
85 id: "child",
86 action: "read",
87 expected: Outcome::Allowed("parent-grant".to_owned()),
88 },
89 Case {
90 name: "restricted-child",
91 token: Some(valid),
92 kind: "sample.file",
93 id: "restricted",
94 action: "read",
95 expected: Outcome::PermissionRefusal(REFUSAL.trim().to_owned()),
96 },
97 Case {
98 name: "held-mode-hot",
99 token: Some(valid),
100 kind: "sample.file",
101 id: "held",
102 action: "write",
103 expected: Outcome::PermissionRefusal(
104 include_str!("../fixtures/refusals/held-hot.json")
105 .trim()
106 .to_owned(),
107 ),
108 },
109 Case {
110 name: "exact-refusal-bytes",
111 token: Some(valid),
112 kind: "sample.file",
113 id: "restricted",
114 action: "read",
115 expected: Outcome::PermissionRefusal(REFUSAL.trim().to_owned()),
116 },
117 Case {
118 name: "deliberate-unreachable",
119 token: None,
120 kind: "sample.file",
121 id: "child",
122 action: "read",
123 expected: Outcome::NamedRefusal("lys_could_not_be_asked".to_owned()),
124 },
125 Case {
126 name: "tampered",
127 token: Some(include_str!("../fixtures/passes/tampered.jwt").trim()),
128 kind: "sample.file",
129 id: "child",
130 action: "read",
131 expected: Outcome::NamedRefusal("signature_refused".to_owned()),
132 },
133 ]
134}
135
136#[derive(Debug)]
138pub struct Failure {
139 pub case: &'static str,
141 pub reason: String,
143}
144
145#[derive(Debug)]
147pub struct Report {
148 pub run: usize,
150 pub passed: usize,
152 pub failures: Vec<Failure>,
154}
155
156impl Report {
157 pub fn require_conformant(&self) -> Result<(), Nonconformant> {
159 if self.passed == self.run && self.run == cases().len() && self.failures.is_empty() {
160 Ok(())
161 } else {
162 Err(Nonconformant {
163 run: self.run,
164 passed: self.passed,
165 })
166 }
167 }
168}
169
170#[derive(Debug, thiserror::Error)]
172#[error("lys_pass_nonconformance: {passed}/{run} vectors matched")]
173pub struct Nonconformant {
174 pub run: usize,
176 pub passed: usize,
178}
179
180pub fn run<E: std::fmt::Display>(mut observe: impl FnMut(&Case) -> Result<Outcome, E>) -> Report {
182 let cases = cases();
183 let mut report = Report {
184 run: cases.len(),
185 passed: 0,
186 failures: Vec::new(),
187 };
188 for case in &cases {
189 match observe(case) {
190 Ok(actual) if actual == case.expected => report.passed += 1,
191 Ok(actual) => report.failures.push(Failure {
192 case: case.name,
193 reason: format!("expected {:?}, received {actual:?}", case.expected),
194 }),
195 Err(error) => report.failures.push(Failure {
196 case: case.name,
197 reason: error.to_string(),
198 }),
199 }
200 }
201 report
202}
203
204pub fn refusal_fixture() -> Result<Refusal, serde_json::Error> {
206 serde_json::from_str(REFUSAL)
207}