feat(import): add deterministic OpenAPI normalized IR
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
use crank_import::rest::model::{CanonicalSourceNode, CanonicalSourceValue, CoverageDisposition};
|
||||
use crank_import::rest::{
|
||||
ImportFindingSeverity, ImportParseError, NormalizationConfig, NormalizedFinding, NormalizedIr,
|
||||
SourceDigest, SourceLocation, normalize_verified_document, validate_normalized_ir,
|
||||
};
|
||||
|
||||
fn normalize(document: &str) -> NormalizedIr {
|
||||
normalize_verified_document(
|
||||
document,
|
||||
SourceDigest::parse("c".repeat(64)).unwrap(),
|
||||
&NormalizationConfig::default(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn valid_source() -> &'static str {
|
||||
r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Coverage, version: v1 }
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItems
|
||||
tags: [items]
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content:
|
||||
application/json:
|
||||
schema: { type: object, properties: { id: { type: string } } }
|
||||
"#
|
||||
}
|
||||
|
||||
fn assert_invalid(ir: &NormalizedIr) {
|
||||
assert_eq!(
|
||||
validate_normalized_ir(ir),
|
||||
Err(ImportParseError::InvalidDocument)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_digest_deserialization_preserves_the_validated_invariant() {
|
||||
assert!(serde_json::from_str::<SourceDigest>(&format!("\"{}\"", "a".repeat(64))).is_ok());
|
||||
assert!(serde_json::from_str::<SourceDigest>(&format!("\"{}\"", "A".repeat(64))).is_err());
|
||||
assert!(serde_json::from_str::<SourceDigest>("\"not-a-digest\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_coverage_location_disposition_and_orphan_finding() {
|
||||
let ir = normalize(valid_source());
|
||||
let operation_id = ir.operations[0].stable_id.clone();
|
||||
|
||||
let mut wrong_location = ir.clone();
|
||||
wrong_location
|
||||
.coverage
|
||||
.iter_mut()
|
||||
.find(|entry| entry.construct_id == operation_id)
|
||||
.unwrap()
|
||||
.location
|
||||
.pointer = "/paths/~1other/get".to_owned();
|
||||
assert_invalid(&wrong_location);
|
||||
|
||||
let mut wrong_disposition = ir.clone();
|
||||
wrong_disposition
|
||||
.coverage
|
||||
.iter_mut()
|
||||
.find(|entry| entry.construct_id == operation_id)
|
||||
.unwrap()
|
||||
.disposition = CoverageDisposition::Finding;
|
||||
assert_invalid(&wrong_disposition);
|
||||
|
||||
let mut duplicate = ir.clone();
|
||||
duplicate.coverage.push(duplicate.coverage[0].clone());
|
||||
assert_invalid(&duplicate);
|
||||
|
||||
let mut orphan = ir;
|
||||
orphan.findings.push(NormalizedFinding {
|
||||
code: "forged".to_owned(),
|
||||
severity: ImportFindingSeverity::Warning,
|
||||
message: "forged".to_owned(),
|
||||
construct_id: "orphan".to_owned(),
|
||||
location: SourceLocation {
|
||||
pointer: String::new(),
|
||||
},
|
||||
operation_key: None,
|
||||
});
|
||||
assert_invalid(&orphan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_contract_operation_identity_and_source_tree() {
|
||||
let ir = normalize(valid_source());
|
||||
|
||||
let mut normalizer = ir.clone();
|
||||
normalizer.normalizer_version = "future-normalizer".to_owned();
|
||||
assert_invalid(&normalizer);
|
||||
|
||||
let mut projection = ir.clone();
|
||||
projection.projection_version = "future-projection".to_owned();
|
||||
assert_invalid(&projection);
|
||||
|
||||
let mut source_version = ir.clone();
|
||||
source_version.source.version = Some("3.0.3".to_owned());
|
||||
assert_invalid(&source_version);
|
||||
|
||||
let mut stable_id = ir.clone();
|
||||
stable_id.operations[0].stable_id.push_str(":forged");
|
||||
assert_invalid(&stable_id);
|
||||
|
||||
let mut operation_location = ir.clone();
|
||||
operation_location.operations[0].location.pointer = "/paths/~1items/post".to_owned();
|
||||
assert_invalid(&operation_location);
|
||||
|
||||
let mut operation_key = ir.clone();
|
||||
operation_key.operations[0].key = "POST /items".to_owned();
|
||||
assert_invalid(&operation_key);
|
||||
|
||||
let mut schema_description = ir.clone();
|
||||
schema_description.operations[0]
|
||||
.response_schema
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.description = Some("forged description".to_owned());
|
||||
assert_invalid(&schema_description);
|
||||
|
||||
let mut source_tree = ir;
|
||||
source_tree.source_tree.location.pointer = "/forged".to_owned();
|
||||
assert_invalid(&source_tree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_every_recursive_source_tree_pointer_and_construct_id() {
|
||||
let mut ir = normalize(valid_source());
|
||||
let CanonicalSourceValue::Object(root) = &mut ir.source_tree.value else {
|
||||
panic!("source root must be an object");
|
||||
};
|
||||
let CanonicalSourceValue::Object(info) = &mut root.get_mut("info").unwrap().value else {
|
||||
panic!("info must be an object");
|
||||
};
|
||||
let title = info.get_mut("title").unwrap();
|
||||
title.construct_id = "source:/info/wrong".to_owned();
|
||||
assert_invalid(&ir);
|
||||
|
||||
let mut ir = normalize(valid_source());
|
||||
let title = source_node_mut(&mut ir.source_tree, "/info/title").unwrap();
|
||||
title.location.pointer = "/info/wrong".to_owned();
|
||||
assert_invalid(&ir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_virtual_missing_operation_id_with_exact_finding_coverage() {
|
||||
let ir = normalize(
|
||||
"openapi: 3.1.0\ninfo: { title: Missing ID }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }",
|
||||
);
|
||||
validate_normalized_ir(&ir).unwrap();
|
||||
let operation = &ir.operations[0];
|
||||
let id = format!("{}:operation_id", operation.stable_id);
|
||||
let expected_pointer = format!("{}/operationId", operation.location.pointer);
|
||||
assert!(source_node(&ir.source_tree, &expected_pointer).is_none());
|
||||
assert!(operation.findings.iter().any(|finding| {
|
||||
finding.code == "missing_operation_id"
|
||||
&& finding.construct_id == id
|
||||
&& finding.location.pointer == expected_pointer
|
||||
}));
|
||||
assert!(ir.coverage.iter().any(|entry| {
|
||||
entry.construct_id == id
|
||||
&& entry.location.pointer == expected_pointer
|
||||
&& entry.disposition == CoverageDisposition::Finding
|
||||
}));
|
||||
|
||||
let mut missing_finding = ir;
|
||||
missing_finding.operations[0]
|
||||
.findings
|
||||
.retain(|finding| finding.code != "missing_operation_id");
|
||||
assert_invalid(&missing_finding);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_shared_path_parameter_and_schema_coverage_exactly() {
|
||||
let ir = normalize(
|
||||
r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Shared }
|
||||
paths:
|
||||
/items/{id}:
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
get:
|
||||
operationId: getItem
|
||||
responses: { '200': { description: ok } }
|
||||
post:
|
||||
operationId: updateItem
|
||||
responses: { '200': { description: ok } }
|
||||
"#,
|
||||
);
|
||||
validate_normalized_ir(&ir).unwrap();
|
||||
assert_eq!(ir.operations.len(), 2);
|
||||
assert_eq!(ir.operations[0].parameters, ir.operations[1].parameters);
|
||||
let parameter_id = &ir.operations[0].parameters[0].construct_id;
|
||||
let schema_id = &ir.operations[0].parameters[0]
|
||||
.schema
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.construct_id;
|
||||
assert_eq!(
|
||||
ir.coverage
|
||||
.iter()
|
||||
.filter(|entry| &entry.construct_id == parameter_id)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
ir.coverage
|
||||
.iter()
|
||||
.filter(|entry| &entry.construct_id == schema_id)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_method_has_exact_finding_pointer_and_coverage() {
|
||||
let ir = normalize(
|
||||
"openapi: 3.1.0\ninfo: { title: Unsupported }\npaths: { /ok: { get: { responses: { '200': { description: ok } } }, head: { responses: {} } } }",
|
||||
);
|
||||
validate_normalized_ir(&ir).unwrap();
|
||||
let pointer = "/paths/~1ok/head";
|
||||
let construct_id = format!("3.1.0:{pointer}");
|
||||
assert!(ir.findings.iter().any(|finding| {
|
||||
finding.code == "unsupported_http_method"
|
||||
&& finding.construct_id == construct_id
|
||||
&& finding.location.pointer == pointer
|
||||
}));
|
||||
assert!(ir.coverage.iter().any(|entry| {
|
||||
entry.construct_id == construct_id
|
||||
&& entry.location.pointer == pointer
|
||||
&& entry.disposition == CoverageDisposition::Finding
|
||||
}));
|
||||
}
|
||||
|
||||
fn source_node<'a>(
|
||||
node: &'a CanonicalSourceNode,
|
||||
pointer: &str,
|
||||
) -> Option<&'a CanonicalSourceNode> {
|
||||
if node.location.pointer == pointer {
|
||||
return Some(node);
|
||||
}
|
||||
match &node.value {
|
||||
CanonicalSourceValue::Array(items) => {
|
||||
items.iter().find_map(|child| source_node(child, pointer))
|
||||
}
|
||||
CanonicalSourceValue::Object(items) => {
|
||||
items.values().find_map(|child| source_node(child, pointer))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn source_node_mut<'a>(
|
||||
node: &'a mut CanonicalSourceNode,
|
||||
pointer: &str,
|
||||
) -> Option<&'a mut CanonicalSourceNode> {
|
||||
if node.location.pointer == pointer {
|
||||
return Some(node);
|
||||
}
|
||||
match &mut node.value {
|
||||
CanonicalSourceValue::Array(items) => items
|
||||
.iter_mut()
|
||||
.find_map(|child| source_node_mut(child, pointer)),
|
||||
CanonicalSourceValue::Object(items) => items
|
||||
.values_mut()
|
||||
.find_map(|child| source_node_mut(child, pointer)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user