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 an actionable hint for expanding coverage in a category.
340    /// Used in the report when a category has 0 tests detected from the spec.
341    pub fn category_hint(category: &str) -> &'static str {
342        match category {
343            "Parameters" => "Add path, query, header, or cookie parameters to your operations",
344            "Request Bodies" => "Add requestBody with JSON, form, or multipart content to POST/PUT/PATCH operations",
345            "Schema Types" => "Use typed properties (string, integer, number, boolean, array, object) in your schemas",
346            "Composition" => "Use oneOf, anyOf, or allOf schema composition in your models",
347            "String Formats" => "Add format annotations (date, email, uuid, uri, ipv4, etc.) to string schemas",
348            "Constraints" => "Add required fields, min/max, pattern, or enum constraints to your schemas",
349            "Response Codes" => "Define explicit 200, 201, 204, 400, and 404 responses on your operations",
350            "HTTP Methods" => "Use GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS methods in your paths",
351            "Content Types" => "Serve multiple content types or add Accept header negotiation",
352            "Security" => "Define securitySchemes (bearer, apiKey, basic) in your components",
353            "Response Validation" => "Add response schemas so MockForge can validate response structure",
354            _ => "Expand your OpenAPI spec to cover this category",
355        }
356    }
357
358    /// Count the number of features in a given category
359    pub fn features_in_category(category: &str) -> usize {
360        Self::all().iter().filter(|f| f.category() == category).count()
361    }
362
363    /// Get the OpenAPI spec URL for this feature's category (used in SARIF reports)
364    pub fn spec_url(&self) -> &'static str {
365        match self.category() {
366            "Parameters" => "https://spec.openapis.org/oas/v3.0.0#parameter-object",
367            "Request Bodies" => "https://spec.openapis.org/oas/v3.0.0#request-body-object",
368            "Schema Types" => "https://spec.openapis.org/oas/v3.0.0#schema-object",
369            "Composition" => "https://spec.openapis.org/oas/v3.0.0#schema-object",
370            "String Formats" => "https://spec.openapis.org/oas/v3.0.0#data-types",
371            "Constraints" => "https://spec.openapis.org/oas/v3.0.0#schema-object",
372            "Response Codes" => "https://spec.openapis.org/oas/v3.0.0#responses-object",
373            "HTTP Methods" => "https://spec.openapis.org/oas/v3.0.0#path-item-object",
374            "Content Types" => "https://spec.openapis.org/oas/v3.0.0#media-type-object",
375            "Security" => "https://spec.openapis.org/oas/v3.0.0#security-scheme-object",
376            "Response Validation" => "https://spec.openapis.org/oas/v3.0.0#response-object",
377            _ => "https://spec.openapis.org/oas/v3.0.0",
378        }
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn test_all_features_have_categories() {
388        for feature in ConformanceFeature::all() {
389            assert!(!feature.category().is_empty());
390            assert!(!feature.check_name().is_empty());
391        }
392    }
393
394    #[test]
395    fn test_all_categories_covered() {
396        let categories: std::collections::HashSet<&str> =
397            ConformanceFeature::all().iter().map(|f| f.category()).collect();
398        for cat in ConformanceFeature::categories() {
399            assert!(categories.contains(cat), "Category '{}' has no features", cat);
400        }
401    }
402
403    #[test]
404    fn test_category_from_cli_name() {
405        assert_eq!(ConformanceFeature::category_from_cli_name("parameters"), Some("Parameters"));
406        assert_eq!(
407            ConformanceFeature::category_from_cli_name("request-bodies"),
408            Some("Request Bodies")
409        );
410        assert_eq!(
411            ConformanceFeature::category_from_cli_name("schema-types"),
412            Some("Schema Types")
413        );
414        assert_eq!(ConformanceFeature::category_from_cli_name("PARAMETERS"), Some("Parameters"));
415        assert_eq!(
416            ConformanceFeature::category_from_cli_name("Request-Bodies"),
417            Some("Request Bodies")
418        );
419        assert_eq!(ConformanceFeature::category_from_cli_name("invalid"), None);
420    }
421
422    #[test]
423    fn test_cli_category_names_complete() {
424        let cli_names = ConformanceFeature::cli_category_names();
425        let categories = ConformanceFeature::categories();
426        assert_eq!(cli_names.len(), categories.len());
427        for (_, canonical) in &cli_names {
428            assert!(
429                categories.contains(canonical),
430                "CLI name maps to unknown category: {}",
431                canonical
432            );
433        }
434    }
435
436    #[test]
437    fn test_related_owasp_valid_identifiers() {
438        let pattern = regex::Regex::new(r"^API\d+:2023$").unwrap();
439        for feature in ConformanceFeature::all() {
440            for owasp_id in feature.related_owasp() {
441                assert!(
442                    pattern.is_match(owasp_id),
443                    "Feature {:?} has invalid OWASP identifier: {}",
444                    feature,
445                    owasp_id
446                );
447            }
448        }
449    }
450
451    #[test]
452    fn test_security_features_map_to_api2() {
453        assert!(ConformanceFeature::SecurityBearer.related_owasp().contains(&"API2:2023"));
454        assert!(ConformanceFeature::SecurityApiKey.related_owasp().contains(&"API2:2023"));
455        assert!(ConformanceFeature::SecurityBasic.related_owasp().contains(&"API2:2023"));
456    }
457
458    #[test]
459    fn test_spec_urls_not_empty() {
460        for feature in ConformanceFeature::all() {
461            assert!(!feature.spec_url().is_empty(), "Feature {:?} has empty spec URL", feature);
462        }
463    }
464}