use wip_https::http::StatusCode; use wip_https::wip_protocol::{ FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode, }; use wip_https::{ BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchResponseItem, BatchSuccess, ClientResponseError, DecodedResponse, Endpoint, InvalidResponseKind, Limits, ResourceLimitKind, decode_batch_request, decode_batch_response, encode_batch_error_response, encode_batch_request, encode_batch_resource_limit_error_response, encode_batch_response, }; fn limits() -> Limits { Limits::new(100_000, 100_000, 64, 4).unwrap() } fn request() -> BatchRequest { BatchRequest { requests: vec![ BatchRequestItem { id: "object".into(), request: BatchRequestKind::Fetch(FetchRequest { path: "/item".into(), }), }, BatchRequestItem { id: "interface".into(), request: BatchRequestKind::FetchInterface(FetchInterfaceRequest { interface: "interface-v1".into(), }), }, ], } } #[test] fn batch_round_trips_by_unique_id_with_item_failures_and_validators() { let request = request(); let endpoint = Endpoint::parse("https://example.test/wip").unwrap(); let encoded_request = encode_batch_request(&endpoint, &request, limits()).unwrap(); assert_eq!(encoded_request.uri().path(), "/wip/v1/batch"); assert_eq!(encoded_request.headers()["accept"], "application/json"); assert_eq!( decode_batch_request(encoded_request.body(), limits()).unwrap(), request ); let response = BatchResponse { responses: vec![ BatchResponseItem { id: "interface".into(), outcome: Err(ProtocolError { code: ProtocolErrorCode::InterfaceNotFound, message: "gone".into(), }), }, BatchResponseItem { id: "object".into(), outcome: Ok(BatchSuccess::Fetch(Object { name: "item".into(), description: None, interfaces: vec![], r#ref: None, validator: Some(vec![0, 1, 2, 255]), })), }, ], }; let encoded = encode_batch_response(&request, &response, limits()).unwrap(); assert_eq!(encoded.status(), StatusCode::OK); assert!( std::str::from_utf8(encoded.body()) .unwrap() .contains("AAEC/w==") ); assert_eq!( decode_batch_response(&request, &encoded, limits()).unwrap(), DecodedResponse::Success(response) ); } #[test] fn batch_interface_validator_round_trips_in_item_body() { let request = BatchRequest { requests: vec![BatchRequestItem { id: "interface".into(), request: BatchRequestKind::FetchInterface(FetchInterfaceRequest { interface: "interface-v1".into(), }), }], }; let response = BatchResponse { responses: vec![BatchResponseItem { id: "interface".into(), outcome: Ok(BatchSuccess::FetchInterface(FetchInterfaceResponse { interface: "interface-v1".into(), descriptor: InterfaceDescriptor { format: INTERFACE_FORMAT_V1.into(), documentation: None, types: vec![], operations: vec![], }, validator: Some(vec![255, 254, 253]), })), }], }; let encoded = encode_batch_response(&request, &response, limits()).unwrap(); assert!( std::str::from_utf8(encoded.body()) .unwrap() .contains("//79") ); assert_eq!( decode_batch_response(&request, &encoded, limits()).unwrap(), DecodedResponse::Success(response) ); } #[test] fn malformed_known_method_arguments_are_item_local() { let body = br#"{ "requests": [ {"id":"valid","method":"fetch","arguments":{"path":"/item"}}, {"id":"invalid","method":"fetch_tree","arguments":{"path":"/","depth":"deep","limit":1}} ] }"#; let decoded = decode_batch_request(body, limits()).unwrap(); assert!(matches!( decoded.requests[0].request, BatchRequestKind::Fetch(_) )); let invalid = match &decoded.requests[1].request { BatchRequestKind::Invalid { method, error } => { assert_eq!(*method, BatchMethod::FetchTree); assert_eq!(error.code, ProtocolErrorCode::InvalidRequest); error.clone() } other => panic!("expected item-local invalid request, got {other:?}"), }; let response = BatchResponse { responses: vec![ BatchResponseItem { id: "valid".into(), outcome: Ok(BatchSuccess::Fetch(Object { name: "item".into(), description: None, interfaces: vec![], r#ref: None, validator: None, })), }, BatchResponseItem { id: "invalid".into(), outcome: Err(invalid), }, ], }; let encoded = encode_batch_response(&decoded, &response, limits()).unwrap(); assert_eq!(encoded.status(), StatusCode::OK); assert_eq!( decode_batch_response(&decoded, &encoded, limits()).unwrap(), DecodedResponse::Success(response) ); } #[test] fn invalid_batch_envelopes_are_rejected_at_top_level() { assert!(decode_batch_request(br#"{}"#, limits()).is_err()); let malformed_item = br#"{ "requests": [ {"id":"item","method":"fetch","arguments":{"path":"/"},"extra":true} ] }"#; assert!(decode_batch_request(malformed_item, limits()).is_err()); let duplicate = br#"{ "requests": [ {"id":"same","method":"fetch","arguments":{"path":"/a"}}, {"id":"same","method":"fetch","arguments":{"path":"/b"}} ] }"#; assert!(decode_batch_request(duplicate, limits()).is_err()); let operation = br#"{ "requests": [ {"id":"call","method":"call_operation","arguments":{}} ] }"#; assert!(decode_batch_request(operation, limits()).is_err()); let too_many = Limits::new(100_000, 100_000, 64, 1).unwrap(); assert!(decode_batch_request(duplicate, too_many).is_err()); } #[test] fn batch_resource_limits_use_413_or_422_and_never_429() { let error = ProtocolError { code: ProtocolErrorCode::ResourceLimitExceeded, message: "bounded".into(), }; for (kind, status) in [ ( ResourceLimitKind::RepresentationBytes, StatusCode::PAYLOAD_TOO_LARGE, ), (ResourceLimitKind::Logical, StatusCode::UNPROCESSABLE_ENTITY), ] { let encoded = encode_batch_resource_limit_error_response(&error, kind, limits()).unwrap(); assert_eq!(encoded.status(), status); assert_ne!(encoded.status(), StatusCode::TOO_MANY_REQUESTS); assert_eq!( decode_batch_response(&request(), &encoded, limits()).unwrap(), DecodedResponse::ProtocolFailure { error: error.clone(), status_mismatch: None, } ); } } #[test] fn batch_success_body_requires_exactly_http_200() { let request = request(); let response = BatchResponse { responses: vec![ BatchResponseItem { id: "object".into(), outcome: Ok(BatchSuccess::Fetch(Object { name: "item".into(), description: None, interfaces: vec![], r#ref: None, validator: None, })), }, BatchResponseItem { id: "interface".into(), outcome: Err(ProtocolError { code: ProtocolErrorCode::InterfaceNotFound, message: "gone".into(), }), }, ], }; let encoded = encode_batch_response(&request, &response, limits()).unwrap(); for status in [StatusCode::CREATED, StatusCode::ACCEPTED] { let mut noncanonical = encoded.clone(); *noncanonical.status_mut() = status; assert!(matches!( decode_batch_response(&request, &noncanonical, limits()).unwrap_err(), ClientResponseError::InvalidResponse { .. } )); } } #[test] fn top_level_batch_errors_use_the_batch_allowed_code_set() { let invalid = ProtocolError { code: ProtocolErrorCode::InvalidRequest, message: "bad envelope".into(), }; let encoded = encode_batch_error_response(&invalid, limits()).unwrap(); assert_eq!( decode_batch_response(&request(), &encoded, limits()).unwrap(), DecodedResponse::ProtocolFailure { error: invalid, status_mismatch: None, } ); let not_found = wip_https::http::Response::builder() .status(StatusCode::NOT_FOUND) .header("content-type", "application/json; charset=utf-8") .body(br#"{"error":{"code":"not_found","message":"not a batch failure"}}"#.to_vec()) .unwrap(); assert!(matches!( decode_batch_response(&request(), ¬_found, limits()).unwrap_err(), ClientResponseError::InvalidResponse { kind: InvalidResponseKind::InvalidBatch, .. } )); } #[test] fn unsupported_batch_descriptor_waits_for_envelope_correlation() { let duplicate = wip_https::http::Response::builder() .status(StatusCode::OK) .header("content-type", "application/json; charset=utf-8") .body( br#"{"responses":[{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2","future":true}}},{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"}}}]}"# .to_vec(), ) .unwrap(); assert!(matches!( decode_batch_response(&request(), &duplicate, limits()).unwrap_err(), ClientResponseError::InvalidResponse { kind: InvalidResponseKind::InvalidBatch, .. } )); let missing = wip_https::http::Response::builder() .status(StatusCode::OK) .header("content-type", "application/json; charset=utf-8") .body( br#"{"responses":[{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"}}}]}"# .to_vec(), ) .unwrap(); assert!(matches!( decode_batch_response(&request(), &missing, limits()).unwrap_err(), ClientResponseError::InvalidResponse { kind: InvalidResponseKind::InvalidBatch, .. } )); let request = BatchRequest { requests: vec![BatchRequestItem { id: "interface".into(), request: BatchRequestKind::FetchInterface(FetchInterfaceRequest { interface: "interface-v1".into(), }), }], }; assert!(matches!( decode_batch_response(&request, &missing, limits()).unwrap_err(), ClientResponseError::UnsupportedDescriptorFormat { format } if format == "wip-interface/2" )); } #[test] fn batch_response_must_have_exactly_the_requested_ids() { let request = request(); let missing = BatchResponse { responses: vec![BatchResponseItem { id: "object".into(), outcome: Ok(BatchSuccess::Fetch(Object { name: "item".into(), description: None, interfaces: vec![], r#ref: None, validator: None, })), }], }; assert!(encode_batch_response(&request, &missing, limits()).is_err()); }