Files
bsodfather 6c2a3712d8
CI / Rust Checks (push) Failing after 3m12s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped
feat(import): add deterministic OpenAPI normalized IR
2026-08-29 04:15:37 +03:00

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,
}
}