diff --git a/e2e/tests/viewer.spec.ts b/e2e/tests/viewer.spec.ts index 4ac2002..a55b11e 100644 --- a/e2e/tests/viewer.spec.ts +++ b/e2e/tests/viewer.spec.ts @@ -130,6 +130,29 @@ test('logged-out Thing detail acquires the complete model bundle in one public X 'at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.part/part-01', ); expect(resourceRequests).toHaveLength(0); + + const downloadResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === '/xrpc/space.polymodel.library.getPartFile' + && response.request().method() === 'GET'; + }); + await expect(page.getByRole('button', { name: 'Download files' })).toBeVisible(); + await page.getByRole('button', { name: 'Download files' }).click(); + const downloadResponse = await downloadResponsePromise; + expect(downloadResponse.status()).toBe(200); + expect(downloadResponse.headers()['content-type']).toBe('application/x-ldraw'); + expect(downloadResponse.headers()['content-disposition']).toBe( + 'attachment; filename="Main_enclosure_body"', + ); + expect(new URL(downloadResponse.url()).searchParams.get('uri')).toBe( + 'at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.part/part-01', + ); + expect(await downloadResponse.body()).toEqual(Buffer.from( + '0 BFC CERTIFY CCW\n' + + '1 16 -35 0 0 1 0 0 0 1 0 0 0 1 models/child.dat\n' + + '1 16 35 0 0 0 0 -1 0 1 0 1 0 0 models/child.dat\n' + + '1 16 0 0 35 1 0 0 0 1 0 0 0 1 models/companion.dat\n', + )); }); test('model switching keeps one WebGL context and updates renderer session metadata', async ({ page }) => { diff --git a/src/appview/mod.rs b/src/appview/mod.rs index 800c422..8c3895b 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -31,6 +31,8 @@ mod writes; #[cfg(test)] mod tests; +#[cfg(test)] +pub(crate) use tests::test_support; use axum::Router; use jacquard_axum::IntoRouter; diff --git a/src/appview/test_support.rs b/src/appview/test_support.rs index 4587c50..94d70e4 100644 --- a/src/appview/test_support.rs +++ b/src/appview/test_support.rs @@ -16,10 +16,10 @@ 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; use jacquard::oauth::scopes::Scopes; use jacquard::oauth::session::{ClientSessionData, DpopClientData}; use jacquard::oauth::types::{OAuthTokenType, TokenSet}; +use jacquard::oauth::utils::generate_key; use jacquard_common::deps::fluent_uri::Uri; use jacquard_common::deps::smol_str::SmolStr; pub(crate) use jacquard_common::session::SessionKey; @@ -47,14 +47,7 @@ pub(crate) fn did(s: &str) -> Did { } pub(crate) fn client_session(account_did: &str, session_id: &str) -> ClientSessionData { - let dpop_key = Keyset::generate_es256("test") - .unwrap() - .public_jwks() - .keys - .into_iter() - .next() - .unwrap() - .key; + let dpop_key = generate_key(&["ES256"]).unwrap(); ClientSessionData { account_did: did(account_did), session_id: SmolStr::new(session_id), @@ -87,7 +80,20 @@ pub(crate) async fn seed_oauth_session( account_did: &str, session_id: &str, ) -> SessionKey { - let data = client_session(account_did, session_id); + seed_oauth_session_at(state, account_did, session_id, None).await +} + +pub(crate) async fn seed_oauth_session_at( + state: &AppState, + account_did: &str, + session_id: &str, + host_url: Option<&str>, +) -> SessionKey { + let mut data = client_session(account_did, session_id); + if let Some(host_url) = host_url { + data.host_url = Uri::parse(host_url).unwrap().to_owned(); + data.token_set.aud = SmolStr::new(host_url); + } let key = SessionKey::new(data.account_did.clone(), data.session_id.clone()); state .oauth @@ -184,6 +190,101 @@ pub(crate) async fn loopback_pds( (endpoint, task) } +pub(crate) async fn loopback_pds_with_upload( + expected_mime: &'static str, +) -> (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.repo.getRecord", + any(|| async { + ( + StatusCode::BAD_REQUEST, + axum::Json(json!({"error": "RecordNotFound", "message": "record not found"})), + ) + }), + ) + .route( + "/xrpc/com.atproto.repo.uploadBlob", + any( + move |headers: axum::http::HeaderMap, body: Body| async move { + let mime = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if mime != expected_mime { + return (StatusCode::UNSUPPORTED_MEDIA_TYPE, Body::from("wrong mime")) + .into_response(); + } + let bytes = match axum::body::to_bytes(body, 1 << 20).await { + Ok(bytes) => bytes, + Err(_) => { + return (StatusCode::BAD_REQUEST, Body::from("body read failed")) + .into_response(); + } + }; + let (cid, _) = test_blob(&bytes); + ( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "application/json")], + axum::Json(json!({ + "blob": { + "$type": "blob", + "ref": { "$link": cid }, + "mimeType": mime, + "size": bytes.len() + } + })), + ) + .into_response() + }, + ), + ) + .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(); + ( + StatusCode::NOT_FOUND, + Body::from(format!("unknown blob {cid}")), + ) + .into_response() + }, + ), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (endpoint, task) +} + pub(crate) async fn loopback_pds_with_blobs( blobs: std::collections::HashMap>, ) -> (String, tokio::task::JoinHandle<()>) { diff --git a/src/appview/tests.rs b/src/appview/tests.rs index 83e8798..2134cd6 100644 --- a/src/appview/tests.rs +++ b/src/appview/tests.rs @@ -17,4 +17,4 @@ mod read_tests; mod session_tests; #[cfg(test)] #[path = "test_support.rs"] -mod test_support; +pub(crate) mod test_support; diff --git a/src/appview/writes.rs b/src/appview/writes.rs index 8242d65..ce9e98d 100644 --- a/src/appview/writes.rs +++ b/src/appview/writes.rs @@ -1661,7 +1661,9 @@ fn hex_bytes(bytes: &[u8]) -> String { fn agent_error(operation: &str, err: jacquard::client::AgentError) -> AppError { if agent_error_is_auth(&err) { tracing::warn!(operation, error = %err, "authenticated PDS operation failed authorization"); - return unauthorized("authenticated PDS operation was not authorized"); + return unauthorized(format!( + "{operation}: authenticated PDS operation was not authorized" + )); } tracing::error!(operation, error = %err, "authenticated PDS operation failed"); internal("authenticated PDS operation failed") @@ -1749,10 +1751,15 @@ fn agent_error_is_conflict(err: &jacquard::client::AgentError) -> bool { mod tests { use std::str::FromStr; + use axum::response::IntoResponse; + use axum_extra::extract::PrivateCookieJar; use jacquard_common::types::string::Did; use serde_json::json; use sqlx::SqlitePool; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use tower::ServiceExt; + + use crate::appview::test_support::{seed_identity, seed_oauth_session_at, seed_profile}; use super::*; @@ -1849,6 +1856,94 @@ mod tests { uri } + #[tokio::test] + async fn stage_file_route_accepts_authenticated_ldraw_upload_and_persists_owner_metadata() { + let mut state = state().await; + let (pds, server) = + crate::appview::test_support::loopback_pds_with_upload("application/x-ldraw").await; + state.resolver = crate::appview::test_support::loopback_resolver(&pds); + seed_identity(&state.pool, DID_A, "alice.com").await; + seed_profile(&state.pool, DID_A, "Alice").await; + let key = seed_oauth_session_at(&state, DID_A, "stage-ldraw-session", Some(&pds)).await; + let cookie_response = jacquard_axum::oauth::set_session_cookie( + PrivateCookieJar::new(state.cookie_key.clone()), + &state.oauth_config, + &key, + ) + .unwrap() + .into_response(); + let cookie = cookie_response + .headers() + .get(axum::http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let app = crate::appview::router().with_state(state.clone()); + let bytes = b"0 FILE model.dat\n1 16 0 0 0 part.dat\n"; + let digest = sha256_bytes(bytes); + let response = app + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/xrpc/space.polymodel.library.stageFile") + .header(axum::http::header::COOKIE, cookie) + .header("content-type", "application/x-ldraw") + .header("x-polymodel-filename", "model.dat") + .body(axum::body::Body::from(bytes.as_slice())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .unwrap(); + let output: StageFileOutput = serde_json::from_slice(&body).unwrap(); + assert_eq!(output.status, Some(StageFileOutputStatus::Uploaded)); + assert_eq!(output.filename.as_deref(), Some("model.dat")); + assert_eq!(output.file.mime_type.as_str(), "application/x-ldraw"); + assert_eq!(output.file.size, bytes.len() as i64); + assert_eq!(output.file.digest.as_deref(), Some(digest.as_slice())); + assert_eq!(output.file.chunks.len(), 1); + assert_eq!(output.file.chunks[0].size, bytes.len() as i64); + let upload_id = output.upload_id.to_string(); + let row = sqlx::query_as::<_, (String, String, i64, Vec, String)>( + "SELECT owner_did, filename, size, sha256, file_json FROM upload_staging WHERE owner_did = ? AND upload_id = ?", + ) + .bind(DID_A) + .bind(&upload_id) + .fetch_one(&state.pool) + .await + .unwrap(); + assert_eq!(row.0, DID_A); + assert_eq!(row.1, "model.dat"); + assert_eq!(row.2, bytes.len() as i64); + assert_eq!(row.3, digest); + let stored: File = serde_json::from_str(&row.4).unwrap(); + assert_eq!(stored.mime_type.as_str(), "application/x-ldraw"); + assert_eq!( + stored.chunks[0].blob.blob().r#ref.as_str(), + output.file.chunks[0].blob.blob().r#ref.as_str() + ); + assert_eq!( + load_upload_file(&state, &did(DID_A), &upload_id) + .await + .unwrap() + .size, + bytes.len() as i64 + ); + assert!( + load_upload_file(&state, &did(DID_B), &upload_id) + .await + .is_err() + ); + server.abort(); + } + #[tokio::test] async fn upload_staging_round_trips_owner_scoped_file_metadata() { let state = state().await; diff --git a/src/publish.rs b/src/publish.rs index fa1b79f..b479c75 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -25,7 +25,7 @@ use draft::{ use jacquard_common::deps::smol_str::SmolStr; use polymodel_api::com_atproto::repo::strong_ref::StrongRef; use polymodel_api::space_polymodel::library::Image; -use polymodel_renderer_protocol::format_registry::{self, FormatId}; +use polymodel_renderer_protocol::format_registry; use state::PublishPhase; pub(crate) mod draft; @@ -2884,10 +2884,19 @@ mod tests { #[test] fn geometry_picker_accept_is_shared_and_explicit() { for expected in [ - ".ldr", ".mpd", ".dat", "application/x-ldraw", "model/ldraw", - "model/gltf+json", "model/gltf-binary", + ".ldr", + ".mpd", + ".dat", + "application/x-ldraw", + "model/ldraw", + "model/gltf+json", + "model/gltf-binary", ] { - assert!(GEOMETRY_PICKER_ACCEPT.split(',').any(|value| value == expected)); + assert!( + GEOMETRY_PICKER_ACCEPT + .split(',') + .any(|value| value == expected) + ); } } diff --git a/src/thing_detail.rs b/src/thing_detail.rs index 9699e40..6a89547 100644 --- a/src/thing_detail.rs +++ b/src/thing_detail.rs @@ -3,7 +3,7 @@ use jacquard::common::types::ident::AtIdentifier; use jacquard::common::types::recordkey::Rkey; use jacquard::common::types::string::{AtUri, Cid}; use jacquard::common::types::value::from_data; -use jacquard_common::deps::smol_str::{SmolStr, StrExt}; +use jacquard_common::deps::smol_str::StrExt; use jacquard_common::xrpc::XrpcClient; use polymodel_api::com_atproto::repo::strong_ref::StrongRef; use polymodel_api::space_polymodel::graph::{ @@ -11,10 +11,7 @@ use polymodel_api::space_polymodel::graph::{ delete_save::DeleteSave, }; use polymodel_api::space_polymodel::library; -use polymodel_renderer_protocol::{ - MeshFormat, - format_registry::{self, FormatId}, -}; +use polymodel_renderer_protocol::format_registry::{self, FormatId}; use crate::Route; use crate::author_byline::AuthorByline; @@ -936,6 +933,7 @@ fn dimensions_label(dimensions: &library::Bbox) -> String { mod tests { use super::*; use jacquard_common::types::datetime::Datetime; + use polymodel_renderer_protocol::MeshFormat; fn actor() -> library::Actor { library::Actor { diff --git a/src/viewer.rs b/src/viewer.rs index 0c1cf24..177695b 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -2531,7 +2531,13 @@ mod tests { output: LoadOutput::Interactive, }; assert!(required_capabilities(&command).contains(&Capability::CompoundLdraw)); - assert!(!handshake.negotiated().unwrap().capabilities.contains(&Capability::CompoundLdraw)); + assert!( + !handshake + .negotiated() + .unwrap() + .capabilities + .contains(&Capability::CompoundLdraw) + ); assert!(!command_is_ready_for_worker(true, Some(7), true, &command)); }