276 lines
8.7 KiB
Rust
276 lines
8.7 KiB
Rust
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,
|
|
}
|
|
}
|