//! THE wire-contract proof: the real `makepad-asset-client` against a //! real `AssetServer`, over real sockets — connect, listing probe, publish, //! search, detail, event subscription, jobs and the worker protocol, all in //! one flow. No fixtures, no mocks, on either side. //! //! This also pins the root-authorization semantics: the bootstrap admin //! token (the documented default credential) can provision and exercise the //! whole server — upload, register, annotate, publish, alias, enqueue, //! claim, cancel — without any explicit self-grants. use makepad_asset_store::{AssetServer, ServerConfig}; use makepad_asset_client::{ ApiEndpoints, AssetClient, CatalogQuery, CatalogSubscriberConfig, CatalogSubscriptionEvent, ClientConfig, ClientError, PublishFile, PublishRequest, PublishThumbnail, RoomClaimDto, }; use makepad_asset_data::{ AssetAlias, AssetId, AssetKind, AssetRevisionRef, ContentLock, FileRole, GameAlias, GameRevisionManifest, LockEntry, MediaType, ThumbnailMedia, ThumbnailMeta, }; use std::path::PathBuf; use std::str::FromStr; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; static DIR_COUNTER: AtomicU64 = AtomicU64::new(0); fn test_root(name: &str) -> PathBuf { let n = DIR_COUNTER.fetch_add(1, Ordering::Relaxed); std::env::temp_dir().join(format!( "mp_asset_e2e_{}_{}_{}", std::process::id(), n, name )) } fn start_server(name: &str) -> (AssetServer, String) { let root = test_root(name); let mut cfg = ServerConfig::new(root.clone()); cfg.control_addr = "127.0.0.1:0".parse().unwrap(); cfg.data_addr = "127.0.0.1:0".parse().unwrap(); cfg.bootstrap_admin = true; cfg.log = false; let server = AssetServer::start(cfg).expect("server start"); let token = std::fs::read_to_string(root.join("admin-token")) .expect("admin token") .trim() .to_string(); (server, token) } fn connect(server: &AssetServer, token: &str, cache: &str) -> AssetClient { let mut cfg = ClientConfig::new(test_root(cache)); cfg.token = Some(token.to_string()); let endpoints = ApiEndpoints { control: server.control_addr(), data: server.data_addr(), }; AssetClient::connect(cfg, endpoints, Some(server.server_id())) .expect("real connect (health + credentialed listing probe)") } #[test] fn real_client_full_stack_roundtrip() { let (server, token) = start_server("full_stack"); let mut client = connect(&server, &token, "full_stack_cache"); // Empty catalog: the listing route answers honestly. let page = client.assets_page(None, None, 10).expect("listing"); assert!(page.assets.is_empty()); assert!(page.next.is_none()); // Subscribe to committed catalog events BEFORE publishing. let mut sub_cfg = CatalogSubscriberConfig::default_v1(); sub_cfg.wait_ms = 2_000; let mut subscriber = client.subscribe_catalog(sub_cfg).expect("subscribe"); // Publish a video artifact end to end with the ADMIN token (root // bypass: no explicit grants were ever made). let artifact = vec![0xAB; 6_000]; let thumb = vec![0xCD; 1_500]; let mut request = PublishRequest::new( "gen", AssetKind::Video, "E2E neon clip", PublishFile { bytes: artifact.clone(), media: MediaType::Mp4, role: FileRole::Video, media_millis: 5_200, dims: None, }, PublishThumbnail { bytes: thumb.clone(), media: ThumbnailMedia::Png, width: 512, height: 512, views: Vec::new(), }, ); request.alias = Some(AssetAlias::from_str("gen/e2e-neon").unwrap()); request.categories = vec!["demo".into()]; request.prompt = "a neon e2e clip".into(); let published = client.publish_artifact(&request).expect("publish via admin token"); // Search (server-side kind filter) finds it. let mut query = CatalogQuery::browse(10); query.kind = Some(AssetKind::Video); let found = client.catalog_search(&query, None).expect("search"); assert!(found.hits.iter().any(|h| h.asset_id == published.asset_id)); let hit = found.hits.iter().find(|h| h.asset_id == published.asset_id).unwrap(); assert_eq!(hit.kind, Some(AssetKind::Video)); assert_eq!(hit.title, "E2E neon clip"); // Full detail: candidates carry the published revision. let detail = client.asset_detail(&published.asset_id).expect("detail"); assert_eq!( detail.latest_published().expect("published candidate").revision, published.revision ); // Manifest + blob bytes round-trip through the verified cache. let manifest = client.fetch_asset_manifest(&published.revision).expect("manifest"); assert_eq!(manifest.asset_id, published.asset_id); let bytes = client .fetch_blob_bytes(&published.artifact_blob, Some(artifact.len() as u64)) .expect("blob"); assert_eq!(bytes, artifact); // The same real client publishes and reads an exact game revision through // the public `/v1/game-revisions/{grev}` contract. This specifically // guards the Sandbox bootstrap path against fixture-only routes. let game = client.register_game("gen", None).expect("register game"); let locked_alias = AssetAlias::from_str("gen/e2e-neon").unwrap(); let locked = AssetRevisionRef { asset_id: published.asset_id, revision: published.revision, }; let lock = ContentLock { game_id: game, entries: vec![LockEntry { alias: locked_alias, asset_id: locked.asset_id, revision: locked.revision, }], closure: vec![locked], variant_sets: Vec::new(), }; let lock_bytes = lock.to_canonical_bytes().expect("lock bytes"); let splash = b"game { model: gen/e2e-neon }".to_vec(); let game_toml = b"[game]\nname=\"E2E\"\n".to_vec(); let game_thumb = vec![0xE2; 1_024]; let splash_blob = client.upload_blob("gen", &splash).expect("game splash blob"); let manifest_blob = client .upload_blob("gen", &game_toml) .expect("game toml blob"); let lock_blob = client.upload_blob("gen", &lock_bytes).expect("game lock blob"); let thumb_blob = client .upload_blob("gen", &game_thumb) .expect("game thumbnail blob"); let game_manifest = GameRevisionManifest { game_id: game, name: "E2E generated world".into(), description: "Real client/server game revision roundtrip".into(), author: "Asset Server test".into(), splash_blob, manifest_blob, lock_blob, thumbnail: ThumbnailMeta { blob: thumb_blob, media: ThumbnailMedia::Png, width: 512, height: 512, byte_len: game_thumb.len() as u64, views: Vec::new(), }, catalog_snapshot: None, search_algorithm_version: 1, engine_version: 1, protocol_version: 1, splash_byte_len: splash.len() as u64, }; let game_revision = client .stage_game_revision(&game, &game_manifest, &lock) .expect("stage game revision"); assert_eq!( client .fetch_game_manifest(&game_revision) .expect("fetch staged game revision"), game_manifest ); client .publish_game_revision(&game, &game_revision) .expect("publish game revision"); let game_alias = GameAlias::from_str("gen/games/e2e-world").unwrap(); client .put_game_alias(&game_alias, &game, &game_revision) .expect("set game alias"); let resolved_game = client.resolve_game_alias(&game_alias).expect("resolve game alias"); assert_eq!(resolved_game.game_id, game); assert_eq!(resolved_game.head_revision, game_revision); // Listing now carries the asset. let page = client.assets_page(Some("gen"), None, 10).expect("listing 2"); assert_eq!(page.assets.len(), 1); assert_eq!(page.assets[0].asset_id, published.asset_id); assert!(page.assets[0].created_ms > 0); // The event stream delivers the publication (kind-stamped: the client // publish flow annotates BEFORE publishing). let deadline = Instant::now() + Duration::from_secs(10); let mut saw_publish = false; while Instant::now() < deadline && !saw_publish { for event in subscriber.poll() { if let CatalogSubscriptionEvent::Events { events, .. } = event { for ev in events { if ev.asset_id == Some(published.asset_id) && ev.kind == makepad_asset_client::CatalogEventKind::AssetPublished { assert_eq!(ev.content_kind, Some(AssetKind::Video)); assert_eq!(ev.revision, Some(published.revision)); saw_publish = true; } } } } std::thread::sleep(Duration::from_millis(50)); } assert!(saw_publish, "asset_published event must reach the subscriber"); subscriber.shutdown(); // The jobs + worker protocol left the store (aicore P7): generation is // client-driven now. Catalog, publish, events and downloads above are the // whole remaining surface. drop(server); } #[test] fn publish_retry_recovers_a_published_revision_missing_its_alias() { let (server, token) = start_server("publish_alias_recovery"); let mut client = connect(&server, &token, "publish_alias_recovery_cache"); let asset_id = AssetId::from_bytes([0x42; 16]); let alias = AssetAlias::from_str("gen/history-recovery").unwrap(); let mut request = PublishRequest::new( "gen", AssetKind::Video, "Recoverable clip", PublishFile { bytes: vec![0xA5; 6_000], media: MediaType::Mp4, role: FileRole::Video, media_millis: 2_000, dims: None, }, PublishThumbnail { bytes: vec![0x5A; 1_500], media: ThumbnailMedia::Png, width: 512, height: 512, views: Vec::new(), }, ); request.asset_id = Some(asset_id); // Models the importer crash/failure point seen in production: the asset // and candidate are Published, but no alias was committed afterward. let first = client.publish_artifact(&request).expect("initial publish without alias"); assert_eq!(first.asset_id, asset_id); assert!(client.resolve_alias(&alias).is_err(), "alias must not exist yet"); // Exact same manifest/revision: retry must not re-stage Published. It // resumes at the missing idempotent alias write instead. request.alias = Some(alias.clone()); let recovered = client.publish_artifact(&request).expect("recover missing alias"); assert_eq!(recovered.asset_id, first.asset_id); assert_eq!(recovered.revision, first.revision); let resolved = client.resolve_alias(&alias).expect("recovered alias"); assert_eq!(resolved.asset_id, asset_id); assert_eq!(resolved.head_revision, first.revision); let detail = client.asset_detail(&asset_id).expect("detail"); assert_eq!(detail.candidates.len(), 1, "retry must not create a duplicate candidate"); assert_eq!(detail.latest_published().unwrap().revision, first.revision); } #[test] fn non_root_principal_still_needs_grants() { // The root bypass must not weaken scoping for everyone else: a fresh // principal without grants is denied enqueue/publish paths. let (server, admin_token) = start_server("scoped"); let admin = connect(&server, &admin_token, "scoped_admin_cache"); let _ = admin; // admin connect itself is the read-probe check // Mint a token for an ungranted principal via raw client API calls is // an admin-only flow the typed client does not wrap; use the admin to // publish and then verify the ungranted principal cannot. Simplest // hostile check without extra plumbing: a bogus (well-formed, unknown) // token fails the connect probe with the uniform 401. let mut cfg = ClientConfig::new(test_root("scoped_cache")); cfg.token = Some(format!("mpat_{}", "77".repeat(32))); let endpoints = ApiEndpoints { control: server.control_addr(), data: server.data_addr(), }; match AssetClient::connect(cfg, endpoints, Some(server.server_id())) { Err(ClientError::Unauthenticated) => {} other => panic!("unknown token must be uniformly refused, got {other:?}"), } } /// The rendezvous, end to end through the real client: two players press /// Play on the same game and the second is sent to the first, not handed a /// second claim. Then the unreachable-room escape, which is what keeps a /// stale record from becoming a dead end for everyone who follows. #[test] fn real_clients_meet_in_one_room_and_never_dead_end_on_a_stale_one() { let (server, token) = start_server("rooms"); let rik = connect(&server, &token, "rooms_rik_cache"); let sam = connect(&server, &token, "rooms_sam_cache"); // Nobody is playing. Both apps see the same nothing. assert!(rik.rooms(Some("arcade")).expect("list").is_empty()); assert!(sam.rooms(None).expect("list").is_empty()); // Rik presses Play. No room, so he hosts and takes the claim. let claimed = rik .claim_room("arcade", "10.0.0.7:5000:5001#ab", "rik", 30_000, None) .expect("claim"); let RoomClaimDto::Claimed { room, token: host_token } = claimed else { panic!("the first press must take the claim"); }; // Sam presses Play on the same game. He is told where Rik is — and is // NOT given a claim of his own, which is the whole point. let second = sam .claim_room("arcade", "10.0.0.9:6000:6001#cd", "sam", 30_000, None) .expect("claim"); let RoomClaimDto::Occupied { room: found } = second else { panic!("the second press must be sent to the first"); }; assert_eq!(found, room); assert_eq!(found.invite, "10.0.0.7:5000:5001#ab"); assert_eq!(found.host, "rik"); assert_eq!(rik.rooms(Some("arcade")).expect("list"), vec![found.clone()]); // Rik stays alive, then leaves; his claim frees at once. let beat = rik.room_heartbeat(&room.room, &host_token, 30_000).expect("heartbeat"); assert!(beat.expires_ms >= room.expires_ms); rik.retire_room(&room.room, &host_token).expect("retire"); assert!(sam.rooms(Some("arcade")).expect("list").is_empty()); // Leaving twice is not an error — a host that leaves and then exits // runs both paths. rik.retire_room(&room.room, &host_token).expect("retire again"); // A room whose host has vanished without retiring: Sam reads it, fails // to dial it, and says which room he failed on. He becomes the host // instead of hitting the same wall forever. let stale = rik .claim_room("arcade", "10.0.0.7:5000:5001#ab", "rik", 30_000, None) .expect("claim"); let RoomClaimDto::Claimed { room: dead, token: dead_token } = stale else { panic!("claim"); }; let taken = sam .claim_room("arcade", "10.0.0.9:6000:6001#cd", "sam", 30_000, Some(&dead.room)) .expect("replacing claim"); let RoomClaimDto::Claimed { room: live, .. } = taken else { panic!("an unreachable room must yield its claim"); }; assert_eq!(live.host, "sam"); assert_eq!(rik.rooms(Some("arcade")).expect("list"), vec![live]); // The displaced host learns the claim moved the next time it says it is // alive — a plain NotFound, which means "claim again", not "you broke". match rik.room_heartbeat(&dead.room, &dead_token, 30_000) { Err(ClientError::NotFound { .. }) => {} other => panic!("a replaced room must heartbeat as gone, got {other:?}"), } // Local refusals happen before anything reaches the wire. assert!(matches!( rik.claim_room("", "i", "h", 30_000, None), Err(ClientError::InvalidInput { .. }) )); assert!(matches!( rik.claim_room("g", "i", "h", 1, None), Err(ClientError::InvalidInput { .. }) )); assert!(matches!( rik.rooms(Some(&"x".repeat(200))), Err(ClientError::InvalidInput { .. }) )); }