use crank_import::rest::{ ExternalDocumentSnapshot, ImportFindingSeverity, NormalizationConfig, NormalizedSchemaKind, SourceDigest, external_reference_uris, normalize_verified_bundle, normalize_verified_document, preview_from_ir, reference_uris, }; fn digest(byte: char) -> SourceDigest { SourceDigest::parse(byte.to_string().repeat(64)).unwrap() } fn normalize(document: &str) -> crank_import::rest::NormalizedIr { normalize_verified_document(document, digest('a'), &NormalizationConfig::default()).unwrap() } #[test] fn resolves_local_schema_and_object_references_with_rfc6901_escaping() { let document = r#" openapi: 3.1.0 info: { title: Local refs } servers: [{ url: https://api.example.test }] paths: /items/{id}: get: operationId: getItem parameters: - { $ref: '#/components/parameters/Id' } responses: '200': { $ref: '#/components/responses/Ok' } components: parameters: Id: { name: id, in: path, required: true, schema: { type: string } } responses: Ok: description: ok content: application/json: schema: { $ref: '#/components/schemas/a~1b~0c' } schemas: a/b~c: type: object required: [id] properties: { id: { type: string } } "#; let ir = normalize(document); let operation = &ir.operations[0]; assert_eq!(operation.parameters.len(), 1); assert_eq!(operation.parameters[0].name, "id"); assert!(matches!( operation.response_schema.as_ref().map(|schema| &schema.kind), Some(NormalizedSchemaKind::Object { properties, .. }) if properties.contains_key("id") )); assert!(ir.unresolved_references.is_empty()); assert_eq!(ir.reference_graph.edges.len(), 3); assert!( ir.findings .iter() .chain(operation.findings.iter()) .all(|finding| finding.code != "unresolved_reference") ); } #[test] fn broken_reference_blocks_only_affected_candidate_and_full_preview_remains_visible() { let document = r#" openapi: 3.1.0 info: { title: Partial graph } servers: [{ url: https://api.example.test }] paths: /broken: get: operationId: broken responses: '200': description: nope content: { application/json: { schema: { $ref: '#/components/schemas/Missing' } } } /healthy: get: operationId: healthy responses: { '204': { description: ok } } "#; let ir = normalize(document); let broken = ir .operations .iter() .find(|operation| operation.path == "/broken") .unwrap(); let healthy = ir .operations .iter() .find(|operation| operation.path == "/healthy") .unwrap(); assert!(broken.findings.iter().any(|finding| { finding.code == "reference_target_missing" && finding.severity == ImportFindingSeverity::Error })); assert!(healthy.findings.is_empty()); let preview = preview_from_ir(&ir); assert_eq!( preview .groups .iter() .map(|group| group.operations.len()) .sum::(), 2 ); } #[test] fn external_references_are_default_deny_without_network_or_snapshot() { let document = r#" openapi: 3.1.0 info: { title: External deny } servers: [{ url: https://api.example.test }] paths: /items: get: operationId: listItems responses: '200': description: ok content: { application/json: { schema: { $ref: 'https://schemas.example.test/root.yaml#/Item' } } } "#; let ir = normalize(document); assert!( ir.operations[0] .findings .iter() .any(|finding| finding.code == "external_reference_disabled") ); assert!(ir.reference_graph.edges.is_empty()); assert!(ir.reference_graph.dependency_digests.is_empty()); } #[test] fn resolves_supplied_external_snapshot_and_relative_chain_deterministically() { let document = r#" openapi: 3.1.0 info: { title: External snapshots } servers: [{ url: https://api.example.test }] paths: /items: get: operationId: listItems responses: '200': description: ok content: { application/json: { schema: { $ref: 'https://schemas.example.test/root.yaml#/Item' } } } "#; let snapshots = vec![ ExternalDocumentSnapshot { canonical_uri: "https://schemas.example.test/root.yaml".to_owned(), digest: digest('b'), document: "Item: { $ref: 'child.yaml#/Child' }".to_owned(), }, ExternalDocumentSnapshot { canonical_uri: "https://schemas.example.test/child.yaml".to_owned(), digest: digest('c'), document: "Child: { type: object, properties: { value: { type: integer } } }" .to_owned(), }, ]; let first = normalize_verified_bundle( document, digest('a'), &snapshots, &NormalizationConfig::default(), ) .unwrap(); let second = normalize_verified_bundle( document, digest('a'), &snapshots, &NormalizationConfig::default(), ) .unwrap(); assert_eq!( serde_json::to_vec(&first).unwrap(), serde_json::to_vec(&second).unwrap() ); assert_eq!( first.reference_graph.dependency_digests, vec![digest('b'), digest('c')] ); assert_eq!(first.reference_graph.edges.len(), 2); assert_eq!( first.reference_graph.edges[1].source.snapshot_digest, digest('b') ); assert!(first.unresolved_references.is_empty()); } #[test] fn recursion_is_a_stable_graph_edge_without_unbounded_expansion() { let document = r#" openapi: 3.1.0 info: { title: Recursive } servers: [{ url: https://api.example.test }] paths: /nodes: get: operationId: getNode responses: '200': description: ok content: { application/json: { schema: { $ref: '#/components/schemas/Node' } } } components: schemas: Node: type: object properties: child: { $ref: '#/components/schemas/Node' } "#; let ir = normalize(document); assert!(ir.reference_graph.edges.iter().any(|edge| edge.recursive)); assert!(ir.unresolved_references.is_empty()); assert!( ir.operations[0] .findings .iter() .all(|finding| finding.code != "reference_graph_limit") ); } #[test] fn merges_compatible_all_of_and_blocks_conflicts_without_first_branch_loss() { let compatible = r#" openapi: 3.1.0 info: { title: AllOf } servers: [{ url: https://api.example.test }] paths: /items: post: operationId: createItem requestBody: content: application/json: schema: allOf: - { type: object, required: [id], properties: { id: { type: string } } } - { type: object, required: [name], properties: { name: { type: string } } } responses: { '204': { description: ok } } "#; let ir = normalize(compatible); assert!(matches!( ir.operations[0].request_body_schema.as_ref().map(|schema| &schema.kind), Some(NormalizedSchemaKind::Object { properties, required }) if properties.len() == 2 && required == &vec!["id".to_owned(), "name".to_owned()] )); let conflict = compatible.replace("{ name: { type: string } }", "{ id: { type: integer } }"); let ir = normalize(&conflict); assert!( ir.operations[0] .findings .iter() .any(|finding| finding.code == "all_of_conflict") ); } #[test] fn preserves_one_of_discriminator_and_projects_all_alternatives() { let document = r#" openapi: 3.1.0 info: { title: Alternatives } servers: [{ url: https://api.example.test }] paths: /events: post: operationId: createEvent requestBody: content: application/json: schema: discriminator: propertyName: kind mapping: { text: '#/components/schemas/Text' } oneOf: - { type: object, properties: { text: { type: string } } } - { type: object, properties: { count: { type: integer } } } responses: { '204': { description: ok } } "#; let ir = normalize(document); let schema = ir.operations[0].request_body_schema.as_ref().unwrap(); assert_eq!(schema.discriminator.as_ref().unwrap().property_name, "kind"); assert!(matches!( &schema.kind, NormalizedSchemaKind::Composition { operator, variants } if operator == "oneOf" && variants.len() == 2 )); let preview = preview_from_ir(&ir); let candidate = &preview.groups[0].operations[0]; assert_eq!( candidate .draft .input_schema .fields .get("body") .unwrap() .variants .len(), 2 ); let any_of = document .replace("discriminator:\n propertyName: kind\n mapping: { text: '#/components/schemas/Text' }\n oneOf:", "anyOf:"); let ir = normalize(&any_of); assert!(matches!( &ir.operations[0].request_body_schema.as_ref().unwrap().kind, NormalizedSchemaKind::Composition { operator, variants } if operator == "anyOf" && variants.len() == 2 )); let preview = preview_from_ir(&ir); assert_eq!( preview.groups[0].operations[0].draft.input_schema.fields["body"] .variants .len(), 2 ); } #[test] fn oas_31_applies_ref_siblings_while_oas_30_ignores_them() { let template = |version: &str| { format!( r#" openapi: {version} info: {{ title: Siblings }} servers: [{{ url: https://api.example.test }}] paths: /items: get: operationId: listItems responses: '200': description: ok content: application/json: schema: $ref: '#/components/schemas/Item' description: sibling-description components: schemas: Item: {{ type: string, description: target-description }} "# ) }; let v30 = normalize(&template("3.0.3")); let v31 = normalize(&template("3.1.0")); assert_eq!( v30.operations[0] .response_schema .as_ref() .unwrap() .description .as_deref(), Some("target-description") ); assert_eq!( v31.operations[0] .response_schema .as_ref() .unwrap() .description .as_deref(), Some("sibling-description") ); } #[test] fn invalid_pointer_and_type_mismatch_are_exact_blockers_without_panic() { let document = r#" openapi: 3.1.0 info: { title: Invalid targets } servers: [{ url: https://api.example.test }] paths: /missing: get: operationId: missing responses: '200': { description: ok, content: { application/json: { schema: { $ref: '#not-a-pointer' } } } } /scalar: get: operationId: scalar responses: '200': { description: ok, content: { application/json: { schema: { $ref: '#/info/title' } } } } "#; let ir = normalize(document); let codes = ir .operations .iter() .flat_map(|operation| { operation .findings .iter() .map(move |finding| (operation.path.as_str(), finding.code.as_str())) }) .collect::>(); assert!(codes.contains(&("/missing", "reference_target_missing"))); assert!(codes.contains(&("/scalar", "reference_type_mismatch"))); } #[test] fn reference_depth_and_expanded_node_limits_fail_closed() { let document = r#" openapi: 3.1.0 info: { title: Bounded } servers: [{ url: https://api.example.test }] paths: /items: get: operationId: listItems responses: '200': { description: ok, content: { application/json: { schema: { $ref: '#/components/schemas/A' } } } } components: schemas: A: { $ref: '#/components/schemas/B' } B: { $ref: '#/components/schemas/C' } C: { type: object, properties: { id: { type: string } } } "#; let config = NormalizationConfig { max_reference_depth: 1, ..NormalizationConfig::default() }; let ir = normalize_verified_document(document, digest('a'), &config).unwrap(); assert!( ir.operations .iter() .flat_map(|operation| &operation.findings) .any(|finding| finding.code == "reference_graph_limit") ); let config = NormalizationConfig { max_expanded_nodes: 8, ..NormalizationConfig::default() }; let ir = normalize_verified_document(document, digest('a'), &config).unwrap(); assert!( ir.findings .iter() .chain( ir.operations .iter() .flat_map(|operation| &operation.findings) ) .any(|finding| finding.code == "reference_graph_limit") ); } #[test] fn invalid_discriminator_and_multiple_composition_operators_are_blockers() { let document = r#" openapi: 3.1.0 info: { title: Unsupported composition } servers: [{ url: https://api.example.test }] paths: /events: post: operationId: createEvent requestBody: content: application/json: schema: discriminator: { mapping: { bad: 42 } } oneOf: [{ type: string }, { type: integer }] anyOf: [{ type: boolean }, { type: string }] responses: { '204': { description: ok } } "#; let ir = normalize(document); let codes = ir.operations[0] .findings .iter() .map(|finding| finding.code.as_str()) .collect::>(); assert!(codes.contains("unsupported_discriminator")); assert!(codes.contains("unsupported_composition")); } #[test] fn reference_uri_scan_applies_external_size_depth_and_alias_limits_before_traversal() { let config = NormalizationConfig { max_bytes: 8, max_external_document_bytes: 8, ..NormalizationConfig::default() }; assert_eq!( reference_uris("external-document", &config), Err(crank_import::rest::ImportParseError::LimitExceeded) ); let config = NormalizationConfig { max_bytes: 8, max_external_document_bytes: 32, ..NormalizationConfig::default() }; assert_eq!( external_reference_uris("external-document", &config), Ok(Vec::new()) ); let config = NormalizationConfig { max_depth: 1, ..NormalizationConfig::default() }; assert_eq!( reference_uris("a: { b: { $ref: '#/x' } }", &config), Err(crank_import::rest::ImportParseError::LimitExceeded) ); let aliases = format!( "items: [{}]", std::iter::repeat_n("*a", 129) .collect::>() .join(", ") ); assert_eq!( reference_uris(&aliases, &NormalizationConfig::default()), Err(crank_import::rest::ImportParseError::LimitExceeded) ); } #[test] fn resolves_relative_references_using_rfc3986_paths_and_decoded_fragments() { let document = r#" openapi: 3.1.0 info: { title: Relative refs } servers: [{ url: https://api.example.test }] paths: /items: get: operationId: listItems responses: '200': description: ok content: { application/json: { schema: { $ref: 'HTTPS://SCHEMAS.EXAMPLE.TEST:443/a/b/root.yaml#/Item' } } } "#; let snapshots = vec![ ExternalDocumentSnapshot { canonical_uri: "https://schemas.example.test/a/b/root.yaml".to_owned(), digest: digest('b'), document: r#" Item: type: object properties: dot: { $ref: './child.yaml#/Value' } parent: { $ref: '../common.yaml#/Value' } absolute: { $ref: '/shared.yaml#/Value' } "# .to_owned(), }, ExternalDocumentSnapshot { canonical_uri: "https://schemas.example.test/a/b/child.yaml".to_owned(), digest: digest('c'), document: "Value: { type: string }".to_owned(), }, ExternalDocumentSnapshot { canonical_uri: "https://schemas.example.test/a/common.yaml".to_owned(), digest: digest('d'), document: "Value: { type: integer }".to_owned(), }, ExternalDocumentSnapshot { canonical_uri: "https://schemas.example.test/shared.yaml".to_owned(), digest: digest('e'), document: "Value: { $ref: '#/a%7E1b' }\na/b: { type: boolean }".to_owned(), }, ]; let ir = normalize_verified_bundle( document, digest('a'), &snapshots, &NormalizationConfig::default(), ) .unwrap(); let Some(NormalizedSchemaKind::Object { properties, .. }) = ir.operations[0] .response_schema .as_ref() .map(|schema| &schema.kind) else { panic!("response should be an expanded object schema"); }; assert_eq!(properties.len(), 3); assert_eq!(ir.reference_graph.edges.len(), 5); assert!(ir.findings.is_empty()); } #[test] fn malformed_percent_encoded_fragment_is_a_blocker() { let document = r#" openapi: 3.1.0 info: { title: Malformed reference } servers: [{ url: https://api.example.test }] paths: /items: get: operationId: listItems responses: '200': { description: ok, content: { application/json: { schema: { $ref: '#/components/%ZZ' } } } } "#; let ir = normalize(document); assert!( ir.operations[0] .findings .iter() .any(|finding| finding.code == "reference_uri_malformed") ); } #[test] fn does_not_follow_references_inside_literal_payloads() { let document = r#" openapi: 3.1.0 info: { title: Literal payloads } servers: [{ url: https://api.example.test }] paths: /items: get: operationId: listItems responses: '200': description: ok content: application/json: schema: type: object example: { $ref: '#/components/schemas/Missing' } examples: { sample: { value: { $ref: '#/components/schemas/Missing' } } } default: { $ref: '#/components/schemas/Missing' } enum: [{ $ref: '#/components/schemas/Missing' }] const: { $ref: '#/components/schemas/Missing' } x-fixture: { $ref: '#/components/schemas/Missing' } properties: { known: { $ref: '#/components/schemas/Known' } } components: schemas: Known: { type: string } "#; assert_eq!( reference_uris(document, &NormalizationConfig::default()), Ok(vec!["#/components/schemas/Known".to_owned()]) ); let ir = normalize(document); assert_eq!(ir.reference_graph.edges.len(), 1); assert!( ir.operations[0] .findings .iter() .all(|finding| finding.code != "reference_target_missing") ); } #[test] fn primary_oas31_applies_ref_siblings_in_external_snapshots_without_openapi_field() { let source = |version: &str| { format!( r#" openapi: {version} info: {{ title: External siblings }} servers: [{{ url: https://api.example.test }}] paths: /items: get: operationId: listItems responses: '200': {{ description: ok, content: {{ application/json: {{ schema: {{ $ref: 'https://schemas.example.test/root.yaml#/Item' }} }} }} }} "# ) }; let snapshots = vec![ ExternalDocumentSnapshot { canonical_uri: "https://schemas.example.test/root.yaml".to_owned(), digest: digest('b'), document: "Item: { $ref: 'child.yaml#/Base', description: sibling }".to_owned(), }, ExternalDocumentSnapshot { canonical_uri: "https://schemas.example.test/child.yaml".to_owned(), digest: digest('c'), document: "Base: { type: string, description: target }".to_owned(), }, ]; let v31 = normalize_verified_bundle( &source("3.1.0"), digest('a'), &snapshots, &NormalizationConfig::default(), ) .unwrap(); let v30 = normalize_verified_bundle( &source("3.0.3"), digest('a'), &snapshots, &NormalizationConfig::default(), ) .unwrap(); assert_eq!( v31.operations[0] .response_schema .as_ref() .unwrap() .description .as_deref(), Some("sibling") ); assert_eq!( v30.operations[0] .response_schema .as_ref() .unwrap() .description .as_deref(), Some("target") ); } #[test] fn all_of_intersects_constraints_and_nested_properties_without_losing_conflicts() { let source = r#" openapi: 3.1.0 info: { title: allOf intersections } servers: [{ url: https://api.example.test }] paths: /items: post: operationId: createItem requestBody: content: application/json: schema: allOf: - type: object minimum: 1 maximum: 10 minLength: 2 maxLength: 12 properties: { nested: { type: object, properties: { left: { type: string } } } } - type: object minimum: 4 maximum: 8 minLength: 5 maxLength: 9 properties: { nested: { type: object, properties: { right: { type: integer } } } } responses: { '204': { description: ok } } "#; let ir = normalize(source); let schema = ir.operations[0].request_body_schema.as_ref().unwrap(); assert_eq!(schema.constraints.minimum, Some(4.0)); assert_eq!(schema.constraints.maximum, Some(8.0)); assert_eq!(schema.constraints.min_length, Some(5)); assert_eq!(schema.constraints.max_length, Some(9)); let NormalizedSchemaKind::Object { properties, .. } = &schema.kind else { panic!("merged schema should remain an object"); }; let NormalizedSchemaKind::Object { properties, .. } = &properties["nested"].kind else { panic!("nested property should remain an object"); }; assert!(properties.contains_key("left") && properties.contains_key("right")); let conflicting = source.replace("maximum: 8", "maximum: 3"); let ir = normalize(&conflicting); assert!( ir.operations[0] .findings .iter() .any(|finding| finding.code == "all_of_conflict") ); } #[test] fn non_array_all_of_is_preserved_and_reported() { let document = r#" openapi: 3.1.0 info: { title: Invalid allOf } servers: [{ url: https://api.example.test }] paths: /items: post: operationId: createItem requestBody: content: { application/json: { schema: { allOf: { type: string } } } } responses: { '204': { description: ok } } "#; let ir = normalize(document); assert!( ir.operations[0] .findings .iter() .any(|finding| finding.code == "unsupported_composition") ); assert!(matches!( ir.operations[0] .request_body_schema .as_ref() .map(|schema| &schema.kind), Some(NormalizedSchemaKind::Unknown) )); }