Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141//! PM-43 server-side publish drafts + app-internal image upload.//!//! Drafts are app-internal (not ATProto records, not federated): owner-scoped//! rows in the `drafts` table holding a [`DraftThingInput`] JSON payload. They//! never reach the actor's PDS until publish, and they live in their own table//! so feeds (which read only `things`) never surface them.//!//! Every handler authenticates with the strict OAuth session (same extractor as//! the PM-28 writes) and scopes every query by the authenticated DID, so no//! actor can read, write, publish, or delete another actor's drafts.//!//! Routes (same-origin, cookie-authenticated; app-internal, not `/xrpc`)://! - `POST /app/drafts` create (server assigns an opaque id)//! - `PUT /app/drafts/{draft_id}` upsert an existing draft//! - `GET /app/drafts` list draft summaries//! - `GET /app/drafts/{draft_id}` fetch one draft payload//! - `DELETE /app/drafts/{draft_id}` delete a draft//! - `POST /app/drafts/{draft_id}/publish` assemble + publish + delete-on-success//! - `POST /app/images` upload an image blob, return an `#image`
use axum::Json;use axum::body::Body;use axum::extract::{Path, State};use axum::http::{HeaderMap, HeaderValue, Response};use jacquard::client::Agent;use jacquard_axum::oauth::ExtractOAuthSession;use jacquard_common::types::blob::BlobRef;use jacquard_common::types::string::Did;use polymodel_api::space_polymodel::library::publish_thing::{PublishThingOutput, ThingInput};use polymodel_api::space_polymodel::library::{AspectRatio, Image};
use super::content_type::content_type;use super::error::{AppResult, db, internal, invalid_request, not_found};use super::state::AppState;use super::writes::{ ExtractSession, agent_upload_blob, authenticated_did, ensure_polymodel_profile, publish_composition,};use crate::publish::draft::{ DeleteDraftResponse, DraftIdResponse, DraftSummary, DraftThingInput, ImageUploadResponse,};
/// Image blobs are buffered fully (cover/preview art is small); cap to keep the/// app from buffering an unbounded body.const MAX_IMAGE_SIZE: usize = 20 * 1024 * 1024;
// ----------------------------------------------------------------------------// Owner-scoped DB helpers (pure; unit-tested directly)// ----------------------------------------------------------------------------
fn now_nanos() -> i64 { chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()}
/// Insert or update a draft row, refreshing the denormalized `name`/`model_count`/// and `updated_at`. `created_at` is preserved across updates.pub(super) async fn upsert_draft( state: &AppState, owner: &Did, draft_id: &str, draft: &DraftThingInput,) -> AppResult<()> { let payload_json = serde_json::to_string(draft).map_err(|e| internal(format!("draft serialize: {e}")))?; let name = draft.display_name().map(ToOwned::to_owned); let model_count = draft.model_count(); let now = now_nanos(); let owner_did = owner.as_ref(); db(sqlx::query!( "INSERT INTO drafts (owner_did, draft_id, payload_json, name, model_count, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT(owner_did, draft_id) DO UPDATE SET \ payload_json = excluded.payload_json, \ name = excluded.name, \ model_count = excluded.model_count, \ updated_at = excluded.updated_at", owner_did, draft_id, payload_json, name, model_count, now, now, ) .execute(&state.pool) .await)?; Ok(())}
/// Load a single owner-scoped draft payload, if present.pub(super) async fn get_draft( state: &AppState, owner: &Did, draft_id: &str,) -> AppResult<Option<DraftThingInput>> { let owner_did = owner.as_ref(); let row = db(sqlx::query!( "SELECT payload_json FROM drafts WHERE owner_did = ? AND draft_id = ?", owner_did, draft_id, ) .fetch_optional(&state.pool) .await)?; match row { Some(row) => { let draft: DraftThingInput = serde_json::from_str(&row.payload_json) .map_err(|e| internal(format!("draft deserialize: {e}")))?; Ok(Some(draft)) } None => Ok(None), }}
/// List owner-scoped draft summaries, most-recently-updated first.pub(super) async fn list_drafts(state: &AppState, owner: &Did) -> AppResult<Vec<DraftSummary>> { let owner_did = owner.as_ref(); let rows = db(sqlx::query!( "SELECT draft_id, name, model_count, created_at, updated_at \ FROM drafts WHERE owner_did = ? ORDER BY updated_at DESC", owner_did, ) .fetch_all(&state.pool) .await)?; Ok(rows .into_iter() .map(|row| DraftSummary { draft_id: row.draft_id, name: row.name, model_count: row.model_count, created_at: row.created_at, updated_at: row.updated_at, }) .collect())}
/// Delete an owner-scoped draft; returns whether a row existed.pub(super) async fn delete_draft(state: &AppState, owner: &Did, draft_id: &str) -> AppResult<bool> { let owner_did = owner.as_ref(); let result = db(sqlx::query!( "DELETE FROM drafts WHERE owner_did = ? AND draft_id = ?", owner_did, draft_id, ) .execute(&state.pool) .await)?; Ok(result.rows_affected() > 0)}
/// Promote a draft to a published thing: load → assemble → publish → delete.////// The publish step is injected so the ordering invariant (the draft row is/// deleted **only after** a successful publish) is unit-testable without a live/// PDS agent. If `publish` returns `Err`, the draft is left untouched.pub(super) async fn promote_draft<F, Fut>( state: &AppState, owner: &Did, draft_id: &str, publish: F,) -> AppResult<PublishThingOutput>where F: FnOnce(ThingInput) -> Fut, Fut: std::future::Future<Output = AppResult<PublishThingOutput>>,{ let draft = get_draft(state, owner, draft_id) .await? .ok_or_else(not_found)?; let thing = draft .assemble() .map_err(|errors| invalid_request(errors.to_string()))?; let output = publish(thing).await?; // Reached only on a successful publish; a failure short-circuits above and // leaves the draft recoverable. delete_draft(state, owner, draft_id).await?; Ok(output)}
// ----------------------------------------------------------------------------// Handlers// ----------------------------------------------------------------------------
pub(super) async fn create_draft( State(state): State<AppState>, ExtractOAuthSession(session): ExtractSession, Json(draft): Json<DraftThingInput>,) -> AppResult<Json<DraftIdResponse>> { let agent = Agent::from(session); let actor = authenticated_did(&agent).await?; let draft_id = ulid::Ulid::new().to_string(); upsert_draft(&state, &actor, &draft_id, &draft).await?; Ok(Json(DraftIdResponse { draft_id }))}
pub(super) async fn put_draft( State(state): State<AppState>, ExtractOAuthSession(session): ExtractSession, Path(draft_id): Path<String>, Json(draft): Json<DraftThingInput>,) -> AppResult<Json<DraftIdResponse>> { let agent = Agent::from(session); let actor = authenticated_did(&agent).await?; upsert_draft(&state, &actor, &draft_id, &draft).await?; Ok(Json(DraftIdResponse { draft_id }))}
pub(super) async fn get_draft_handler( State(state): State<AppState>, ExtractOAuthSession(session): ExtractSession, Path(draft_id): Path<String>,) -> AppResult<Json<DraftThingInput>> { let agent = Agent::from(session); let actor = authenticated_did(&agent).await?; let draft = get_draft(&state, &actor, &draft_id) .await? .ok_or_else(not_found)?; Ok(Json(draft))}
pub(super) async fn list_drafts_handler( State(state): State<AppState>, ExtractOAuthSession(session): ExtractSession,) -> AppResult<Json<Vec<DraftSummary>>> { let agent = Agent::from(session); let actor = authenticated_did(&agent).await?; let drafts = list_drafts(&state, &actor).await?; Ok(Json(drafts))}
pub(super) async fn delete_draft_handler( State(state): State<AppState>, ExtractOAuthSession(session): ExtractSession, Path(draft_id): Path<String>,) -> AppResult<Json<DeleteDraftResponse>> { let agent = Agent::from(session); let actor = authenticated_did(&agent).await?; let deleted = delete_draft(&state, &actor, &draft_id).await?; Ok(Json(DeleteDraftResponse { deleted }))}
pub(super) async fn publish_draft( State(state): State<AppState>, ExtractOAuthSession(session): ExtractSession, Path(draft_id): Path<String>,) -> AppResult<Json<PublishThingOutput>> { let agent = Agent::from(session); let actor = authenticated_did(&agent).await?; let _ = ensure_polymodel_profile(&state, &agent, &actor, false).await?; let _write_guard = state.write_lock.lock().await; let output = promote_draft(&state, &actor, &draft_id, |thing| { publish_composition(&state, &agent, &actor, thing) }) .await?; Ok(Json(output))}
pub(super) async fn get_staged_resource( State(state): State<AppState>, ExtractOAuthSession(session): ExtractSession, Path(upload_id): Path<String>,) -> AppResult<Response<Body>> { let agent = Agent::from(session); let actor = authenticated_did(&agent).await?; let file = super::writes::load_upload_file(&state, &actor, &upload_id).await?; let size = u64::try_from(file.size) .map_err(|_| invalid_request("staged resource has negative length"))?; let digest = file .digest .as_deref() .ok_or_else(|| invalid_request("staged resource has no digest"))?; if size == 0 || size > crate::publish::draft::MAX_PREVIEW_RESOURCE_BYTES || digest.len() != 32 { return Err(invalid_request("staged resource exceeds preview bounds")); } super::downloads::validate_file_manifest(&file)?; let cids = super::downloads::ordered_chunks(&file) .iter() .map(|chunk| chunk.blob.blob().r#ref.as_str().to_owned()) .collect::<Vec<_>>(); let bytes = super::ldraw::fetch_staged_bytes( &state, &actor, &cids, size, digest, crate::publish::draft::MAX_PREVIEW_RESOURCE_BYTES, ) .await?; let mut response = Response::new(Body::from(bytes)); response.headers_mut().insert( axum::http::header::CONTENT_TYPE, HeaderValue::from_str(file.mime_type.as_str()) .map_err(|_| invalid_request("invalid staged resource MIME"))?, ); Ok(response)}
pub(super) async fn upload_image( State(state): State<AppState>, ExtractOAuthSession(session): ExtractSession, headers: HeaderMap, body: Body,) -> AppResult<Json<ImageUploadResponse>> { let agent = Agent::from(session); let actor = authenticated_did(&agent).await?; let _ = ensure_polymodel_profile(&state, &agent, &actor, false).await?;
let mime_type = content_type(&headers); let alt = headers .get("x-polymodel-alt") .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_string();
let bytes = axum::body::to_bytes(body, MAX_IMAGE_SIZE) .await .map_err(|_| invalid_request("image body exceeds the size limit or could not be read"))?; if bytes.is_empty() { return Err(invalid_request( "image upload requires a non-empty raw byte body", )); }
// `aspectRatio` is required by `#image` but is not returned by uploadBlob, so // decode width/height from the bytes (header-only probe, no full decode). let dims = imagesize::blob_size(&bytes) .map_err(|e| invalid_request(format!("unsupported or corrupt image: {e}")))?;
let blob = agent_upload_blob(&agent, bytes.to_vec(), &mime_type).await?; let image = Image { alt: alt.into(), aspect_ratio: AspectRatio { height: dims.height as i64, width: dims.width as i64, extra_data: None, }, image: BlobRef::from(blob), extra_data: None, }; Ok(Json(ImageUploadResponse { image }))}
#[cfg(test)]mod tests { use std::str::FromStr;
use axum::response::IntoResponse; use axum_extra::extract::PrivateCookieJar; use base64::Engine as _; use jacquard_common::types::string::Did; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use tower::ServiceExt;
use super::*; use crate::publish::draft::{DraftModelInput, DraftPartInput};
const DID_A: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; const DID_B: &str = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb";
async fn state() -> AppState { let options = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); let pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) .await .unwrap(); sqlx::migrate!("./migrations").run(&pool).await.unwrap(); let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) .expect("ephemeral OAuth bootstrap for tests"); AppState::new(pool, bootstrap) }
fn did(s: &str) -> Did { Did::new_owned(s).unwrap() }
fn draft_named(name: &str) -> DraftThingInput { DraftThingInput { name: Some(name.to_string()), license: Some("CC-BY-4.0".to_string()), models: vec![DraftModelInput { name: Some("Model".to_string()), parts: vec![DraftPartInput { name: Some("part.stl".to_string()), upload_id: Some("abcdef0123456789-42".to_string()), ..Default::default() }], ..Default::default() }], ..Default::default() } }
#[tokio::test] async fn upsert_and_get_round_trips_owner_scoped() { let state = state().await; let owner = did(DID_A); let draft = draft_named("Widget"); upsert_draft(&state, &owner, "d1", &draft).await.unwrap();
let loaded = get_draft(&state, &owner, "d1").await.unwrap(); assert_eq!(loaded, Some(draft));
// A different owner cannot see it. let other = get_draft(&state, &did(DID_B), "d1").await.unwrap(); assert_eq!(other, None); }
#[tokio::test] async fn upsert_updates_denormalized_summary_and_preserves_created_at() { let state = state().await; let owner = did(DID_A); upsert_draft(&state, &owner, "d1", &draft_named("First")) .await .unwrap(); let created_at: i64 = sqlx::query_scalar("SELECT created_at FROM drafts WHERE draft_id = 'd1'") .fetch_one(&state.pool) .await .unwrap();
upsert_draft(&state, &owner, "d1", &draft_named("Second")) .await .unwrap(); let summaries = list_drafts(&state, &owner).await.unwrap(); assert_eq!(summaries.len(), 1); assert_eq!(summaries[0].name.as_deref(), Some("Second")); assert_eq!(summaries[0].model_count, 1);
let created_after: i64 = sqlx::query_scalar("SELECT created_at FROM drafts WHERE draft_id = 'd1'") .fetch_one(&state.pool) .await .unwrap(); assert_eq!( created_at, created_after, "created_at must be preserved on update" ); }
#[tokio::test] async fn list_is_owner_scoped_and_ordered() { let state = state().await; let a = did(DID_A); upsert_draft(&state, &a, "d1", &draft_named("One")) .await .unwrap(); upsert_draft(&state, &a, "d2", &draft_named("Two")) .await .unwrap(); upsert_draft(&state, &did(DID_B), "d3", &draft_named("Other")) .await .unwrap();
let summaries = list_drafts(&state, &a).await.unwrap(); assert_eq!(summaries.len(), 2); // Most recently updated first. assert_eq!(summaries[0].draft_id, "d2"); assert_eq!(summaries[1].draft_id, "d1"); }
#[tokio::test] async fn delete_is_owner_scoped() { let state = state().await; let a = did(DID_A); upsert_draft(&state, &a, "d1", &draft_named("One")) .await .unwrap();
// Another owner cannot delete it. assert!(!delete_draft(&state, &did(DID_B), "d1").await.unwrap()); assert!(get_draft(&state, &a, "d1").await.unwrap().is_some());
// The owner can. assert!(delete_draft(&state, &a, "d1").await.unwrap()); assert!(get_draft(&state, &a, "d1").await.unwrap().is_none()); // Deleting again reports no row. assert!(!delete_draft(&state, &a, "d1").await.unwrap()); }
#[tokio::test] async fn authenticated_draft_lifecycle_round_trips_and_rejects_cross_owner_access() { let mut state = crate::appview::test_support::state().await; crate::appview::test_support::seed_identity(&state.pool, DID_A, "alice.com").await; crate::appview::test_support::seed_identity(&state.pool, DID_B, "bob.com").await; crate::appview::test_support::seed_profile(&state.pool, DID_A, "Alice").await; crate::appview::test_support::seed_profile(&state.pool, DID_B, "Bob").await; let (pds, create_records, server) = crate::appview::test_support::loopback_pds_for_publish(false).await; state.resolver = crate::appview::test_support::loopback_resolver(&pds); let key_a = crate::appview::test_support::seed_oauth_session_at( &state, DID_A, "draft-a", Some(&pds), ) .await; let key_b = crate::appview::test_support::seed_oauth_session_at( &state, DID_B, "draft-b", Some(&pds), ) .await; let cookie = |key| { jacquard_axum::oauth::set_session_cookie( PrivateCookieJar::new(state.cookie_key.clone()), &state.oauth_config, &key, ) .unwrap() .into_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 staged = app .clone() .oneshot( axum::http::Request::builder() .method("POST") .uri("/xrpc/space.polymodel.library.stageFile") .header(axum::http::header::COOKIE, cookie(key_a.clone())) .header("content-type", "model/stl") .header("x-polymodel-filename", "part.stl") .body(axum::body::Body::from("solid draft\nendsolid draft\n")) .unwrap(), ) .await .unwrap(); assert_eq!(staged.status(), axum::http::StatusCode::OK); let staged: polymodel_api::space_polymodel::library::stage_file::StageFileOutput = serde_json::from_slice( &axum::body::to_bytes(staged.into_body(), 1 << 20) .await .unwrap(), ) .unwrap(); let lifecycle_draft = |name: &str| { let mut draft = draft_named(name); draft.models[0].parts[0].upload_id = Some(staged.upload_id.to_string()); draft }; let created = app .clone() .oneshot( axum::http::Request::builder() .method("POST") .uri("/app/drafts") .header(axum::http::header::COOKIE, cookie(key_a.clone())) .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::to_vec(&lifecycle_draft("Before")).unwrap(), )) .unwrap(), ) .await .unwrap(); assert_eq!(created.status(), axum::http::StatusCode::OK); let created: DraftIdResponse = serde_json::from_slice( &axum::body::to_bytes(created.into_body(), 1 << 20) .await .unwrap(), ) .unwrap(); let path = format!("/app/drafts/{}", created.draft_id); let updated = app .clone() .oneshot( axum::http::Request::builder() .method("PUT") .uri(&path) .header(axum::http::header::COOKIE, cookie(key_a.clone())) .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::to_vec(&lifecycle_draft("After")).unwrap(), )) .unwrap(), ) .await .unwrap(); assert_eq!(updated.status(), axum::http::StatusCode::OK); let reloaded = app .clone() .oneshot( axum::http::Request::builder() .uri(&path) .header(axum::http::header::COOKIE, cookie(key_a.clone())) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(reloaded.status(), axum::http::StatusCode::OK); let reloaded: DraftThingInput = serde_json::from_slice( &axum::body::to_bytes(reloaded.into_body(), 1 << 20) .await .unwrap(), ) .unwrap(); assert_eq!(reloaded.name.as_deref(), Some("After")); let cross_owner = app .clone() .oneshot( axum::http::Request::builder() .uri(&path) .header(axum::http::header::COOKIE, cookie(key_b.clone())) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(cross_owner.status(), axum::http::StatusCode::NOT_FOUND);
let cross_owner_publish = app .clone() .oneshot( axum::http::Request::builder() .method("POST") .uri(format!("{path}/publish")) .header(axum::http::header::COOKIE, cookie(key_b)) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( cross_owner_publish.status(), axum::http::StatusCode::NOT_FOUND );
let invalid_created = app .clone() .oneshot( axum::http::Request::builder() .method("POST") .uri("/app/drafts") .header(axum::http::header::COOKIE, cookie(key_a.clone())) .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::to_vec(&DraftThingInput { name: Some("Incomplete".into()), ..Default::default() }) .unwrap(), )) .unwrap(), ) .await .unwrap(); let invalid_created: DraftIdResponse = serde_json::from_slice( &axum::body::to_bytes(invalid_created.into_body(), 1 << 20) .await .unwrap(), ) .unwrap(); let invalid_path = format!("/app/drafts/{}", invalid_created.draft_id); let invalid_publish = app .clone() .oneshot( axum::http::Request::builder() .method("POST") .uri(format!("{invalid_path}/publish")) .header(axum::http::header::COOKIE, cookie(key_a.clone())) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( invalid_publish.status(), axum::http::StatusCode::BAD_REQUEST ); let invalid_error: serde_json::Value = serde_json::from_slice( &axum::body::to_bytes(invalid_publish.into_body(), 1 << 20) .await .unwrap(), ) .unwrap(); assert_eq!(invalid_error["error"], "InvalidRequest"); let retained_invalid = app .clone() .oneshot( axum::http::Request::builder() .uri(&invalid_path) .header(axum::http::header::COOKIE, cookie(key_a.clone())) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(retained_invalid.status(), axum::http::StatusCode::OK);
let published = app .clone() .oneshot( axum::http::Request::builder() .method("POST") .uri(format!("{path}/publish")) .header(axum::http::header::COOKIE, cookie(key_a.clone())) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(published.status(), axum::http::StatusCode::OK); let published: PublishThingOutput = serde_json::from_slice( &axum::body::to_bytes(published.into_body(), 1 << 20) .await .unwrap(), ) .unwrap(); assert_eq!(published.models.len(), 1); let requests = create_records.lock().await; // Assert exactly 3 calls in part→model→thing order BEFORE any // dereference, so a missing or reordered createRecord cannot satisfy // the chain. assert_eq!(requests.len(), 3); assert_eq!( requests .iter() .map(|request| request["collection"].as_str().unwrap()) .collect::<Vec<_>>(), [ "space.polymodel.library.part", "space.polymodel.library.model", "space.polymodel.library.thing", ] ); for request in requests.iter() { assert_eq!(request["repo"], DID_A); } // Derive expected CIDs independently from request bytes, matching the // fixture's per-request CID derivation. let part_cid = crate::appview::test_support::test_blob(&serde_json::to_vec(&requests[0]).unwrap()).0; let model_cid = crate::appview::test_support::test_blob(&serde_json::to_vec(&requests[1]).unwrap()).0; let thing_cid = crate::appview::test_support::test_blob(&serde_json::to_vec(&requests[2]).unwrap()).0; // CIDs must be pairwise distinct so a misbound or partial publish // cannot satisfy the chain. let mut distinct = std::collections::HashSet::new(); assert!(distinct.insert(&part_cid), "part CID distinct"); assert!(distinct.insert(&model_cid), "model CID distinct"); assert!(distinct.insert(&thing_cid), "thing CID distinct"); // Assert each request's own identity. assert_eq!(requests[0]["rkey"], serde_json::Value::Null); assert_eq!( requests[0]["record"]["$type"], "space.polymodel.library.part" ); assert_eq!(requests[0]["record"]["name"], "part.stl"); assert_eq!(requests[0]["record"]["file"]["mimeType"], "model/stl"); assert_eq!(requests[0]["record"]["file"]["size"], staged.file.size); assert_eq!(requests[1]["rkey"], serde_json::Value::Null); assert_eq!( requests[1]["record"]["$type"], "space.polymodel.library.model" ); assert_eq!(requests[1]["record"]["name"], "Model"); // model.parts[0] must equal the captured PART response (uri+cid). assert_eq!( requests[1]["record"]["parts"][0]["uri"], format!("at://{DID_A}/space.polymodel.library.part/record-0") ); assert_eq!(requests[1]["record"]["parts"][0]["cid"], part_cid); assert_eq!(requests[2]["rkey"], serde_json::Value::Null); assert_eq!( requests[2]["record"]["$type"], "space.polymodel.library.thing" ); assert_eq!(requests[2]["record"]["name"], "After"); assert_eq!(requests[2]["record"]["license"], "CC-BY-4.0"); // thing.models[0] must equal the captured MODEL response. assert_eq!( requests[2]["record"]["models"][0]["uri"], format!("at://{DID_A}/space.polymodel.library.model/record-1") ); assert_eq!(requests[2]["record"]["models"][0]["cid"], model_cid); // Assert the publish output's strong refs match the captured responses. assert_eq!(published.models.len(), 1); assert_eq!(published.models[0].parts.len(), 1); assert_eq!( published.models[0].parts[0].uri.as_str(), format!("at://{DID_A}/space.polymodel.library.part/record-0") ); assert_eq!(published.models[0].parts[0].cid.as_str(), part_cid); assert_eq!( published.models[0].model.uri.as_str(), format!("at://{DID_A}/space.polymodel.library.model/record-1") ); assert_eq!(published.models[0].model.cid.as_str(), model_cid); assert_eq!( published.thing.uri.as_str(), format!("at://{DID_A}/space.polymodel.library.thing/record-2") ); assert_eq!(published.thing.cid.as_str(), thing_cid); drop(requests); let deleted_after_publish = app .oneshot( axum::http::Request::builder() .uri(&path) .header(axum::http::header::COOKIE, cookie(key_a)) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( deleted_after_publish.status(), axum::http::StatusCode::NOT_FOUND ); server.abort(); }
async fn seed_staged_manifest( state: &AppState, upload_id: &str, cid: &str, size: i64, digest: Vec<u8>, ) { let file = serde_json::json!({ "mimeType": "application/x-ldraw", "size": size, "digest": { "$bytes": base64::engine::general_purpose::STANDARD.encode(&digest) }, "chunks": [{ "blob": { "$type": "blob", "ref": { "$link": cid }, "mimeType": "application/x-ldraw", "size": size }, "offset": 0, "size": size }] }); sqlx::query( "INSERT INTO upload_staging (owner_did, upload_id, sha256, mime_type, size, filename, status, file_json, chunks_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'uploaded', ?, '[]', 1, 1)", ) .bind(DID_A) .bind(upload_id) .bind(digest) .bind("application/x-ldraw") .bind(size) .bind("preview.dat") .bind(file.to_string()) .execute(&state.pool) .await .unwrap(); }
#[tokio::test] async fn staged_preview_route_reports_fetch_integrity_and_bounds_failures() { use sha2::Digest as _;
let mut state = crate::appview::test_support::state().await; crate::appview::test_support::seed_identity(&state.pool, DID_A, "alice.com").await; crate::appview::test_support::seed_profile(&state.pool, DID_A, "Alice").await; let (pds, server) = crate::appview::test_support::loopback_pds(false, None).await; state.resolver = crate::appview::test_support::loopback_resolver(&pds); let key = crate::appview::test_support::seed_oauth_session_at( &state, DID_A, "staged-preview-failures", Some(&pds), ) .await; let cookie = jacquard_axum::oauth::set_session_cookie( PrivateCookieJar::new(state.cookie_key.clone()), &state.oauth_config, &key, ) .unwrap() .into_response() .headers() .get(axum::http::header::SET_COOKIE) .unwrap() .to_str() .unwrap() .split(';') .next() .unwrap() .to_owned(); let good_cid = "bafkreih3ryqpylsmh4siyygdtplff46bgrzjro4xpofu2widxbifkyqgam"; let failed_cid = "bafkreih7uy2yhx5gobvypuuexbvq22j2cypeqqfk2lc46225e7b3syq7pu"; seed_staged_manifest( &state, "good", good_cid, 2, sha2::Sha256::digest(b"ab").to_vec(), ) .await; seed_staged_manifest( &state, "fetch-failure", failed_cid, 2, sha2::Sha256::digest(b"ab").to_vec(), ) .await; seed_staged_manifest(&state, "integrity-failure", good_cid, 2, vec![0; 32]).await; seed_staged_manifest( &state, "bounds-failure", good_cid, crate::publish::draft::MAX_PREVIEW_RESOURCE_BYTES as i64 + 1, vec![0; 32], ) .await; let app = crate::appview::router().with_state(state);
let success = app .clone() .oneshot( axum::http::Request::builder() .uri("/app/staged/good") .header(axum::http::header::COOKIE, &cookie) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(success.status(), axum::http::StatusCode::OK); assert_eq!(success.headers()["content-type"], "application/x-ldraw"); assert_eq!( axum::body::to_bytes(success.into_body(), 1024) .await .unwrap() .as_ref(), b"ab" );
for (upload_id, status, error, message) in [ ( "fetch-failure", axum::http::StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", "blob fetch failed with HTTP 503 Service Unavailable", ), ( "integrity-failure", axum::http::StatusCode::BAD_REQUEST, "InvalidRequest", "staged preview integrity verification failed", ), ( "bounds-failure", axum::http::StatusCode::BAD_REQUEST, "InvalidRequest", "staged resource exceeds preview bounds", ), ] { let response = app .clone() .oneshot( axum::http::Request::builder() .uri(format!("/app/staged/{upload_id}")) .header(axum::http::header::COOKIE, &cookie) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), status, "{upload_id}"); let body: serde_json::Value = serde_json::from_slice( &axum::body::to_bytes(response.into_body(), 1 << 20) .await .unwrap(), ) .unwrap(); assert_eq!(body["error"], error, "{upload_id}: {body}"); assert_eq!(body["message"], message, "{upload_id}: {body}"); } server.abort(); }
#[tokio::test] async fn authenticated_publish_failure_keeps_draft_reloadable() { let mut state = crate::appview::test_support::state().await; crate::appview::test_support::seed_identity(&state.pool, DID_A, "alice.com").await; crate::appview::test_support::seed_profile(&state.pool, DID_A, "Alice").await; let (pds, _create_records, server) = crate::appview::test_support::loopback_pds_for_publish(true).await; state.resolver = crate::appview::test_support::loopback_resolver(&pds); let key = crate::appview::test_support::seed_oauth_session_at( &state, DID_A, "draft-publish-failure", Some(&pds), ) .await; let cookie = jacquard_axum::oauth::set_session_cookie( PrivateCookieJar::new(state.cookie_key.clone()), &state.oauth_config, &key, ) .unwrap() .into_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 staged = app .clone() .oneshot( axum::http::Request::builder() .method("POST") .uri("/xrpc/space.polymodel.library.stageFile") .header(axum::http::header::COOKIE, &cookie) .header("content-type", "model/stl") .header("x-polymodel-filename", "failure.stl") .body(axum::body::Body::from("solid failure\nendsolid failure\n")) .unwrap(), ) .await .unwrap(); assert_eq!(staged.status(), axum::http::StatusCode::OK); let staged: polymodel_api::space_polymodel::library::stage_file::StageFileOutput = serde_json::from_slice( &axum::body::to_bytes(staged.into_body(), 1 << 20) .await .unwrap(), ) .unwrap(); let mut draft = draft_named("Keep after PDS failure"); draft.models[0].parts[0].upload_id = Some(staged.upload_id.to_string()); let created = app .clone() .oneshot( axum::http::Request::builder() .method("POST") .uri("/app/drafts") .header(axum::http::header::COOKIE, &cookie) .header("content-type", "application/json") .body(axum::body::Body::from(serde_json::to_vec(&draft).unwrap())) .unwrap(), ) .await .unwrap(); let created: DraftIdResponse = serde_json::from_slice( &axum::body::to_bytes(created.into_body(), 1 << 20) .await .unwrap(), ) .unwrap(); let path = format!("/app/drafts/{}", created.draft_id); let failed = app .clone() .oneshot( axum::http::Request::builder() .method("POST") .uri(format!("{path}/publish")) .header(axum::http::header::COOKIE, &cookie) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( failed.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR ); let error: serde_json::Value = serde_json::from_slice( &axum::body::to_bytes(failed.into_body(), 1 << 20) .await .unwrap(), ) .unwrap(); assert_eq!(error["error"], "InternalServerError"); assert_eq!(error["message"], "authenticated PDS operation failed"); let retained = app .oneshot( axum::http::Request::builder() .uri(&path) .header(axum::http::header::COOKIE, cookie) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(retained.status(), axum::http::StatusCode::OK); server.abort(); }
#[tokio::test] async fn promote_failure_keeps_draft() { let state = state().await; let owner = did(DID_A); upsert_draft(&state, &owner, "d1", &draft_named("Keepme")) .await .unwrap();
let result = promote_draft(&state, &owner, "d1", |_thing| async { Err(internal("simulated publish failure")) }) .await; assert!(result.is_err(), "publish failure must propagate");
// The invariant: a failed publish must not delete the draft. assert!( get_draft(&state, &owner, "d1").await.unwrap().is_some(), "draft must survive a failed publish" ); }
#[tokio::test] async fn promote_invalid_draft_is_rejected_and_kept() { let state = state().await; let owner = did(DID_A); // Missing license/models → assemble fails before any publish attempt. let partial = DraftThingInput { name: Some("WIP".to_string()), ..Default::default() }; upsert_draft(&state, &owner, "d1", &partial).await.unwrap();
let mut published = false; let result = promote_draft(&state, &owner, "d1", |_thing| { published = true; async { unreachable!("publish must not run for an invalid draft") } }) .await; assert!(result.is_err()); assert!(!published, "an invalid draft must never reach publish"); assert!(get_draft(&state, &owner, "d1").await.unwrap().is_some()); }}