//! HTTP admission limits reject bounded explicit decisions before 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 decision_limit(semantic: bool, operations: 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 decision_name = if operations { "operation_omissions" } else { "communication_endpoints" }; let (route, mut request, decision_parent, applied_decision_parent) = if semantic { ( "semantic-transitions", serde_json::to_value(SemanticTransitionRequest::new(transition)).unwrap(), "/transition", "/plan/request/transition", ) } else { ( "transitions", serde_json::to_value(transition).unwrap(), "", "/plan/request", ) }; // 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 = if operations { json!(OperationOmission { source, rationale: "Explicitly abstract technical operation".into() }) } else { json!(CommunicationEndpointDecision { source, source_port: CommunicationPortChoice::Reuse { port: source }, target_port: CommunicationPortChoice::Reuse { port: source }, }) }; request .pointer_mut(decision_parent) .unwrap() .as_object_mut() .unwrap() .insert(decision_name.into(), 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_parent) .unwrap() .as_object_mut() .unwrap() .insert(decision_name.into(), over_limit.clone()); plan.pointer_mut(applied_decision_parent) .unwrap() .as_object_mut() .unwrap() .insert(decision_name.into(), 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(if operations { "omission" } else { "endpoint" }), "Capacity rejection must identify the bounded 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() { decision_limit(false, false).await; } #[tokio::test] async fn semantic_transition_endpoint_decision_limit_rejects_201_without_mutation() { decision_limit(true, false).await; } #[tokio::test] async fn ordinary_transition_operation_omission_limit_rejects_201_without_mutation() { decision_limit(false, true).await; } #[tokio::test] async fn semantic_transition_operation_omission_limit_rejects_201_without_mutation() { decision_limit(true, true).await; }