Files
ARCADIA-Studio/crates/oa-desktop/src/hosted/client_tests.rs
T

621 lines
22 KiB
Rust

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<oneshot::Sender<()>>,
task: JoinHandle<()>,
_directory: Option<tempfile::TempDir>,
}
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<String>, 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<SourceBundle>| {
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<u8>) -> (String, Arc<AtomicUsize>, 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::<usize>().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());
}