Skip to main content

mockforge_bench/conformance/
spec.rs

1//! Conformance feature definitions and bundled reference spec
2
3/// OpenAPI 3.0.0 feature categories for conformance testing
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5pub enum ConformanceFeature {
6    // Parameters
7    PathParamString,
8    PathParamInteger,
9    QueryParamString,
10    QueryParamInteger,
11    QueryParamArray,
12    HeaderParam,
13    CookieParam,
14    // Request Bodies
15    BodyJson,
16    BodyFormUrlencoded,
17    BodyMultipart,
18    // Schema Types
19    SchemaString,
20    SchemaInteger,
21    SchemaNumber,
22    SchemaBoolean,
23    SchemaArray,
24    SchemaObject,
25    // Composition
26    CompositionOneOf,
27    CompositionAnyOf,
28    CompositionAllOf,
29    // String Formats
30    FormatDate,
31    FormatDateTime,
32    FormatEmail,
33    FormatUuid,
34    FormatUri,
35    FormatIpv4,
36    FormatIpv6,
37    // Constraints
38    ConstraintRequired,
39    ConstraintOptional,
40    ConstraintMinMax,
41    ConstraintPattern,
42    ConstraintEnum,
43    // Response Codes
44    Response200,
45    Response201,
46    Response204,
47    Response400,
48    Response404,
49    // HTTP Methods
50    MethodGet,
51    MethodPost,
52    MethodPut,
53    MethodPatch,
54    MethodDelete,
55    MethodHead,
56    MethodOptions,
57    // Content Negotiation
58    ContentNegotiation,
59    // Security
60    SecurityBearer,
61    SecurityApiKey,
62    SecurityBasic,
63    // Response Validation (spec-driven mode)
64    ResponseValidation,
65}
66
67impl ConformanceFeature {
68    /// Get the category name for this feature
69    pub fn category(&self) -> &'static str {
70        match self {
71            Self::PathParamString
72            | Self::PathParamInteger
73            | Self::QueryParamString
74            | Self::QueryParamInteger
75            | Self::QueryParamArray
76            | Self::HeaderParam
77            | Self::CookieParam => "Parameters",
78            Self::BodyJson | Self::BodyFormUrlencoded | Self::BodyMultipart => "Request Bodies",
79            Self::SchemaString
80            | Self::SchemaInteger
81            | Self::SchemaNumber
82            | Self::SchemaBoolean
83            | Self::SchemaArray
84            | Self::SchemaObject => "Schema Types",
85            Self::CompositionOneOf | Self::CompositionAnyOf | Self::CompositionAllOf => {
86                "Composition"
87            }
88            Self::FormatDate
89            | Self::FormatDateTime
90            | Self::FormatEmail
91            | Self::FormatUuid
92            | Self::FormatUri
93            | Self::FormatIpv4
94            | Self::FormatIpv6 => "String Formats",
95            Self::ConstraintRequired
96            | Self::ConstraintOptional
97            | Self::ConstraintMinMax
98            | Self::ConstraintPattern
99            | Self::ConstraintEnum => "Constraints",
100            Self::Response200
101            | Self::Response201
102            | Self::Response204
103            | Self::Response400
104            | Self::Response404 => "Response Codes",
105            Self::MethodGet
106            | Self::MethodPost
107            | Self::MethodPut
108            | Self::MethodPatch
109            | Self::MethodDelete
110            | Self::MethodHead
111            | Self::MethodOptions => "HTTP Methods",
112            Self::ContentNegotiation => "Content Types",
113            Self::SecurityBearer | Self::SecurityApiKey | Self::SecurityBasic => "Security",
114            Self::ResponseValidation => "Response Validation",
115        }
116    }
117
118    /// Get the check name used in k6 scripts (maps back from k6 output)
119    pub fn check_name(&self) -> &'static str {
120        match self {
121            Self::PathParamString => "param:path:string",
122            Self::PathParamInteger => "param:path:integer",
123            Self::QueryParamString => "param:query:string",
124            Self::QueryParamInteger => "param:query:integer",
125            Self::QueryParamArray => "param:query:array",
126            Self::HeaderParam => "param:header",
127            Self::CookieParam => "param:cookie",
128            Self::BodyJson => "body:json",
129            Self::BodyFormUrlencoded => "body:form-urlencoded",
130            Self::BodyMultipart => "body:multipart",
131            Self::SchemaString => "schema:string",
132            Self::SchemaInteger => "schema:integer",
133            Self::SchemaNumber => "schema:number",
134            Self::SchemaBoolean => "schema:boolean",
135            Self::SchemaArray => "schema:array",
136            Self::SchemaObject => "schema:object",
137            Self::CompositionOneOf => "composition:oneOf",
138            Self::CompositionAnyOf => "composition:anyOf",
139            Self::CompositionAllOf => "composition:allOf",
140            Self::FormatDate => "format:date",
141            Self::FormatDateTime => "format:date-time",
142            Self::FormatEmail => "format:email",
143            Self::FormatUuid => "format:uuid",
144            Self::FormatUri => "format:uri",
145            Self::FormatIpv4 => "format:ipv4",
146            Self::FormatIpv6 => "format:ipv6",
147            Self::ConstraintRequired => "constraint:required",
148            Self::ConstraintOptional => "constraint:optional",
149            Self::ConstraintMinMax => "constraint:minmax",
150            Self::ConstraintPattern => "constraint:pattern",
151            Self::ConstraintEnum => "constraint:enum",
152            Self::Response200 => "response:200",
153            Self::Response201 => "response:201",
154            Self::Response204 => "response:204",
155            Self::Response400 => "response:400",
156            Self::Response404 => "response:404",
157            Self::MethodGet => "method:GET",
158            Self::MethodPost => "method:POST",
159            Self::MethodPut => "method:PUT",
160            Self::MethodPatch => "method:PATCH",
161            Self::MethodDelete => "method:DELETE",
162            Self::MethodHead => "method:HEAD",
163            Self::MethodOptions => "method:OPTIONS",
164            Self::ContentNegotiation => "content:negotiation",
165            Self::SecurityBearer => "security:bearer",
166            Self::SecurityApiKey => "security:apikey",
167            Self::SecurityBasic => "security:basic",
168            Self::ResponseValidation => "response:schema:validation",
169        }
170    }
171
172    /// All feature variants
173    pub fn all() -> &'static [ConformanceFeature] {
174        &[
175            Self::PathParamString,
176            Self::PathParamInteger,
177            Self::QueryParamString,
178            Self::QueryParamInteger,
179            Self::QueryParamArray,
180            Self::HeaderParam,
181            Self::CookieParam,
182            Self::BodyJson,
183            Self::BodyFormUrlencoded,
184            Self::BodyMultipart,
185            Self::SchemaString,
186            Self::SchemaInteger,
187            Self::SchemaNumber,
188            Self::SchemaBoolean,
189            Self::SchemaArray,
190            Self::SchemaObject,
191            Self::CompositionOneOf,
192            Self::CompositionAnyOf,
193            Self::CompositionAllOf,
194            Self::FormatDate,
195            Self::FormatDateTime,
196            Self::FormatEmail,
197            Self::FormatUuid,
198            Self::FormatUri,
199            Self::FormatIpv4,
200            Self::FormatIpv6,
201            Self::ConstraintRequired,
202            Self::ConstraintOptional,
203            Self::ConstraintMinMax,
204            Self::ConstraintPattern,
205            Self::ConstraintEnum,
206            Self::Response200,
207            Self::Response201,
208            Self::Response204,
209            Self::Response400,
210            Self::Response404,
211            Self::MethodGet,
212            Self::MethodPost,
213            Self::MethodPut,
214            Self::MethodPatch,
215            Self::MethodDelete,
216            Self::MethodHead,
217            Self::MethodOptions,
218            Self::ContentNegotiation,
219            Self::SecurityBearer,
220            Self::SecurityApiKey,
221            Self::SecurityBasic,
222            Self::ResponseValidation,
223        ]
224    }
225
226    /// All unique categories
227    pub fn categories() -> &'static [&'static str] {
228        &[
229            "Parameters",
230            "Request Bodies",
231            "Schema Types",
232            "Composition",
233            "String Formats",
234            "Constraints",
235            "Response Codes",
236            "HTTP Methods",
237            "Content Types",
238            "Security",
239            "Response Validation",
240        ]
241    }
242
243    /// Convert a CLI category name (lowercase, hyphenated) to the canonical category name
244    pub fn category_from_cli_name(name: &str) -> Option<&'static str> {
245        match name.to_lowercase().replace('_', "-").as_str() {
246            "parameters" => Some("Parameters"),
247            "request-bodies" => Some("Request Bodies"),
248            "schema-types" => Some("Schema Types"),
249            "composition" => Some("Composition"),
250            "string-formats" => Some("String Formats"),
251            "constraints" => Some("Constraints"),
252            "response-codes" => Some("Response Codes"),
253            "http-methods" => Some("HTTP Methods"),
254            "content-types" => Some("Content Types"),
255            "security" => Some("Security"),
256            "response-validation" => Some("Response Validation"),
257            _ => None,
258        }
259    }
260
261    /// All valid CLI category names with their canonical names
262    pub fn cli_category_names() -> Vec<(&'static str, &'static str)> {
263        vec![
264            ("parameters", "Parameters"),
265            ("request-bodies", "Request Bodies"),
266            ("schema-types", "Schema Types"),
267            ("composition", "Composition"),
268            ("string-formats", "String Formats"),
269            ("constraints", "Constraints"),
270            ("response-codes", "Response Codes"),
271            ("http-methods", "HTTP Methods"),
272            ("content-types", "Content Types"),
273            ("security", "Security"),
274            ("response-validation", "Response Validation"),
275        ]
276    }
277
278    /// Related OWASP API Security Top 10 (2023) categories
279    pub fn related_owasp(&self) -> &'static [&'static str] {
280        match self {
281            // Security features → Broken Authentication
282            Self::SecurityBearer | Self::SecurityApiKey | Self::SecurityBasic => &["API2:2023"],
283            // Path parameters → BOLA + Security Misconfiguration
284            Self::PathParamString | Self::PathParamInteger => &["API1:2023", "API8:2023"],
285            // Other parameters → Security Misconfiguration
286            Self::QueryParamString
287            | Self::QueryParamInteger
288            | Self::QueryParamArray
289            | Self::HeaderParam
290            | Self::CookieParam => &["API8:2023"],
291            // Request bodies → Resource Consumption + Security Misconfiguration
292            Self::BodyJson | Self::BodyFormUrlencoded | Self::BodyMultipart => {
293                &["API4:2023", "API8:2023"]
294            }
295            // Required/Optional constraints → Broken Object Property Auth + Misconfiguration
296            Self::ConstraintRequired | Self::ConstraintOptional => &["API3:2023", "API8:2023"],
297            // Validation constraints → Resource Consumption + Misconfiguration
298            Self::ConstraintMinMax | Self::ConstraintPattern | Self::ConstraintEnum => {
299                &["API4:2023", "API8:2023"]
300            }
301            // Schema types → Security Misconfiguration
302            Self::SchemaString
303            | Self::SchemaInteger
304            | Self::SchemaNumber
305            | Self::SchemaBoolean
306            | Self::SchemaArray
307            | Self::SchemaObject => &["API8:2023"],
308            // String formats → Security Misconfiguration
309            Self::FormatDate
310            | Self::FormatDateTime
311            | Self::FormatEmail
312            | Self::FormatUuid
313            | Self::FormatUri
314            | Self::FormatIpv4
315            | Self::FormatIpv6 => &["API8:2023"],
316            // Composition → Security Misconfiguration
317            Self::CompositionOneOf | Self::CompositionAnyOf | Self::CompositionAllOf => {
318                &["API8:2023"]
319            }
320            // Error response codes → Misconfiguration + Improper Inventory
321            Self::Response400 | Self::Response404 => &["API8:2023", "API9:2023"],
322            // Success response codes — no direct OWASP mapping
323            Self::Response200 | Self::Response201 | Self::Response204 => &[],
324            // HTTP methods → Broken Function Auth + Improper Inventory
325            Self::MethodGet
326            | Self::MethodPost
327            | Self::MethodPut
328            | Self::MethodPatch
329            | Self::MethodDelete
330            | Self::MethodHead
331            | Self::MethodOptions => &["API5:2023", "API9:2023"],
332            // Content negotiation → Security Misconfiguration
333            Self::ContentNegotiation => &["API8:2023"],
334            // Response validation → Misconfiguration + Unsafe Consumption
335            Self::ResponseValidation => &["API8:2023", "API10:2023"],
336        }
337    }
338
339    /// Get the OpenAPI spec URL for this feature's category (used in SARIF reports)
340    pub fn spec_url(&self) -> &'static str {
341        match self.category() {
342            "Parameters" => "https://spec.openapis.org/oas/v3.0.0#parameter-object",
343            "Request Bodies" => "https://spec.openapis.org/oas/v3.0.0#request-body-object",
344            "Schema Types" => "https://spec.openapis.org/oas/v3.0.0#schema-object",
345            "Composition" => "https://spec.openapis.org/oas/v3.0.0#schema-object",
346            "String Formats" => "https://spec.openapis.org/oas/v3.0.0#data-types",
347            "Constraints" => "https://spec.openapis.org/oas/v3.0.0#schema-object",
348            "Response Codes" => "https://spec.openapis.org/oas/v3.0.0#responses-object",
349            "HTTP Methods" => "https://spec.openapis.org/oas/v3.0.0#path-item-object",
350            "Content Types" => "https://spec.openapis.org/oas/v3.0.0#media-type-object",
351            "Security" => "https://spec.openapis.org/oas/v3.0.0#security-scheme-object",
352            "Response Validation" => "https://spec.openapis.org/oas/v3.0.0#response-object",
353            _ => "https://spec.openapis.org/oas/v3.0.0",
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_all_features_have_categories() {
364        for feature in ConformanceFeature::all() {
365            assert!(!feature.category().is_empty());
366            assert!(!feature.check_name().is_empty());
367        }
368    }
369
370    #[test]
371    fn test_all_categories_covered() {
372        let categories: std::collections::HashSet<&str> =
373            ConformanceFeature::all().iter().map(|f| f.category()).collect();
374        for cat in ConformanceFeature::categories() {
375            assert!(categories.contains(cat), "Category '{}' has no features", cat);
376        }
377    }
378
379    #[test]
380    fn test_category_from_cli_name() {
381        assert_eq!(ConformanceFeature::category_from_cli_name("parameters"), Some("Parameters"));
382        assert_eq!(
383            ConformanceFeature::category_from_cli_name("request-bodies"),
384            Some("Request Bodies")
385        );
386        assert_eq!(
387            ConformanceFeature::category_from_cli_name("schema-types"),
388            Some("Schema Types")
389        );
390        assert_eq!(ConformanceFeature::category_from_cli_name("PARAMETERS"), Some("Parameters"));
391        assert_eq!(
392            ConformanceFeature::category_from_cli_name("Request-Bodies"),
393            Some("Request Bodies")
394        );
395        assert_eq!(ConformanceFeature::category_from_cli_name("invalid"), None);
396    }
397
398    #[test]
399    fn test_cli_category_names_complete() {
400        let cli_names = ConformanceFeature::cli_category_names();
401        let categories = ConformanceFeature::categories();
402        assert_eq!(cli_names.len(), categories.len());
403        for (_, canonical) in &cli_names {
404            assert!(
405                categories.contains(canonical),
406                "CLI name maps to unknown category: {}",
407                canonical
408            );
409        }
410    }
411
412    #[test]
413    fn test_related_owasp_valid_identifiers() {
414        let pattern = regex::Regex::new(r"^API\d+:2023$").unwrap();
415        for feature in ConformanceFeature::all() {
416            for owasp_id in feature.related_owasp() {
417                assert!(
418                    pattern.is_match(owasp_id),
419                    "Feature {:?} has invalid OWASP identifier: {}",
420                    feature,
421                    owasp_id
422                );
423            }
424        }
425    }
426
427    #[test]
428    fn test_security_features_map_to_api2() {
429        assert!(ConformanceFeature::SecurityBearer.related_owasp().contains(&"API2:2023"));
430        assert!(ConformanceFeature::SecurityApiKey.related_owasp().contains(&"API2:2023"));
431        assert!(ConformanceFeature::SecurityBasic.related_owasp().contains(&"API2:2023"));
432    }
433
434    #[test]
435    fn test_spec_urls_not_empty() {
436        for feature in ConformanceFeature::all() {
437            assert!(!feature.spec_url().is_empty(), "Feature {:?} has empty spec URL", feature);
438        }
439    }
440}