581 lines
20 KiB
Rust
581 lines
20 KiB
Rust
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<crank_import::rest::NormalizedIr, ImportParseError> {
|
|
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 keeps_references_typed_without_resolving_them() {
|
|
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::Reference { .. })
|
|
));
|
|
assert!(
|
|
operation
|
|
.findings
|
|
.iter()
|
|
.any(|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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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::<std::collections::BTreeSet<_>>()
|
|
.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::<std::collections::BTreeSet<_>>();
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>()
|
|
.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 { .. })
|
|
));
|
|
}
|
|
}
|
|
}
|