From 271b2190cbf619003ea6c2444cbc49457fd9cf87 Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 3 Aug 2026 14:11:47 -0400 Subject: [PATCH] PM-80: strengthen download and route regression tests --- src/appview/download_tests.rs | 85 ++++++++++++++++++++++++++++++++-- src/appview/test_support.rs | 86 ++++++++++++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src/appview/download_tests.rs b/src/appview/download_tests.rs index e86c07c..46ca90d 100644 --- a/src/appview/download_tests.rs +++ b/src/appview/download_tests.rs @@ -96,17 +96,94 @@ async fn get_part_file_missing_part_is_not_found() { } #[tokio::test] -async fn app_parts_download_route_is_gone() { - let state = state().await; +async fn get_part_file_later_blob_failure_truncates_committed_stream() { + let failed_cid = "bafkreiape5vm4aofohlvq72uc7nw6golsxa44ser6photbjne6gkhboeii"; + let (pds, server) = loopback_pds(false, Some(failed_cid.to_string())).await; + let pool = pool().await; + let part_uri = format!("at://{DID_A}/space.polymodel.library.part/download"); + let file = json!({ + "mimeType": "model/stl", + "size": 4, + "chunks": [ + {"blob": {"$type": "blob", "ref": {"$link": failed_cid}, "mimeType": "model/stl", "size": 2}, "offset": 2, "size": 2}, + {"blob": {"$type": "blob", "ref": {"$link": "bafkreif2nycdsf3vvit7hhpxegmlmoknx6m7yuvdt4tkzpk7hv4jqo3ulq"}, "mimeType": "model/stl", "size": 2}, "offset": 0, "size": 2} + ] + }); + sqlx::query("INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + .bind(DID_A).bind("download").bind(&part_uri).bind("part-cid").bind("Bracket.stl") + .bind("stl").bind(file.to_string()).bind(1_i64).bind(1_i64).bind(file!()) + .execute(&pool).await.unwrap(); + let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) + .expect("ephemeral OAuth bootstrap for tests"); + let state = AppState::new_with_resolver(pool, bootstrap, loopback_resolver(&pds)); let app = crate::appview::router().with_state(state); let resp = app .oneshot( Request::builder() - .uri("/app/parts/did:plc:x/abc/download") + .uri(format!( + "/xrpc/space.polymodel.library.getPartFile?uri={}", + urlencoding::encode(&part_uri) + )) .body(Body::empty()) .unwrap(), ) .await .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.headers()["content-length"], "4"); + let mut body = resp.into_body(); + let mut bytes = Vec::new(); + let mut stream_error = false; + while let Some(frame) = body.frame().await { + match frame { + Ok(frame) => { + if let Ok(data) = frame.into_data() { + bytes.extend_from_slice(&data); + } + } + Err(_) => { + stream_error = true; + break; + } + } + } + assert!(stream_error, "later PDS failure must surface as a body error"); + assert_eq!(&bytes[..], b"ab"); + server.abort(); +} + +#[tokio::test] +async fn removed_ldraw_routes_and_public_download_surface_are_rejected() { + let state = state().await; + let pool = state.pool.clone(); + let app = crate::appview::router().with_state(state); + for (method, path, body) in [ + (Method::GET, "/app/parts/did:plc:x/abc/download", Body::empty()), + (Method::GET, "/app/ldraw/did:plc:x/abc", Body::empty()), + (Method::GET, "/xrpc/space.polymodel.library.ldrawResource?uri=at%3A%2F%2Fdid%3Aplc%3Ax%2Fresource%2Fabc", Body::empty()), + (Method::GET, "/xrpc/space.polymodel.library.getLdrawResource?uri=at%3A%2F%2Fdid%3Aplc%3Ax%2Fresource%2Fabc", Body::empty()), + (Method::POST, "/xrpc/space.polymodel.library.mintLdrawResource", Body::from("{}")), + ] { + let resp = app.clone().oneshot( + Request::builder().method(method.clone()).uri(path).body(body).unwrap(), + ).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND, "removed route must stay absent: {method} {path}"); + } + for table in [ + "ldraw_resources", + "ldraw_manifest_files", + "ldraw_verification", + "ldraw_routes", + "ldraw_official_snapshots", + "ldraw_legacy_resources", + ] { + let exists: Option = sqlx::query_scalar( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + ) + .bind(table) + .fetch_optional(&pool) + .await + .unwrap(); + assert!(exists.is_none(), "cleanup migration must remove {table}"); + } } diff --git a/src/appview/test_support.rs b/src/appview/test_support.rs index 1ea7dcb..30df566 100644 --- a/src/appview/test_support.rs +++ b/src/appview/test_support.rs @@ -9,8 +9,10 @@ #![cfg(test)] pub(crate) use axum::body::Body; -pub(crate) use axum::http::{Request, StatusCode}; +pub(crate) use axum::http::{Method, Request, StatusCode}; pub(crate) use dioxus::prelude::dioxus_fullstack::FullstackContext; +use jacquard::identity::PublicResolver; +use jacquard::identity::resolver::{DidStep, PlcSource, ResolverOptions}; use jacquard::oauth::authstore::ClientAuthStore; pub(crate) use jacquard::oauth::client::OAuthSession; use jacquard::oauth::keyset::Keyset; @@ -114,6 +116,88 @@ pub(crate) async fn state() -> AppState { AppState::new(pool, bootstrap) } +pub(crate) async fn loopback_pds( + fail_blob: bool, + fail_cid: Option, +) -> (String, tokio::task::JoinHandle<()>) { + use axum::extract::Query; + use axum::response::IntoResponse; + use axum::routing::any; + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let did_doc_endpoint = endpoint.clone(); + let app = axum::Router::new() + .route( + "/xrpc/com.atproto.identity.resolveDid", + any(move || { + let service_endpoint = did_doc_endpoint.clone(); + async move { + axum::Json(json!({ + "didDoc": { + "@context": ["https://www.w3.org/ns/did/v1"], + "id": DID_A, + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": service_endpoint + }] + } + })) + } + }), + ) + .route( + "/xrpc/com.atproto.sync.getBlob", + any( + move |Query(params): Query>| async move { + let cid = params.get("cid").map(String::as_str).unwrap_or_default(); + if fail_blob + || fail_cid.as_deref() == Some(cid) + || cid == "bafkreih7uy2yhx5gobvypuuexbvq22j2cypeqqfk2lc46225e7b3syq7pu" + { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Body::from("blob unavailable"), + ) + .into_response(); + } + match cid { + "bafkreif2nycdsf3vvit7hhpxegmlmoknx6m7yuvdt4tkzpk7hv4jqo3ulq" => { + (StatusCode::OK, Body::from("ab")).into_response() + } + "bafkreiape5vm4aofohlvq72uc7nw6golsxa44ser6photbjne6gkhboeii" => { + (StatusCode::OK, Body::from("cd")).into_response() + } + _ => (StatusCode::NOT_FOUND, Body::from("unknown blob")).into_response(), + } + }, + ), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (endpoint, task) +} + +pub(crate) fn loopback_resolver(endpoint: &str) -> PublicResolver { + let mut options = ResolverOptions::default(); + options.plc_source = PlcSource::PlcDirectory { + base: Uri::parse(format!("{endpoint}/")).unwrap().to_owned(), + }; + options.pds_fallback = Some(Uri::parse(endpoint.to_owned()).unwrap().to_owned()); + options.did_order = vec![DidStep::PdsResolveDid]; + PublicResolver::new(reqwest::Client::new(), options) +} + +pub(crate) async fn body_text(resp: axum::response::Response) -> String { + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) + .await + .unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() +} + // Seeding uses runtime queries so the committed .sqlx cache stays focused on production SQL. pub(crate) fn blob(cid: &str) -> serde_json::Value { -- 2.54.0