use super::*; use axum::{ Json, Router, http::StatusCode, response::IntoResponse, routing::{get, post, put}, }; use serde_json::json; use std::{ collections::BTreeMap, sync::atomic::{AtomicUsize, Ordering}, }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, sync::{oneshot, watch}, task::JoinHandle, }; struct TestServer { base: String, stop: Option>, task: JoinHandle<()>, _directory: Option, } impl TestServer { async fn fixture(router: Router) -> Self { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let (stop, done) = oneshot::channel(); let task = tokio::spawn(async move { axum::serve(listener, router) .with_graceful_shutdown(async { let _ = done.await; }) .await .unwrap(); }); Self { base, stop: Some(stop), task, _directory: None, } } async fn production(token: Option, prefix: bool) -> Self { let directory = tempfile::tempdir().unwrap(); let path = directory.path().to_owned(); let store = Arc::new( tokio::task::spawn_blocking(move || oa_server::store::Store::open(path)) .await .unwrap() .unwrap(), ); let router = oa_server::app(store, token); let router = if prefix { Router::new().nest("/teams/engineering", router) } else { router }; let mut server = Self::fixture(router).await; server._directory = Some(directory); if prefix { server.base.push_str("/teams/engineering"); } server } fn connection(&self) -> Connection { Connection::new(&self.base, Secret::default()).unwrap() } async fn finish(mut self) { self.stop.take().unwrap().send(()).unwrap(); (&mut self.task).await.unwrap(); } } impl Drop for TestServer { fn drop(&mut self) { self.task.abort(); } } fn authored() -> SourceBundle { let bundle = SourceBundle { entry: "harbor.arcadia".into(), files: BTreeMap::from([ ("harbor.arcadia".into(), "// Harbor rationale 航海\r\narcadia 4; project harbor \"Harbor & rescue\" {\r\n include \"work/operations.arcadia\";\r\n}\r\n".into()), ("work/operations.arcadia".into(), "// Keep this authored organization\r\noa_activity assess \"Assess assistance\" {}\r\nsa_function assist \"Assist operator\" {}\r\n".into()), ]), }; source_project::compile_bundle(&bundle, SOURCE_LIMITS) .unwrap() .bundle } fn envelope(bundle: SourceBundle, resource_id: Id, revision: u64) -> serde_json::Value { let model = source_project::compile_bundle(&bundle, SOURCE_LIMITS) .unwrap() .decoded .project; json!({"resource_id":resource_id,"revision":revision,"model":model,"source_project":bundle}) } #[test] fn hosted_endpoint_validation_redacts_secrets_and_preserves_proxy_paths() { for base in [ "http://example.com", "http://192.168.1.4", "ftp://localhost", "not a url", "https://user:secret@example.com", "https://@example.com", "https://user@example.com", "https://example.com/?token=secret", "https://example.com/#secret", "https://example.com\\hidden", "https://example.com/\nsecret", ] { let failure = Connection::new(base, Secret("secret".into())).unwrap_err(); assert_eq!(failure.code, "invalid_endpoint"); assert!(!failure.detail.contains("secret")); assert!(!failure.uncertain); } for base in [ "http://localhost:8787", "http://127.0.0.2:8787", "http://[::1]:8787", "https://example.com/prefix", ] { assert!(Connection::new(base, Secret::default()).is_ok(), "{base}"); } let client = Connection::new( "https://example.com/teams/space%20name", Secret("private-token".into()), ) .unwrap(); assert_eq!(client.base(), "https://example.com/teams/space%20name/"); assert_eq!( client.endpoint("projects").as_str(), "https://example.com/teams/space%20name/api/v1/projects" ); assert!(!format!("{client:?}").contains("private-token")); assert_eq!( format!("{:?}", Secret("private-token".into())), "Secret([redacted])" ); assert_eq!( Connection::new("http://localhost", Secret("bad\nheader".into())) .unwrap_err() .code, "invalid_token" ); for etag in [ "*", "W/\"weak\"", "unquoted", "\"one\",\"two\"", "\"new\nline\"", ] { assert!(!valid_etag(etag), "{etag}"); } assert!(valid_etag("\"opaque:tag/+,=?\"")); } #[tokio::test] async fn hosted_production_roundtrip_preserves_comments_noops_and_conflicts() { let server = TestServer::production(None, false).await; let client = server.connection(); assert!(client.catalog("", None).await.unwrap().items.is_empty()); let bundle = authored(); let original = client.publish(bundle.clone()).await.unwrap(); assert_ne!(original.resource_id, original.compiled.decoded.project.id); assert_eq!(original.compiled.bundle, bundle); assert_eq!(original.revision, 1); let catalog = client.catalog("Harbor &", None).await.unwrap(); assert_eq!(catalog.items.len(), 1); assert_eq!(catalog.items[0].resource_id, original.resource_id); assert_eq!( catalog.items[0].model_id, original.compiled.decoded.project.id ); assert_eq!(catalog.items[0].name, "Harbor & rescue"); assert_eq!( (catalog.items[0].elements, catalog.items[0].relations), (2, 0) ); assert!( client .catalog("?q=missing&limit=1000", None) .await .unwrap() .items .is_empty() ); assert!( client .catalog("", Some(original.resource_id)) .await .unwrap() .items .is_empty() ); let same = client .save(original.resource_id, &original.etag, bundle.clone()) .await .unwrap(); assert_eq!(same.etag, original.etag); assert_eq!(same.revision, original.revision); let mut changed = bundle.clone(); changed .files .get_mut("work/operations.arcadia") .unwrap() .push_str("// Reviewed by the team\r\n"); let saved = client .save(original.resource_id, &original.etag, changed.clone()) .await .unwrap(); assert_eq!(saved.resource_id, original.resource_id); assert_eq!(saved.revision, 2); assert_ne!(saved.etag, original.etag); assert_eq!(saved.compiled.bundle, changed); assert_eq!( saved.compiled.decoded.project, original.compiled.decoded.project ); let conflict = client .save(original.resource_id, &original.etag, bundle) .await .unwrap_err(); assert_eq!(conflict.status, Some(412)); assert_eq!(conflict.code, "revision_conflict"); assert!(!conflict.uncertain); assert_eq!( client .open(original.resource_id) .await .unwrap() .compiled .bundle, changed ); server.finish().await; } #[tokio::test] async fn hosted_production_auth_proxy_prefix_and_located_rejection() { let token = "hosted-test-token-with-at-least-32-characters"; let server = TestServer::production(Some(token.into()), true).await; let unauthorized = server.connection().catalog("", None).await.unwrap_err(); assert_eq!( (unauthorized.status, unauthorized.code.as_str()), (Some(401), "unauthorized") ); let client = Connection::new(&server.base, Secret(token.into())).unwrap(); let original = client.publish(authored()).await.unwrap(); assert_eq!( client.catalog("Harbor &", None).await.unwrap().items.len(), 1 ); let mut invalid = original.compiled.bundle.clone(); invalid .files .get_mut("work/operations.arcadia") .unwrap() .push_str("this is not valid source;"); let rejected = client .save(original.resource_id, &original.etag, invalid) .await .unwrap_err(); assert_eq!(rejected.status, Some(422)); assert!(!rejected.uncertain); assert!(rejected.detail.contains("work/operations.arcadia:")); assert_eq!( client.open(original.resource_id).await.unwrap().etag, original.etag ); let missing = client.open(Id::new_v4()).await.unwrap_err(); assert_eq!(missing.status, Some(404)); assert!(!missing.uncertain); server.finish().await; } #[tokio::test] async fn hosted_redirect_is_not_followed_or_retried_and_problem_tokens_are_redacted() { let redirected = Arc::new(AtomicUsize::new(0)); let calls = Arc::clone(&redirected); let destination = TestServer::fixture(Router::new().fallback(move || { let calls = Arc::clone(&calls); async move { calls.fetch_add(1, Ordering::SeqCst); StatusCode::OK } })) .await; let location = destination.base.clone(); let attempts = Arc::new(AtomicUsize::new(0)); let seen = Arc::clone(&attempts); let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || { let location = location.clone(); let seen = Arc::clone(&seen); async move { seen.fetch_add(1, Ordering::SeqCst); (StatusCode::TEMPORARY_REDIRECT, [(header::LOCATION, location)]) } })).route("/api/v1/projects", get(|| async { (StatusCode::FORBIDDEN, Json(json!({"code":"denied","detail":"Do not print private-test-token", "issues":[{"message":"private-test-token","path":"secret/private-test-token.arcadia","line":4,"column":2}]}))) }))).await; let client = Connection::new(&server.base, Secret("private-test-token".into())).unwrap(); let failure = client.publish(authored()).await.unwrap_err(); assert_eq!(failure.code, "redirect_refused"); assert!(!failure.uncertain); assert_eq!(attempts.load(Ordering::SeqCst), 1); assert_eq!(redirected.load(Ordering::SeqCst), 0); let failure = client.catalog("", None).await.unwrap_err(); assert!(failure.detail.contains("[redacted]")); assert!(!format!("{failure:?}").contains("private-test-token")); server.finish().await; destination.finish().await; } #[tokio::test] async fn hosted_open_rejects_incoherent_envelopes_missing_etags_and_unstable_ids() { let id = Id::new_v4(); let stable = authored(); let mut mismatched = envelope(stable.clone(), id, 1); mismatched["model"]["name"] = json!("different model"); let mut wrong_id = envelope(stable.clone(), Id::new_v4(), 1); // Keep this independently useful even if the model's schema changes. wrong_id["revision"] = json!(1); let mut unstable = envelope(stable.clone(), id, 1); unstable["source_project"] = json!({"entry":"model.arcadia","files":{"model.arcadia":"arcadia 4; project unstable \"Unstable\" {}"}}); for (body, etag) in [ (mismatched, Some("\"opaque\"")), (wrong_id, Some("\"opaque\"")), (envelope(stable.clone(), id, 0), Some("\"opaque\"")), (envelope(stable, id, 1), None), (unstable, Some("\"opaque\"")), ] { let server = TestServer::fixture(Router::new().route( "/api/v1/projects/{id}", get(move || { let body = body.clone(); async move { let mut response = Json(body).into_response(); if let Some(etag) = etag { response .headers_mut() .insert(header::ETAG, etag.parse().unwrap()); } response } }), )) .await; let failure = server.connection().open(id).await.unwrap_err(); assert_eq!(failure.code, "invalid_response"); assert!(!failure.uncertain); server.finish().await; } } #[tokio::test] async fn hosted_put_preserves_an_opaque_etag_and_compiles_the_accepted_bundle() { let id = Id::new_v4(); let etag = "\"opaque:tag/+,=?\""; let bundle = authored(); let response_bundle = bundle.clone(); let server = TestServer::fixture(Router::new().route( "/api/v1/projects/{id}/source-project", put( move |headers: header::HeaderMap, Json(body): Json| { let expected_bundle = response_bundle.clone(); async move { assert_eq!(headers.get(header::IF_MATCH).unwrap(), etag); assert_eq!(body, expected_bundle); ( [(header::ETAG, "\"accepted:new-tag\"")], Json(json!({"revision":8,"changed":false,"source_project":body})), ) } }, ), )) .await; let client = server.connection(); let saved = client.save(id, etag, bundle.clone()).await.unwrap(); assert_eq!(saved.etag, "\"accepted:new-tag\""); assert_eq!(saved.resource_id, id); assert_eq!(saved.revision, 8); assert_eq!(saved.compiled.bundle, bundle.clone()); assert_eq!( client.save(id, "*", bundle).await.unwrap_err().code, "invalid_etag" ); server.finish().await; } #[tokio::test] async fn hosted_publication_retains_known_resource_after_failed_followup() { let id = Id::new_v4(); let attempts = Arc::new(AtomicUsize::new(0)); let calls = Arc::clone(&attempts); let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || { let calls = Arc::clone(&calls); async move { calls.fetch_add(1, Ordering::SeqCst); (StatusCode::CREATED, [(header::ETAG, "\"receipt\"")], Json(json!({"resource_id":id,"revision":1,"model_id":Id::new_v4(),"name":"Published","elements":0,"relations":0}))) } })).route("/api/v1/projects/{id}", get(|| async { StatusCode::SERVICE_UNAVAILABLE }))).await; let failure = server.connection().publish(authored()).await.unwrap_err(); assert_eq!(failure.published, Some(id)); assert!(failure.uncertain); assert_eq!(failure.status, Some(503)); assert_eq!(attempts.load(Ordering::SeqCst), 1); server.finish().await; } /// Reads the entire request before simulating a lost/malformed response. The /// counter proves the production client never repeats an admitted publication. async fn raw_fixture(reply: Vec) -> (String, Arc, JoinHandle<()>) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let attempts = Arc::new(AtomicUsize::new(0)); let calls = Arc::clone(&attempts); let task = tokio::spawn(async move { loop { let (mut stream, _) = listener.accept().await.unwrap(); let mut request = Vec::new(); let mut buffer = [0; 4096]; loop { let read = stream.read(&mut buffer).await.unwrap(); if read == 0 { break; } request.extend_from_slice(&buffer[..read]); if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") { let headers = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase(); let length = headers .lines() .find_map(|line| { line.strip_prefix("content-length:") .and_then(|value| value.trim().parse::().ok()) }) .unwrap_or(0); if request.len() >= end + 4 + length { break; } } } calls.fetch_add(1, Ordering::SeqCst); let _ = stream.write_all(&reply).await; let _ = stream.shutdown().await; } }); (base, attempts, task) } #[tokio::test] async fn hosted_lost_publication_response_is_uncertain_without_retry() { let (base, attempts, task) = raw_fixture(Vec::new()).await; let client = Connection::new(&base, Secret::default()).unwrap(); let failure = client.publish(authored()).await.unwrap_err(); assert!(failure.uncertain); assert_eq!(failure.published, None); assert_eq!(attempts.load(Ordering::SeqCst), 1); task.abort(); } #[tokio::test] async fn hosted_response_limits_cover_declared_and_chunked_bodies() { let declared = format!( "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", MAX_RESPONSE_BYTES + 1 ) .into_bytes(); let mut chunked = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".to_vec(); let chunk = vec![b' '; 64 * 1024]; for _ in 0..=(MAX_RESPONSE_BYTES / chunk.len()) { chunked.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes()); chunked.extend_from_slice(&chunk); chunked.extend_from_slice(b"\r\n"); } chunked.extend_from_slice(b"0\r\n\r\n"); for reply in [declared, chunked] { let (base, _, task) = raw_fixture(reply).await; let failure = Connection::new(&base, Secret::default()) .unwrap() .catalog("", None) .await .unwrap_err(); assert_eq!(failure.code, "response_too_large"); assert!(!failure.uncertain); task.abort(); } } #[tokio::test] async fn hosted_timeout_and_disconnect_leave_mutation_outcomes_explicit() { let received = Arc::new(AtomicUsize::new(0)); let calls = Arc::clone(&received); let (release, held) = watch::channel(false); let server = TestServer::fixture(Router::new().route( "/api/v1/projects/import-source-project", post(move || { let calls = Arc::clone(&calls); let mut held = held.clone(); async move { calls.fetch_add(1, Ordering::SeqCst); // Never race a fixed response delay against the client deadline. // A retained watch value also releases a handler admitted late. while !*held.borrow_and_update() { held.changed().await.unwrap(); } StatusCode::CREATED } }), )) .await; let client = Connection::with_timeout(&server.base, Secret::default(), Duration::from_millis(30)) .unwrap(); let failure = client.publish(authored()).await.unwrap_err(); assert_eq!(failure.code, "timeout"); assert!(failure.uncertain); release.send(true).unwrap(); let base = server.base.clone(); server.finish().await; // Under executor/CPU contention, the deadline can expire before admission. // Both zero and one admission have an uncertain mutation outcome; neither // permits a retry. The lost-response test above independently reads a full // admitted request and requires exactly one publication. assert!(received.load(Ordering::SeqCst) <= 1); let failure = Connection::with_timeout(&base, Secret::default(), Duration::from_secs(2)) .unwrap() .catalog("", None) .await .unwrap_err(); assert!(matches!( failure.code.as_str(), "connection_failed" | "timeout" )); assert!(!failure.uncertain); } #[tokio::test] async fn hosted_publication_race_keeps_receipt_identity_for_review() { let id = Id::new_v4(); let bundle = authored(); let published_model_id = source_project::compile_bundle(&bundle, SOURCE_LIMITS) .unwrap() .decoded .project .id; let mut changed = bundle.clone(); changed .files .get_mut("work/operations.arcadia") .unwrap() .push_str("// Concurrent external review\r\n"); let head = envelope(changed, id, 2); let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || async move { (StatusCode::CREATED, [(header::ETAG, "\"first-revision\"")], Json(json!({"resource_id":id,"revision":1,"model_id":published_model_id,"name":"Published","elements":2,"relations":0}))) })).route("/api/v1/projects/{id}", get(move || { let head = head.clone(); async move { ([(header::ETAG, "\"next-revision\"")], Json(head)) } }))).await; let failure = server.connection().publish(bundle).await.unwrap_err(); assert!(failure.uncertain); assert_eq!(failure.published, Some(id)); assert!(failure.detail.contains("changed before")); server.finish().await; } #[tokio::test] async fn hosted_structured_mutation_failures_distinguish_precommit_rejections() { for (status, code, uncertain) in [ (StatusCode::PAYLOAD_TOO_LARGE, "request_too_large", false), (StatusCode::INSUFFICIENT_STORAGE, "store_capacity", false), (StatusCode::SERVICE_UNAVAILABLE, "overloaded", false), (StatusCode::SERVICE_UNAVAILABLE, "storage_unavailable", true), (StatusCode::GATEWAY_TIMEOUT, "outcome_unknown", true), ] { let server = TestServer::fixture(Router::new().route( "/api/v1/projects/import-source-project", post(move || async move { ( status, Json(json!({"code":code,"detail":"Service-specific actionable detail"})), ) }), )) .await; let failure = server.connection().publish(authored()).await.unwrap_err(); assert_eq!(failure.status, Some(status.as_u16())); assert_eq!(failure.code, code); assert_eq!(failure.uncertain, uncertain, "{code}"); assert_eq!(failure.detail, "Service-specific actionable detail"); server.finish().await; } } #[tokio::test] async fn hosted_oversized_local_bundle_is_rejected_before_network_admission() { let mut bundle = authored(); bundle .files .get_mut(&bundle.entry) .unwrap() .push_str(&" ".repeat(8 * 1024 * 1024)); let failure = Connection::new("http://127.0.0.1:1", Secret::default()) .unwrap() .publish(bundle) .await .unwrap_err(); assert_eq!(failure.code, "request_too_large"); assert!(!failure.uncertain); assert!(failure.published.is_none()); }