Skip to main content

lys_pass/
conformance.rs

1//! Shared route vectors expose product permission drift as a gate failure.
2
3use crate::refusal::Refusal;
4
5/// The route result a product must demonstrate, without accepting a partial match.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Outcome {
8    /// The granting receipt was recorded with the permitted act.
9    Allowed(String),
10    /// A stable token or transport refusal name was returned.
11    NamedRefusal(String),
12    /// The serialized permission refusal matched the shared bytes.
13    PermissionRefusal(String),
14}
15
16/// One shared permission-route input and its required result.
17#[derive(Debug, Clone)]
18pub struct Case {
19    /// The stable fixture name.
20    pub name: &'static str,
21    /// A synthetic signed pass, absent for the live unreachable route.
22    pub token: Option<&'static str>,
23    /// The qualified kind asked about.
24    pub kind: &'static str,
25    /// The exact resource id.
26    pub id: &'static str,
27    /// The action requested.
28    pub action: &'static str,
29    /// The full required result.
30    pub expected: Outcome,
31}
32
33/// The issuer used by the synthetic signed vectors.
34pub const ISSUER: &str = "https://issuer.example";
35/// The vectors' single application audience.
36pub const AUDIENCE: &str = "sample";
37/// The vectors' explicit observation instant, never a wall-clock wait.
38pub const NOW: u64 = 100;
39/// Published verification keys for these vectors, never a product authority.
40pub const KEYS: &str = include_str!("../fixtures/passes/keys.json");
41/// The exact permission-refusal envelope every consumer shares.
42pub const REFUSAL: &str = include_str!("../fixtures/refusals/missing-grant.json");
43
44/// The same ten vectors are shipped to every product gate.
45#[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/// A failed consumer observation, retaining its fixture and complete explanation.
137#[derive(Debug)]
138pub struct Failure {
139    /// The failed fixture.
140    pub case: &'static str,
141    /// The mismatch or the preserved consumer error.
142    pub reason: String,
143}
144
145/// Full counts and every failure; a consumer gate must require an empty failure list.
146#[derive(Debug)]
147pub struct Report {
148    /// Total vectors run, including those whose consumer returned an error.
149    pub run: usize,
150    /// Exact full-result matches.
151    pub passed: usize,
152    /// Every mismatch and consumer failure.
153    pub failures: Vec<Failure>,
154}
155
156impl Report {
157    /// Refuse the consumer gate unless every shipped vector matched completely.
158    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/// A consumer gate with mismatched or missing shared vectors.
171#[derive(Debug, thiserror::Error)]
172#[error("lys_pass_nonconformance: {passed}/{run} vectors matched")]
173pub struct Nonconformant {
174    /// All vectors observed.
175    pub run: usize,
176    /// Those matching the whole required result.
177    pub passed: usize,
178}
179
180/// Run every vector through the product's actual route adapter, retaining all failures.
181pub 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
204/// Decode the shared refusal without silently accepting extra members.
205pub fn refusal_fixture() -> Result<Refusal, serde_json::Error> {
206    serde_json::from_str(REFUSAL)
207}