mod unit { use crank_import::rest::{ ImportFindingSeverity, ImportParseError, NormalizationConfig, normalize_verified_document, preview_document, }; const SWAGGER2: &str = r#" swagger: "2.0" info: title: Pet API host: petstore.example.com basePath: /api schemes: [https] paths: /pets/{id}: get: operationId: getPet summary: Получить питомца tags: [pets] parameters: - name: id in: path required: true type: string responses: '200': description: OK schema: $ref: '#/definitions/Pet' definitions: Pet: type: object required: [id, name] properties: id: type: string name: type: string "#; fn normalize_document( document: &str, config: &NormalizationConfig, ) -> Result { normalize_verified_document( document, crank_import::rest::SourceDigest::parse("b".repeat(64)).unwrap(), config, ) } fn matrix_source() -> String { "openapi: 3.1.0\ninfo: { title: Matrix }\npaths:\n /ok:\n get:\n operationId: getOk\n tags: [matrix]\n parameters: [{ name: q, in: query, schema: { type: string } }]\n responses: { '200': { description: ok, content: { application/json: { schema: { type: object, properties: { value: { type: string } } } } } } }".to_owned() } #[test] fn normalizes_supported_versions_with_canonical_order_and_stable_ids() { let fixture_matrix = [ (include_str!("fixtures/openapi-3.0.yaml"), "3.0.3"), (include_str!("fixtures/openapi-3.1.json"), "3.1.0"), (include_str!("fixtures/swagger-2.0.yaml"), "2.0"), ]; for (fixture, version) in fixture_matrix { assert_eq!( normalize_document(fixture, &NormalizationConfig::default()) .unwrap() .source .version .as_deref(), Some(version) ); } let openapi_31_json = r#"{ "openapi":"3.1.0", "info":{"title":"Canonical"}, "paths": { "/z":{"post":{"responses":{"201":{"description":"ok"}}}}, "/a":{"get":{"responses":{"200":{"description":"ok"}}}} } }"#; let openapi_30_yaml = r#" openapi: 3.0.3 info: { title: Canonical } paths: /a: get: responses: { '200': { description: ok } } "#; let swagger = r#" swagger: '2.0' info: { title: Legacy } paths: /a: get: responses: { '200': { description: ok } } "#; let first = normalize_document(openapi_31_json, &NormalizationConfig::default()).unwrap(); let second = normalize_document(openapi_31_json, &NormalizationConfig::default()).unwrap(); assert_eq!( serde_json::to_vec(&first).unwrap(), serde_json::to_vec(&second).unwrap() ); assert_eq!(first.operations[0].path, "/a"); assert_eq!(first.operations[0].stable_id, "3.1.0:get:/paths/~1a/get"); assert!(first.coverage.len() >= 2); assert_eq!( normalize_document(openapi_30_yaml, &NormalizationConfig::default()) .unwrap() .source .version .as_deref(), Some("3.0.3") ); assert_eq!( normalize_document(swagger, &NormalizationConfig::default()) .unwrap() .source .version .as_deref(), Some("2.0") ); } #[test] fn resolves_local_references_into_typed_graph() { let ir = normalize_document(SWAGGER2, &NormalizationConfig::default()).unwrap(); let operation = &ir.operations[0]; assert!(matches!( operation .response_schema .as_ref() .map(|schema| &schema.kind), Some(crank_import::rest::NormalizedSchemaKind::Object { .. }) )); assert!(ir.unresolved_references.is_empty()); assert!(!ir.reference_graph.edges.is_empty()); assert!( operation .findings .iter() .all(|finding| finding.code != "unresolved_reference") ); } #[test] fn preserves_valid_operations_when_another_path_item_is_malformed() { let document = r#" openapi: 3.0.3 info: { title: Partial } paths: /valid: get: responses: { '200': { description: ok } } /broken: invalid "#; let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); assert_eq!(ir.operations.len(), 1); assert!(ir.findings.iter().any(|finding| { finding.code == "invalid_path_item" && finding.severity == crank_import::rest::ImportFindingSeverity::Error })); assert!(ir.findings.iter().any(|finding| { finding.code == "invalid_path_item" && finding.location.pointer == "/paths/~1broken" && finding.construct_id.contains("/paths/~1broken") })); } #[test] fn malformed_method_values_are_blockers_while_valid_siblings_survive() { for (format, document) in [ ( "openapi", "openapi: 3.0.3\ninfo: { title: malformed }\npaths: { /mixed: { get: { responses: { '200': { description: ok } } }, post: broken } }", ), ( "swagger", "swagger: '2.0'\ninfo: { title: malformed }\npaths: { /mixed: { get: { responses: { '200': { description: ok } } }, post: broken } }", ), ] { let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); assert_eq!(ir.operations.len(), 1, "{format}"); assert_eq!(ir.operations[0].key, "GET /mixed", "{format}"); let finding = ir .findings .iter() .find(|finding| finding.code == "invalid_operation") .unwrap(); assert_eq!(finding.severity, ImportFindingSeverity::Error, "{format}"); assert_eq!(finding.location.pointer, "/paths/~1mixed/post", "{format}"); } } #[test] fn rejects_hostile_input_at_configured_limits() { let document = r#" openapi: 3.0.3 info: { title: Too deep } paths: {} "#; let config = NormalizationConfig { max_bytes: 10, ..NormalizationConfig::default() }; assert_eq!( normalize_document(document, &config), Err(ImportParseError::LimitExceeded) ); let unsupported = "openapi: 3.2.0\ninfo: { title: Nope }\npaths: {}\n"; assert_eq!( normalize_document(unsupported, &NormalizationConfig::default()), Err(ImportParseError::UnsupportedDocument) ); } #[test] fn checked_in_hostile_regressions_are_bounded_and_redacted() { // Minimal regression corpus distilled from malformed/fuzzed inputs. // Assertions deliberately compare typed errors, never parser text. for document in [ include_str!("fixtures/fuzz-regressions/invalid-root.json"), include_str!("fixtures/fuzz-regressions/unsupported-version.yaml"), include_str!("fixtures/fuzz-regressions/wrong-version-shape.yaml"), ] { let error = normalize_document(document, &NormalizationConfig::default()).unwrap_err(); assert!(matches!( error, ImportParseError::InvalidDocument | ImportParseError::UnsupportedDocument )); assert!(!error.to_string().contains("wrong-root-field")); } } #[test] fn verified_digest_stays_inside_ir_and_unknown_contract_fails_closed() { let digest = crank_import::rest::SourceDigest::parse("a".repeat(64)).unwrap(); let ir = crank_import::rest::normalize_verified_document( include_str!("fixtures/openapi-3.1.json"), digest, &NormalizationConfig::default(), ) .unwrap(); assert_eq!(ir.source_identity.digest.as_str(), "a".repeat(64)); let preview = crank_import::rest::preview_from_ir(&ir); assert!( !serde_json::to_string(&preview) .unwrap() .contains("source_identity") ); assert!( !serde_json::to_string(&preview) .unwrap() .contains(&"a".repeat(64)) ); let config = NormalizationConfig { normalizer_version: "future".to_owned(), ..NormalizationConfig::default() }; assert_eq!( normalize_document(include_str!("fixtures/openapi-3.1.json"), &config), Err(ImportParseError::UnsupportedDocument) ); } #[test] fn yaml_alias_preflight_counts_inline_aliases_but_not_quotes_or_comments() { let mut document = "openapi: 3.0.3\ninfo: { title: aliases }\npaths: {}\nitems: [".to_owned(); document.push_str( &std::iter::repeat_n("*a", 129) .collect::>() .join(", "), ); document.push(']'); assert_eq!( normalize_document(&document, &NormalizationConfig::default()), Err(ImportParseError::LimitExceeded) ); let harmless = "openapi: 3.0.3\ninfo: { title: '*not_alias' } # *also_not_alias\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }"; assert!(normalize_document(harmless, &NormalizationConfig::default()).is_ok()); } #[test] fn yaml_alias_preflight_keeps_quote_state_after_escaped_and_doubled_quotes() { let mut escaped_quote = String::from( "openapi: 3.0.3\ninfo:\n title: \"escaped \\\" quote\"\npaths: {}\nitems: [", ); escaped_quote.push_str( &std::iter::repeat_n("*alias", 129) .collect::>() .join(", "), ); escaped_quote.push(']'); assert_eq!( normalize_document(&escaped_quote, &NormalizationConfig::default()), Err(ImportParseError::LimitExceeded) ); let harmless = r#"openapi: 3.0.3 info: title: 'it''s *not_an_alias' description: "escaped \" *also_not_an_alias" paths: /ok: get: responses: { '200': { description: ok } } "#; assert!(normalize_document(harmless, &NormalizationConfig::default()).is_ok()); } #[test] fn yaml_alias_preflight_fails_closed_for_stars_in_block_scalars() { let hidden_alias = r#"openapi: 3.0.3 info: title: Block scalar description: | this text contains *an_alias_like_token paths: /ok: get: responses: { '200': { description: ok } } "#; assert_eq!( normalize_document(hidden_alias, &NormalizationConfig::default()), Err(ImportParseError::LimitExceeded) ); let safe_block = r#"openapi: 3.0.3 info: title: Block scalar description: | this text contains no alias-looking marker paths: /ok: get: responses: { '200': { description: ok } } "#; assert!(normalize_document(safe_block, &NormalizationConfig::default()).is_ok()); } #[test] fn typed_scalar_literals_round_trip_without_stringification() { let document = r#" openapi: 3.1.0 info: { title: Scalars } paths: /value: get: responses: '200': description: ok content: application/json: schema: type: object properties: count: { type: integer, default: 7, enum: [1, 2] } enabled: { type: boolean, default: true, enum: [true, false] } ratio: { type: number, default: 1.5, enum: [0.5, 1.5] } "#; let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); let preview = preview_document(document).unwrap(); let fields = &preview.groups[0].operations[0].draft.output_schema.fields; assert_eq!(fields["count"].kind, crank_schema::SchemaKind::Integer); assert_eq!(fields["enabled"].kind, crank_schema::SchemaKind::Boolean); let rendered = serde_json::to_value(&ir.operations[0].response_schema).unwrap(); assert!(rendered.to_string().contains("integer")); assert!(rendered.to_string().contains("1.5")); } #[test] fn coverage_validator_rejects_nested_gap_and_duplicate_findings() { let mut ir = normalize_document(&matrix_source(), &NormalizationConfig::default()).unwrap(); let nested = ir.operations[0] .response_schema .as_ref() .unwrap() .construct_id .clone(); ir.coverage.retain(|entry| entry.construct_id != nested); assert_eq!( crank_import::rest::validate_normalized_ir(&ir), Err(ImportParseError::InvalidDocument) ); let mut ir = normalize_document(&matrix_source(), &NormalizationConfig::default()).unwrap(); ir.coverage .retain(|entry| entry.construct_id != "source:/paths/~1ok/get/responses/200"); assert_eq!( crank_import::rest::validate_normalized_ir(&ir), Err(ImportParseError::InvalidDocument) ); let ir = normalize_document(&matrix_source(), &NormalizationConfig::default()).unwrap(); let findings = &ir.operations[0].findings; assert_eq!( findings.len(), findings .iter() .map(|finding| (&finding.code, &finding.construct_id)) .collect::>() .len() ); } #[test] fn two_malformed_paths_keep_distinct_internal_locations() { let ir = normalize_document( "openapi: 3.0.3\ninfo: { title: broken }\npaths: { /one: bad, /two: bad }", &NormalizationConfig::default(), ) .unwrap_err(); assert_eq!(ir, ImportParseError::NoMethods); // Structural parser findings require at least one valid operation to return IR. let ir = normalize_document("openapi: 3.0.3\ninfo: { title: broken }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } }, /one: bad, /two: bad }", &NormalizationConfig::default()).unwrap(); let pointers = ir .findings .iter() .filter(|finding| finding.code == "invalid_path_item") .map(|finding| finding.location.pointer.as_str()) .collect::>(); assert_eq!( pointers, std::collections::BTreeSet::from(["/paths/~1one", "/paths/~1two"]) ); } #[test] fn canonical_matrix_covers_all_supported_versions_syntaxes_and_concurrency() { let matrix = [ ( "oas30-yaml", "openapi: 3.0.3\ninfo: { title: OAS30 }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }", ), ( "oas30-json", r#"{"openapi":"3.0.3","info":{"title":"OAS30"},"paths":{"/ok":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#, ), ( "oas31-yaml", "openapi: 3.1.0\ninfo: { title: OAS31 }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }", ), ( "oas31-json", r#"{"openapi":"3.1.0","info":{"title":"OAS31"},"paths":{"/ok":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#, ), ( "swagger-yaml", "swagger: '2.0'\ninfo: { title: Swagger }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }", ), ( "swagger-json", r#"{"swagger":"2.0","info":{"title":"Swagger"},"paths":{"/ok":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#, ), ]; for (name, source) in matrix { let first = normalize_document(source, &NormalizationConfig::default()).unwrap(); let second = normalize_document(source, &NormalizationConfig::default()).unwrap(); assert_eq!( serde_json::to_vec(&first).unwrap(), serde_json::to_vec(&second).unwrap(), "{name}" ); } let source = std::sync::Arc::new(matrix_source()); let results = (0..8) .map(|_| { let source = std::sync::Arc::clone(&source); std::thread::spawn(move || { serde_json::to_vec( &normalize_document(&source, &NormalizationConfig::default()).unwrap(), ) .unwrap() }) }) .map(|thread| thread.join().unwrap()) .collect::>(); assert!(results.windows(2).all(|pair| pair[0] == pair[1])); } #[test] fn every_small_path_order_permutation_has_the_same_canonical_ir() { let paths = [ ("/a", "get", "200"), ("/b", "post", "201"), ("/c", "delete", "204"), ]; let permutations = [ [0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0], ]; let mut canonical = None; for permutation in permutations { let path_entries = permutation .into_iter() .map(|index| { let (path, method, status) = paths[index]; format!( "\"{path}\":{{\"{method}\":{{\"responses\":{{\"{status}\":{{\"description\":\"ok\"}}}}}}}}" ) }) .collect::>() .join(","); let source = format!( "{{\"openapi\":\"3.1.0\",\"info\":{{\"title\":\"Permutation\"}},\"paths\":{{{path_entries}}}}}" ); let bytes = serde_json::to_vec( &normalize_document(&source, &NormalizationConfig::default()).unwrap(), ) .unwrap(); if let Some(expected) = &canonical { assert_eq!(&bytes, expected); } else { canonical = Some(bytes); } } } #[test] fn limit_and_reference_regression_matrix_is_bounded_and_typed() { let source = matrix_source(); for config in [ NormalizationConfig { max_bytes: 1, ..NormalizationConfig::default() }, NormalizationConfig { max_depth: 1, ..NormalizationConfig::default() }, NormalizationConfig { max_nodes: 1, ..NormalizationConfig::default() }, NormalizationConfig { max_collection_items: 1, ..NormalizationConfig::default() }, NormalizationConfig { max_scalar_bytes: 1, ..NormalizationConfig::default() }, ] { assert_eq!( normalize_document(&source, &config), Err(ImportParseError::LimitExceeded) ); } assert_eq!( normalize_document( "openapi: 3.0.3\ninfo: { title: none }\npaths: {}", &NormalizationConfig::default() ), Err(ImportParseError::NoMethods) ); assert_eq!( normalize_document("[]", &NormalizationConfig::default()), Err(ImportParseError::UnsupportedDocument) ); for reference in [ "#/components/schemas/Loop", "https://example.test/schema.json", "#/components/schemas/Loop", ] { let document = format!( "openapi: 3.0.3\ninfo: {{ title: refs }}\npaths:\n /ok:\n get:\n responses:\n '200': {{ description: ok, content: {{ application/json: {{ schema: {{ $ref: '{reference}' }} }} }} }}" ); let ir = normalize_document(&document, &NormalizationConfig::default()).unwrap(); assert!(matches!( ir.operations[0] .response_schema .as_ref() .map(|schema| &schema.kind), Some(crank_import::rest::NormalizedSchemaKind::Reference { .. }) )); } } }