//! HTTP admission limits must reject endpoint decisions before transition planning. use super::*; use oa_core::{semantic::SemanticTransitionRequest, transitions::*}; async fn post( server: &TestServer, id: Id, route: &str, etag: &str, body: &Value, ) -> reqwest::Response { server .client .post(server.url(&format!("/projects/{id}/{route}"))) .header("if-match", etag) .json(body) .send() .await .unwrap() } async fn unchanged(server: &TestServer, before: &StoredProject, disk: &Path, bytes: &[u8]) { let after = server.get(before.resource_id).await; assert_eq!(after.revision, before.revision); assert_eq!(after.etag(), before.etag()); assert_eq!(after.model, before.model); assert_eq!(after.source_project, before.source_project); assert_eq!(std::fs::read(disk).unwrap(), bytes); } async fn endpoint_limit(semantic: bool) { let directory = tempfile::tempdir().unwrap(); let server = TestServer::start(directory.path(), None).await; let mut model = Project::empty(); let source = model .add_element(Kind::Activity, Layer::Oa, "Inspect service", None) .unwrap(); let (id, etag) = server.import(&model).await; let before = server.get(id).await; let disk = directory.path().join(format!("{id}.json")); let bytes = std::fs::read(&disk).unwrap(); let transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]); let (route, mut request, decision_path, applied_decision_path) = if semantic { ( "semantic-transitions", serde_json::to_value(SemanticTransitionRequest::new(transition)).unwrap(), "/transition/communication_endpoints", "/plan/request/transition/communication_endpoints", ) } else { ( "transitions", serde_json::to_value(transition).unwrap(), "/communication_endpoints", "/plan/request/communication_endpoints", ) }; // Obtain a real prepared envelope first: apply must enforce the same bound, // even when a client modifies a previously valid plan's embedded request. let response = post(&server, id, &format!("{route}/preview"), &etag, &request).await; let status = response.status(); let mut plan: Value = response.json().await.unwrap(); assert_eq!(status, StatusCode::OK, "{plan}"); assert_eq!(plan["plan"]["conflicts"], json!([])); unchanged(&server, &before, &disk, &bytes).await; // Syntactically typed but semantically inapplicable decisions deliberately // distinguish capacity admission (400) from core semantic rejection (422 or // a preview containing conflicts). The bound counts entries, not unique IDs. let decision = CommunicationEndpointDecision { source, source_port: CommunicationPortChoice::Reuse { port: source }, target_port: CommunicationPortChoice::Reuse { port: source }, }; *request.pointer_mut(decision_path).unwrap() = json!(vec![decision.clone(); 200]); let response = post(&server, id, &format!("{route}/preview"), &etag, &request).await; let status = response.status(); let body: Value = response.json().await.unwrap(); assert!( matches!(status, StatusCode::OK | StatusCode::UNPROCESSABLE_ENTITY), "Exactly 200 decisions must pass capacity admission: {status} {body}" ); unchanged(&server, &before, &disk, &bytes).await; let over_limit = json!(vec![decision; 201]); *request.pointer_mut(decision_path).unwrap() = over_limit.clone(); *plan.pointer_mut(applied_decision_path).unwrap() = over_limit; for (operation, body) in [("preview", request), ("apply", plan)] { let response = post(&server, id, &format!("{route}/{operation}"), &etag, &body).await; let status = response.status(); let error: Value = response.json().await.unwrap(); assert_eq!( status, StatusCode::BAD_REQUEST, "{route}/{operation}: {error}" ); assert_eq!(error["code"], "invalid_request", "{error}"); let detail = error["detail"].as_str().unwrap().to_lowercase(); assert!( detail.contains("200") && detail.contains("endpoint"), "Capacity rejection must identify endpoint decisions: {error}" ); unchanged(&server, &before, &disk, &bytes).await; } server.stop().await; let server = TestServer::start(directory.path(), None).await; unchanged(&server, &before, &disk, &bytes).await; server.stop().await; } #[tokio::test] async fn ordinary_transition_endpoint_decision_limit_rejects_201_without_mutation() { endpoint_limit(false).await; } #[tokio::test] async fn semantic_transition_endpoint_decision_limit_rejects_201_without_mutation() { endpoint_limit(true).await; }