makepad/libs/asset/store/tests/http/service_http.rs
Admin e37c263b9c ai backbone: the hub era — makepad_ai deleted, every backend is a hub pipe; machine residency elections, job leases, ETA placement; the store only stores; creator pipelines run in the app (aicore)
Squashed from work:
- asset-ai: FastH3 4-step fast video backend; clip keyframes on the wire
- asset-ui: loop video chains — text→image→video that ends where it began
- h3: safetensors -> pruned-Q4_K GGUF quantizer for the 24GB DiT tiers
- h3_quant_gguf verify: row-error gates calibrated to the measured Q4_K floor
- asset-ai realtime: the feedback loop — the source anchors, the drifted frame inits
- asset-ai realtime: a feedback loop survives a resize and travels by default
- asset-ai realtime: the feedback loop frees itself from the feed handshake and pauses for its listener
- asset-ai realtime: the outbound encode leaves the loop's critical path
- asset-ai ocr: the ocr domain — Chandra 2 at page resolution, and the tower goes planner-owned
- llm slots: a lane can hold an image span — embedding prefill and a rope cursor of its own
- vision tower on CUDA: the encode leg gets its two missing kernels
- llm/ocr: one M-RoPE grid encoder for both image paths, and a livelock made an error
- vision tower on CUDA: the f16 GEMM keeps the precision it was throwing away
- live: a feed that moves box takes its trip with it — one seed image
- vision tower on CUDA: the tiled attention becomes bit-exact, and tensor cores go
- llm prefill on CUDA: the MMA attention kernel gets the tile a 4-to-1 model needs
- asset-ai ocr: the CUDA encode lane joins the integration — vision-parity sits beside run's three arms, and the kernels
- Merge branch 'ocr-perf-integration' into work
- asset-ai: the live anchor can follow the trip, and text leaves the 5090
- asset-ai: the camera moves the world, and the world starts still
- asset-import: the EA strategy classics, in the one 2D contract
- rtsmap: one seeded generator for tiled strategy maps
- asset-ui: one card for the strategy classics, with a pack dropdown
- asset-ai: music3 reference-audio path, ocr/h3 backends, registry
- asset: mp4 sample index for range-streaming, chat tools, import profiles
- cnc: tiberium is twelve growth frames, not twelve empty variants
- platform: native file and save dialogs, in-house on all three desktops
- chat: the scan holds out for a lane home
- chat: a full home queues you — take the free lane
- chat: the preload has a percentage, and the boundless cap stops showing
- llm cuda: the 32x2 attention tile — even GQA ratios stay on MMA
- sa3 gets a bake path: the sfx model's tables precomputed by a diffusion-side bin
- sqlite_query: anti-join regression test
- td import: HARV's second frame block is its harvesting cycle, not a turret
- asset-ui: sprite enhancement runs on the 32B dev DiT — distillation, not the prompt, was the ceiling
- ai-hub: makepad-asset-ai becomes makepad-ai-hub at libs/ai/hub, the chat pane becomes makepad-chat-ui, the service bin
- asset-ui: test health fixtures grow the realtime field they were born without
- ai-hub: one home at ~/.makepad — weights/ run/ cache/ logs/, the service cache migrates from ai_content by a single re
- ai-hub: subprocess workers die with the node — process groups everywhere, PDEATHSIG on linux, one KILL_ON_JOB_CLOSE Jo
- ai-hub: the hub object — AiHub::in_process, pipes vocabulary, and the local LLM engine generalized out of mpfiles (aic
- strict-json: the dependency-free JSON module gets its own crate; asset-client re-exports it so nothing downstream move
- ai-hub: the machine layer — node entries, the 0600 machine token, and the residency election that IS the lock (aicore
- ai-hub: MPHUB1 — the fabric beacon only dedicated nodes can send (aicore §4)
- ai-hub: job leases — work lives only while it is renewed (aicore §8)
- asset-creator: the pipeline library is born — specs, the deps gate, and the derived-state law (aicore §9)
- ai-hub: RAM residency facts — the CPU-side twin of residency.rs (aicore §3)
- ai-hub: ETA placement primitives — relative GPU throughput, the four-term estimate, and an observable breakdown (aicor
- ai-hub: leases go live on the wire — origin fields on submit, /job/<id>/keepalive, /bye, and the reaper that cancels w
- ai-hub: the chat providers move in — fleet qwen, openai, grok, claude/codex/grok CLIs, the responses driver, and the w
- asset-creator: the engine — one pipeline run against the hub, deps-gated, spliced, cancellable, resumable-by-construct
- ai-hub: the machine node mode — --machine binds loopback, registers in ~/.makepad/run, and exits on its own once idle
- asset-creator: makepad-creator-run — the detached client for runs that must outlive a window (aicore §9)
- ai-hub: a native Claude Messages-API provider — API-key or Claude Code OAuth, bounded SSE streaming, injected tools (a
- route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p
- asset-creator: the preset tables move in — fifteen chain-policy constants shared by every creator app (aicore §9 / P6)
- makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026
- ai-hub: loads hold the machine residency election — set_model_state claims on Loaded and publishes the service port (a
- ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open
- ai-hub: pick_for_domain_eta — ETA-ranked placement over the shared hard-filter core (aicore §6 / P4)
- asset-creator: the engine picks a provider per stage at dispatch time — a chain's later stages see fresh fleet state (
- ai-hub: the fabric secret gates the service HTTP surface — bearer on everything but /health and the ticketed peer path
- vj: DREAM runs execute in the app — pipelines.rs becomes the run it used to watch (aicore §9 / F1)
- asset-creator: the runner — generate one thing and put it in the catalog, one implementation for every surface (aicore
- chat-ui: the session runs in the app — no broker anywhere on the chat path (aicore P8 / F5)
- asset-store: assets.query is a first-class query endpoint — the bounded SQL surface outlives the broker (aicore P8 / F
- asset-creator: CreatorTools — the chat tool pack for a store that only stores (aicore §9 / P8)
- asset-store: the shrink — the store stores (aicore P7)
- importer + asset-server host: the coordination era ends (aicore P7)
- store config purge + asset-ui goes fleet-direct; the derive protocol gets its route proof (aicore P7)
- client + chat dispatcher: the dead wire comes out (aicore P7/P8)
- ai-hub: 0.3.0 — the health version says which era a node runs
- ai-hub: the default fleet is 'gen' — apps hear the LAN without env plumbing
- ai-hub: the preload note percents the prefill, not the job bar
- ai-hub: conversations keep their KV — the wire mirror, the lane identity, the in-turn dynamic context (aicore §7)
- ai-hub: an open-think model is thinking from its first token
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
- zero-warning sweep, round two — the first full-workspace pass
- zero-warning sweep, round three — the model lanes and the deep examples
- zero-warning sweep, round four — the last stragglers
- zero-warning sweep, round five — vj and chat-ui
- zero-warning sweep, round six — three cascades

Co-authored-by: Claude <info@makepad.nl>
2026-09-01 16:46:31 +02:00

910 lines
37 KiB
Rust

//! End-to-end service tests over real sockets: publication lifecycle,
//! verified blob serving with conditionals and ranges, search + annotations,
//! games, discovery, restart and shutdown.
mod common;
use common::*;
use makepad_asset_client::{Api, ApiEndpoints, HttpLimits};
use makepad_asset_store::discovery::DiscoveryListener;
use makepad_asset_store::json::Value;
use makepad_asset_store::{AssetServer, DiscoveryConfig};
use makepad_asset_data::{AssetId, AssetRevisionId, AssetRevisionRef, BlobId, GameId};
#[test]
fn health_bootstrap_and_whoami() {
let ts = start_server("health");
// Health requires no credentials and reports the real schema versions.
let mut anon = ts.control(None);
let r = anon.get("/v1/health");
assert_eq!(r.status, 200);
assert_eq!(r.json().get("status").unwrap().as_str(), Some("ok"));
assert!(r.json().get("schema_version").unwrap().as_i64().unwrap() >= 1);
assert!(r.json().get("limits").unwrap().get("max_blob_bytes").is_some());
// The identity handshake fields the asset client pins against beacons.
assert_eq!(r.json().get("protocol_version").unwrap().as_i64(), Some(1));
assert_eq!(r.str_field("server_id").len(), 32);
// Health is symmetric on the data plane.
let r = ts.data(None).get("/v1/health");
assert_eq!(r.status, 200);
// Everything else is not anonymous.
assert_eq!(anon.get("/v1/auth/whoami").status, 401);
// The bootstrap admin token from <root>/admin-token authenticates.
let token = ts.admin_token();
assert!(token.starts_with("mpat_"));
let mut admin = ts.control(Some(&token));
let r = admin.get("/v1/auth/whoami");
assert_eq!(r.status, 200);
assert!(r.str_field("principal").starts_with("prin_"));
// Unknown paths and wrong methods refuse cleanly.
assert_eq!(admin.get("/v1/nothing").status, 404);
assert_eq!(admin.post_json("/v1/health", &jobj(vec![])).status, 405);
}
#[test]
fn assets_query_accepts_select_refuses_writes_and_requires_auth() {
let ts = start_server("assets_query");
let admin_token = ts.admin_token();
let mut admin = ts.control(Some(&admin_token));
let reader_token = principal_with(&mut admin, &[]);
let mut reader = ts.control(Some(&reader_token));
let select = jobj(vec![("sql", jstr("SELECT 7 AS answer"))]);
let r = reader.post_json("/v1/assets/query", &select);
assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
let result = r.json();
assert_eq!(
result.get("columns").and_then(Value::as_arr).unwrap()[0].as_str(),
Some("answer")
);
assert_eq!(
result.get("rows").and_then(Value::as_arr).unwrap()[0]
.as_arr()
.unwrap()[0]
.as_str(),
Some("7")
);
assert_eq!(result.get("truncated").and_then(Value::as_bool), Some(false));
let api = Api::new(
ApiEndpoints {
control: ts.server.control_addr(),
data: ts.server.data_addr(),
},
HttpLimits::default_v1(),
Some(reader_token.clone()),
)
.unwrap();
let typed = api.assets_query("SELECT 8 AS typed_answer").unwrap();
assert_eq!(typed.columns, vec!["typed_answer"]);
assert_eq!(typed.rows, vec![vec!["8"]]);
let limited = jobj(vec![
(
"sql",
jstr("SELECT name FROM principals ORDER BY name"),
),
("limit", Value::Int(1)),
]);
let r = reader.post_json("/v1/assets/query", &limited);
assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
assert_eq!(r.json().get("rows").and_then(Value::as_arr).unwrap().len(), 1);
assert_eq!(r.json().get("truncated").and_then(Value::as_bool), Some(true));
assert_eq!(
reader
.post_json(
"/v1/assets/query",
&jobj(vec![
("sql", jstr("SELECT 1")),
("limit", Value::Int(201)),
]),
)
.status,
400
);
let write = jobj(vec![("sql", jstr("INSERT INTO assets DEFAULT VALUES"))]);
assert_eq!(reader.post_json("/v1/assets/query", &write).status, 400);
let mut anonymous = ts.control(None);
assert_eq!(anonymous.post_json("/v1/assets/query", &select).status, 401);
}
#[test]
fn blob_roundtrip_conditionals_and_ranges() {
let ts = start_server("blobs");
let token = ts.admin_token();
let mut admin = ts.control(Some(&token));
let writer_token = principal_with(&mut admin, &[("blob_write", "demo")]);
let mut data = ts.data(Some(&writer_token));
let payload: Vec<u8> = (0..2000u32).map(|i| (i % 251) as u8).collect();
let blob_id = BlobId::hash_of(&payload);
// Upload, then dedup on re-upload.
let r = data.post_bytes("/v1/blobs?ns=demo", &payload);
assert_eq!(r.status, 201, "{}", String::from_utf8_lossy(&r.body));
assert_eq!(r.str_field("blob_id"), blob_id.to_string());
assert_eq!(r.json().get("deduped").unwrap().as_bool(), Some(false));
let r = data.post_bytes("/v1/blobs?ns=demo", &payload);
assert_eq!(r.json().get("deduped").unwrap().as_bool(), Some(true));
// Pre-declared digest must match the streamed bytes.
let hex = blob_id.to_string().replace("sha256:", "");
let r = data.request(
"POST",
&format!("/v1/blobs?ns=demo&sha256={hex}"),
&[],
Some(&payload),
);
assert_eq!(r.status, 201);
let wrong = "0".repeat(64);
let r = data.request(
"POST",
&format!("/v1/blobs?ns=demo&sha256={wrong}"),
&[],
Some(&payload),
);
assert_eq!(r.status, 422);
// Full read, digest-verified server-side, with a strong ETag.
let target = format!("/v1/blobs/{blob_id}");
let r = data.get(&target);
assert_eq!(r.status, 200);
assert_eq!(r.body, payload);
let etag = r.header("ETag").expect("etag").to_string();
assert_eq!(etag, format!("\"{blob_id}\""));
assert_eq!(r.header("Accept-Ranges"), Some("bytes"));
// HEAD: identical metadata, no body.
let r = data.head(&target);
assert_eq!(r.status, 200);
assert_eq!(r.header("Content-Length"), Some("2000"));
assert!(r.body.is_empty());
// If-None-Match -> 304 with no body.
let r = data.request("GET", &target, &[("If-None-Match", &etag)], None);
assert_eq!(r.status, 304);
assert!(r.body.is_empty());
// Byte ranges: bounded, suffix, open-ended, over-long clamps.
let cases: &[(&str, u16, &[u8])] = &[
("bytes=100-199", 206, &payload[100..200]),
("bytes=-100", 206, &payload[1900..]),
("bytes=1900-", 206, &payload[1900..]),
("bytes=1990-4000", 206, &payload[1990..]),
("bytes=0-0", 206, &payload[0..1]),
];
for (range, status, expect) in cases {
let r = data.request("GET", &target, &[("Range", range)], None);
assert_eq!(r.status, *status, "range {range}");
assert_eq!(&r.body, expect, "range {range}");
assert!(r.header("Content-Range").unwrap().starts_with("bytes "));
}
let r = data.request("GET", &target, &[("Range", "bytes=100-199")], None);
assert_eq!(r.header("Content-Range"), Some("bytes 100-199/2000"));
// Unsatisfiable start -> 416 with the total size.
let r = data.request("GET", &target, &[("Range", "bytes=2000-")], None);
assert_eq!(r.status, 416);
assert_eq!(r.header("Content-Range"), Some("bytes */2000"));
// Multi-range and malformed ranges are ignored: full 200.
for bad in ["bytes=0-1,5-6", "bytes=a-b", "bites=0-1"] {
let r = data.request("GET", &target, &[("Range", bad)], None);
assert_eq!(r.status, 200, "range {bad}");
assert_eq!(r.body, payload);
}
// If-Range: matching validator keeps the range; anything else serves full.
let r = data.request(
"GET",
&target,
&[("Range", "bytes=0-9"), ("If-Range", &etag)],
None,
);
assert_eq!(r.status, 206);
assert_eq!(r.body, &payload[0..10]);
let r = data.request(
"GET",
&target,
&[("Range", "bytes=0-9"), ("If-Range", "\"sha256:ffff\"")],
None,
);
assert_eq!(r.status, 200);
assert_eq!(r.body, payload);
// Unknown digest is a 404; malformed one a 400.
let missing = BlobId::hash_of(b"never uploaded");
assert_eq!(data.get(&format!("/v1/blobs/{missing}")).status, 404);
assert_eq!(data.get("/v1/blobs/sha256:zzzz").status, 400);
}
#[test]
fn asset_publication_manifest_alias_thumbnail() {
let ts = start_server("publish");
let token = ts.admin_token();
let mut admin = ts.control(Some(&token));
let publisher = principal_with(
&mut admin,
&[
("blob_write", "demo"),
("asset_register", "demo"),
("asset_publish", "demo"),
("alias_write", "demo"),
],
);
let mut control = ts.control(Some(&publisher));
let mut data = ts.data(Some(&publisher));
let glb = b"glTF-not-really-but-bytes".to_vec();
let thumb = b"\x89PNG-thumb-bytes".to_vec();
for b in [&glb, &thumb] {
assert_eq!(data.post_bytes("/v1/blobs?ns=demo", b).status, 201);
}
// Register + stage; the revision id is the canonical manifest digest.
let r = control.post_json("/v1/assets", &jobj(vec![("namespace", jstr("demo"))]));
assert_eq!(r.status, 201);
let asset_id = r.str_field("asset_id");
let ast: AssetId = asset_id.parse().unwrap();
let manifest_bytes = prop_manifest(ast, &glb, &thumb).to_canonical_bytes().unwrap();
let expect_rev = AssetRevisionId::hash_of(&manifest_bytes);
let r = control.post_bytes(&format!("/v1/assets/{asset_id}/revisions"), &manifest_bytes);
assert_eq!(r.status, 201, "{}", String::from_utf8_lossy(&r.body));
let revision = r.str_field("revision");
assert_eq!(revision, expect_rev.to_string());
// Candidate state reads back.
let r = control.get(&format!("/v1/assets/{asset_id}/revisions/{revision}"));
assert_eq!(r.str_field("state"), "staged");
// Staging under a different asset id is a consistency refusal.
let other = control.post_json("/v1/assets", &jobj(vec![("namespace", jstr("demo"))]));
let other_id = other.str_field("asset_id");
let r = control.post_bytes(&format!("/v1/assets/{other_id}/revisions"), &manifest_bytes);
assert_eq!(r.status, 409);
// Manifest raw bytes round-trip with immutable caching semantics.
let r = control.get(&format!("/v1/revisions/{revision}"));
assert_eq!(r.status, 200);
assert_eq!(r.body, manifest_bytes);
let etag = r.header("ETag").unwrap().to_string();
let r = control.request(
"GET",
&format!("/v1/revisions/{revision}"),
&[("If-None-Match", &etag)],
None,
);
assert_eq!(r.status, 304);
// And the browsing projection.
let r = control.get(&format!("/v1/revisions/{revision}/json"));
assert_eq!(r.status, 200);
let v = r.json();
assert_eq!(v.get("kind").unwrap().as_str(), Some("prop"));
assert_eq!(v.get("asset_id").unwrap().as_str(), Some(asset_id.as_str()));
// Aliases may only point at published revisions.
let alias_target = jobj(vec![
("asset_id", jstr(asset_id.clone())),
("revision", jstr(revision.clone())),
]);
let r = control.put_json("/v1/aliases/demo/props/crate", &alias_target);
assert_eq!(r.status, 409, "alias to staged must refuse");
let r = control.post_json(
&format!("/v1/assets/{asset_id}/revisions/{revision}/publish"),
&jobj(vec![]),
);
assert_eq!(r.status, 200);
assert_eq!(r.str_field("state"), "published");
// Publish is idempotent.
let r = control.post_json(
&format!("/v1/assets/{asset_id}/revisions/{revision}/publish"),
&jobj(vec![]),
);
assert_eq!(r.status, 200);
let r = control.put_json("/v1/aliases/demo/props/crate", &alias_target);
assert_eq!(r.status, 200);
let r = control.get("/v1/aliases/demo/props/crate");
assert_eq!(r.status, 200);
assert_eq!(r.str_field("asset_id"), asset_id);
assert_eq!(r.str_field("head_revision"), revision);
// Thumbnails serve the manifest's typed thumbnail with its own ETag.
let r = data.get("/v1/thumbnails/alias/demo/props/crate");
assert_eq!(r.status, 200);
assert_eq!(r.body, thumb);
assert_eq!(r.header("Content-Type"), Some("image/png"));
let r = data.get(&format!("/v1/thumbnails/revision/{revision}"));
assert_eq!(r.status, 200);
assert_eq!(r.body, thumb);
let r = data.request(
"GET",
&format!("/v1/thumbnails/revision/{revision}"),
&[("Range", "bytes=0-3")],
None,
);
assert_eq!(r.status, 206);
assert_eq!(r.body, thumb[0..4]);
// Alias delete reports whether it existed.
let r = control.delete("/v1/aliases/demo/props/crate");
assert_eq!(r.json().get("existed").unwrap().as_bool(), Some(true));
let r = control.delete("/v1/aliases/demo/props/crate");
assert_eq!(r.json().get("existed").unwrap().as_bool(), Some(false));
}
#[test]
fn data_source_publishes_without_thumbnail_or_mesh() {
let ts = start_server("publish_data");
let token = ts.admin_token();
let mut control = ts.control(Some(&token));
let mut data = ts.data(Some(&token));
let source = b"dataset: city-boundaries-v1\nformat: geojson\n";
assert_eq!(data.post_bytes("/v1/blobs?ns=demo", source).status, 201);
let r = control.post_json("/v1/assets", &jobj(vec![("namespace", jstr("demo"))]));
assert_eq!(r.status, 201);
let asset_id = r.str_field("asset_id");
let asset: AssetId = asset_id.parse().unwrap();
let manifest = data_manifest(asset, source);
assert!(!manifest.kind.has_mesh());
assert!(manifest.thumbnail.is_none());
let bytes = manifest.to_canonical_bytes().expect("Source/Text-only Data manifest validates");
let r = control.post_bytes(&format!("/v1/assets/{asset_id}/revisions"), &bytes);
assert_eq!(r.status, 201, "{}", String::from_utf8_lossy(&r.body));
let revision = r.str_field("revision");
let r = control.post_json(
&format!("/v1/assets/{asset_id}/revisions/{revision}/publish"),
&jobj(vec![]),
);
assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
let r = control.get(&format!("/v1/revisions/{revision}/json"));
assert_eq!(r.status, 200);
assert_eq!(r.json().get("kind").and_then(Value::as_str), Some("data"));
assert_eq!(data.get(&format!("/v1/thumbnails/revision/{revision}")).status, 404);
let annotation = jobj(vec![("title", jstr("City boundaries")), ("kind", jstr("data"))]);
assert_eq!(
control.put_json(&format!("/v1/assets/{asset_id}/annotation"), &annotation).status,
204
);
let r = control.get("/v1/search?ns=demo&kind=data");
assert_eq!(r.status, 200);
let page = r.json();
assert_eq!(page.get("total").and_then(Value::as_i64), Some(1));
let hit = &page.get("hits").and_then(Value::as_arr).unwrap()[0];
assert_eq!(hit.get("kind").and_then(Value::as_str), Some("data"));
}
#[test]
fn quarantine_pulls_content_transactionally() {
let ts = start_server("quarantine");
let token = ts.admin_token();
let mut admin = ts.control(Some(&token));
let publisher = principal_with(
&mut admin,
&[
("blob_write", "demo"),
("asset_register", "demo"),
("asset_publish", "demo"),
("asset_quarantine", "demo"),
("alias_write", "demo"),
],
);
let mut control = ts.control(Some(&publisher));
let mut data = ts.data(Some(&publisher));
let (asset_id, revision) =
publish_prop_http(&mut control, &mut data, "demo", "demo/props/pulled", b"glb-q", b"thumb-q");
assert_eq!(control.get("/v1/aliases/demo/props/pulled").status, 200);
let r = control.post_json(
&format!("/v1/assets/{asset_id}/revisions/{revision}/quarantine"),
&jobj(vec![]),
);
assert_eq!(r.status, 200);
assert_eq!(r.str_field("state"), "quarantined");
// The alias head dropped in the same transaction; revision-addressed
// reads refuse; the candidate row says why.
assert_eq!(control.get("/v1/aliases/demo/props/pulled").status, 404);
assert_eq!(control.get(&format!("/v1/revisions/{revision}")).status, 404);
assert_eq!(control.get(&format!("/v1/revisions/{revision}/json")).status, 404);
assert_eq!(data.get(&format!("/v1/thumbnails/revision/{revision}")).status, 404);
let r = control.get(&format!("/v1/assets/{asset_id}/revisions/{revision}"));
assert_eq!(r.str_field("state"), "quarantined");
// Quarantine is terminal: publish refuses, re-staging refuses.
let r = control.post_json(
&format!("/v1/assets/{asset_id}/revisions/{revision}/publish"),
&jobj(vec![]),
);
assert_eq!(r.status, 409);
let manifest_bytes = prop_manifest(asset_id.parse().unwrap(), b"glb-q", b"thumb-q")
.to_canonical_bytes()
.unwrap();
let r = control.post_bytes(&format!("/v1/assets/{asset_id}/revisions"), &manifest_bytes);
assert_eq!(r.status, 409);
}
#[test]
fn game_lifecycle_refs_and_aliases() {
let ts = start_server("games");
let token = ts.admin_token();
let mut admin = ts.control(Some(&token));
let publisher = principal_with(
&mut admin,
&[
("blob_write", "demo"),
("asset_register", "demo"),
("asset_publish", "demo"),
("alias_write", "demo"),
("game_register", "demo"),
("game_publish", "demo"),
],
);
let mut control = ts.control(Some(&publisher));
let mut data = ts.data(Some(&publisher));
// Two published assets to pin.
let (a1, r1) = publish_prop_http(&mut control, &mut data, "demo", "demo/props/one", b"glb1", b"th1");
let (a2, r2) = publish_prop_http(&mut control, &mut data, "demo", "demo/props/two", b"glb2", b"th2");
let ref1 = AssetRevisionRef { asset_id: a1.parse().unwrap(), revision: r1.parse().unwrap() };
let ref2 = AssetRevisionRef { asset_id: a2.parse().unwrap(), revision: r2.parse().unwrap() };
// Game blobs + canonical documents.
let splash = b"splash-source".to_vec();
let toml = b"[game]\nname=\"t\"".to_vec();
let thumb = b"png-game-thumb".to_vec();
let r = control.post_json("/v1/games", &jobj(vec![("namespace", jstr("demo"))]));
assert_eq!(r.status, 201);
let game_id = r.str_field("game_id");
let gam: GameId = game_id.parse().unwrap();
let lock = lock_for(gam, &[("demo/props/one", ref1), ("demo/props/two", ref2)]);
for b in [&splash, &toml, &lock, &thumb] {
assert_eq!(data.post_bytes("/v1/blobs?ns=demo", b).status, 201);
}
let manifest = game_manifest(gam, &splash, &toml, &lock, &thumb)
.to_canonical_bytes()
.unwrap();
// Malformed framing refuses before touching the catalog.
let r = control.post_bytes(&format!("/v1/games/{game_id}/revisions"), b"tooshort");
assert_eq!(r.status, 400);
let r = control.post_bytes(&format!("/v1/games/{game_id}/revisions"), &framed(&manifest, &lock));
assert_eq!(r.status, 201, "{}", String::from_utf8_lossy(&r.body));
let revision = r.str_field("revision");
// The pinned refs read back in canonical order.
let r = control.get(&format!("/v1/games/{game_id}/revisions/{revision}/refs"));
assert_eq!(r.status, 200);
let refs = r.json();
let refs = refs.get("refs").unwrap().as_arr().unwrap().to_vec();
assert_eq!(refs.len(), 2);
let got: Vec<String> = refs
.iter()
.map(|v| v.get("asset_id").unwrap().as_str().unwrap().to_string())
.collect();
// Canonical ref order is by asset-id BYTES; display strings are base32
// and do not sort identically, so compare through the parsed ids.
let mut want: Vec<AssetId> = vec![a1.parse().unwrap(), a2.parse().unwrap()];
want.sort();
let want: Vec<String> = want.iter().map(|a| a.to_string()).collect();
assert_eq!(got, want, "refs come back sorted by asset id bytes");
// Publish, then alias.
let r = control.post_json(
&format!("/v1/games/{game_id}/revisions/{revision}/publish"),
&jobj(vec![]),
);
assert_eq!(r.status, 200);
let r = control.put_json(
"/v1/game-aliases/demo/games/arena",
&jobj(vec![
("game_id", jstr(game_id.clone())),
("revision", jstr(revision.clone())),
]),
);
assert_eq!(r.status, 200);
let r = control.get("/v1/game-aliases/demo/games/arena");
assert_eq!(r.str_field("game_id"), game_id);
assert_eq!(r.str_field("head_revision"), revision);
let r = control.delete("/v1/game-aliases/demo/games/arena");
assert_eq!(r.json().get("existed").unwrap().as_bool(), Some(true));
// A lock pinning an unpublished revision refuses at stage time.
let r = control.post_json("/v1/games", &jobj(vec![("namespace", jstr("demo"))]));
let game2 = r.str_field("game_id");
let gam2: GameId = game2.parse().unwrap();
let manifest_bytes2 = prop_manifest(AssetId::from_bytes([9u8; 16]), b"glb3", b"th3");
// (staged but never published)
assert_eq!(data.post_bytes("/v1/blobs?ns=demo", b"glb3").status, 201);
assert_eq!(data.post_bytes("/v1/blobs?ns=demo", b"th3").status, 201);
let r = control.post_json(
"/v1/assets",
&jobj(vec![
("namespace", jstr("demo")),
("asset_id", jstr(AssetId::from_bytes([9u8; 16]).to_string())),
]),
);
assert_eq!(r.status, 201);
let mb = manifest_bytes2.to_canonical_bytes().unwrap();
let staged_rev = control
.post_bytes(&format!("/v1/assets/{}/revisions", AssetId::from_bytes([9u8; 16])), &mb)
.str_field("revision");
let bad_ref = AssetRevisionRef {
asset_id: AssetId::from_bytes([9u8; 16]),
revision: staged_rev.parse().unwrap(),
};
let lock2 = lock_for(gam2, &[("demo/props/three", bad_ref)]);
let manifest2 = game_manifest(gam2, &splash, &toml, &lock2, &thumb)
.to_canonical_bytes()
.unwrap();
assert_eq!(data.post_bytes("/v1/blobs?ns=demo", &lock2).status, 201);
let r = control.post_bytes(&format!("/v1/games/{game2}/revisions"), &framed(&manifest2, &lock2));
assert_eq!(r.status, 409, "{}", String::from_utf8_lossy(&r.body));
}
#[test]
fn search_annotations_visibility_and_cursors() {
let ts = start_server("search");
let token = ts.admin_token();
let mut admin = ts.control(Some(&token));
let owner_token = principal_with(
&mut admin,
&[
("blob_write", "demo"),
("asset_register", "demo"),
("asset_publish", "demo"),
("alias_write", "demo"),
],
);
let other_token = principal_with(&mut admin, &[("asset_register", "demo")]);
let mut owner = ts.control(Some(&owner_token));
let mut other = ts.control(Some(&other_token));
let mut data = ts.data(Some(&owner_token));
let (asset_id, _rev) =
publish_prop_http(&mut owner, &mut data, "demo", "demo/props/launcher", b"glb-s", b"th-s");
// Private annotation with an owner-only prompt.
let ann = jobj(vec![
("title", jstr("Rocket Launcher")),
("description", jstr("A fancy launcher of rockets")),
("kind", jstr("prop")),
("tags", Value::Arr(vec![jstr("weapon"), jstr("rocket")])),
("prompt", jstr("zebra striped launcher concept")),
("visibility", jstr("private")),
]);
assert_eq!(owner.put_json(&format!("/v1/assets/{asset_id}/annotation"), &ann).status, 204);
// Owner finds it; another principal sees nothing at all.
let r = owner.get("/v1/search?q=rocket");
assert_eq!(r.status, 200);
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(1));
let hit = r.json().get("hits").unwrap().as_arr().unwrap()[0].clone();
assert_eq!(hit.get("title").unwrap().as_str(), Some("Rocket Launcher"));
assert_eq!(hit.get("live").unwrap().as_bool(), Some(true));
let r = other.get("/v1/search?q=rocket");
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(0));
// The private annotation read is indistinguishable from absence.
assert_eq!(other.get(&format!("/v1/assets/{asset_id}/annotation")).status, 404);
let r = owner.get(&format!("/v1/assets/{asset_id}/annotation"));
assert_eq!(r.status, 200);
assert_eq!(r.str_field("prompt"), "zebra striped launcher concept");
// Public visibility: found by others, but prompt terms and the prompt
// field itself stay owner-only.
let mut public_ann = ann.clone();
if let Value::Obj(pairs) = &mut public_ann {
for (k, v) in pairs.iter_mut() {
if k == "visibility" {
*v = jstr("public");
}
}
}
assert_eq!(owner.put_json(&format!("/v1/assets/{asset_id}/annotation"), &public_ann).status, 204);
let r = other.get("/v1/search?q=rocket");
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(1));
assert_eq!(other.get("/v1/search?q=zebra").json().get("total").unwrap().as_i64(), Some(0));
assert_eq!(owner.get("/v1/search?q=zebra").json().get("total").unwrap().as_i64(), Some(1));
let r = other.get(&format!("/v1/assets/{asset_id}/annotation"));
assert_eq!(r.status, 200);
assert!(r.json().get("prompt").is_none(), "prompt is owner-only");
assert!(r.json().get("owner").is_none());
// Only the owner (or root) may replace or delete an owned annotation.
assert_eq!(other.put_json(&format!("/v1/assets/{asset_id}/annotation"), &public_ann).status, 403);
assert_eq!(other.delete(&format!("/v1/assets/{asset_id}/annotation")).status, 403);
// POST search accepts free text; browse mode lists by filters only.
let r = owner.post_json("/v1/catalog", &jobj(vec![("q", jstr("rocket launcher"))]));
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(1));
let r = owner.get("/v1/search?ns=demo&kind=prop");
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(1));
let r = owner.get("/v1/search?tag=rocket");
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(1));
let r = owner.get("/v1/search?ns=elsewhere");
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(0));
// Server-side tag exclusion, same wire shape as `tag` on both forms.
// The asset carries tags [weapon, rocket].
for (tag, total) in [("rocket", 0i64), ("weapon", 0), ("nosuch", 1)] {
let r = owner.post_json(
"/v1/catalog",
&jobj(vec![("q", jstr("rocket launcher")), ("exclude_tag", jstr(tag))]),
);
assert_eq!(r.status, 200, "exclude_tag {tag}");
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(total), "exclude_tag {tag}");
}
// The exclusion beats a matching positive tag on the same request.
let r = owner.post_json(
"/v1/catalog",
&jobj(vec![
("tag", jstr("weapon")),
("exclude_tag", jstr("rocket")),
]),
);
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(0));
let r = owner.get("/v1/search?tag=weapon&exclude_tag=rocket");
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(0));
let r = owner.get("/v1/search?exclude_tag=nosuch");
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(1));
// Facets travel on both request forms and are absent unless asked for.
// The asset carries kind `prop` and tags [weapon, rocket].
let r = owner.get("/v1/search?q=rocket");
assert_eq!(
r.json().get("facets").unwrap().as_arr().map(<[Value]>::len),
Some(0),
"no facets unless the query asks"
);
let r = owner.get("/v1/search?q=rocket&facets=8");
let facets = r.json().get("facets").unwrap().as_arr().unwrap().to_vec();
let mut seen: Vec<(String, String, i64)> = facets
.iter()
.map(|f| {
(
f.get("kind").unwrap().as_str().unwrap().to_string(),
f.get("label").unwrap().as_str().unwrap().to_string(),
f.get("count").unwrap().as_i64().unwrap(),
)
})
.collect();
seen.sort();
assert_eq!(
seen,
vec![
("tag".to_string(), "rocket".to_string(), 1),
("tag".to_string(), "weapon".to_string(), 1),
]
);
let r = owner.post_json(
"/v1/catalog",
&jobj(vec![("q", jstr("rocket launcher")), ("facets", Value::Int(8))]),
);
assert_eq!(r.json().get("facets").unwrap().as_arr().map(<[Value]>::len), Some(2));
// A facet count over the budget is clamped, not refused, exactly like
// `limit`; a malformed one is a 400.
assert_eq!(owner.get("/v1/search?q=rocket&facets=100000").status, 200);
assert_eq!(
owner
.post_json(
"/v1/catalog",
&jobj(vec![("q", jstr("rocket")), ("facets", jstr("many"))])
)
.status,
400
);
// Bad values refuse with 400, byte-for-byte like a bad `tag`.
for bad in ["", "Bad Tag", "bad\u{7}"] {
let a = owner.post_json("/v1/catalog", &jobj(vec![("tag", jstr(bad))]));
let b = owner.post_json("/v1/catalog", &jobj(vec![("exclude_tag", jstr(bad))]));
assert_eq!((a.status, b.status), (400, 400), "tag/exclude_tag {bad:?}");
}
// A non-string value is a malformed search field.
let r = owner.post_json("/v1/catalog", &jobj(vec![("exclude_tag", Value::Int(1))]));
assert_eq!(r.status, 400);
// Keyset pagination: three annotated assets, one per page, cursor bound
// to the exact query shape.
for i in 0..2u8 {
let (aid, _)= publish_prop_http(
&mut owner,
&mut data,
"demo",
&format!("demo/props/extra-{i}"),
format!("glb-x{i}").as_bytes(),
format!("th-x{i}").as_bytes(),
);
let a = jobj(vec![("title", jstr(format!("Extra {i}"))), ("visibility", jstr("public"))]);
assert_eq!(owner.put_json(&format!("/v1/assets/{aid}/annotation"), &a).status, 204);
}
let mut seen = Vec::new();
let mut cursor: Option<String> = None;
loop {
let target = match &cursor {
None => "/v1/search?ns=demo&limit=1".to_string(),
Some(c) => format!("/v1/search?ns=demo&limit=1&cursor={c}"),
};
let r = owner.get(&target);
assert_eq!(r.status, 200);
let v = r.json();
for h in v.get("hits").unwrap().as_arr().unwrap() {
seen.push(h.get("asset_id").unwrap().as_str().unwrap().to_string());
}
match v.get("cursor").unwrap().as_str() {
Some(c) => cursor = Some(c.to_string()),
None => break,
}
}
assert_eq!(seen.len(), 3);
// A cursor replayed against a different query shape is stale.
let probe = cursor_probe(&mut owner);
let stale = owner.get(&format!("/v1/search?limit=1&cursor={probe}"));
assert_eq!(stale.status, 400);
}
/// Over the wire, `q=` widens and `exact=1` does not — with annotations
/// shaped like the ones the live catalog holds.
#[test]
fn search_expands_synonyms_unless_the_request_asks_for_exact_words() {
let ts = start_server("search_synonyms");
let token = ts.admin_token();
let mut admin = ts.control(Some(&token));
let owner_token = principal_with(
&mut admin,
&[
("blob_write", "demo"),
("asset_register", "demo"),
("asset_publish", "demo"),
("alias_write", "demo"),
],
);
let mut owner = ts.control(Some(&owner_token));
let mut data = ts.data(Some(&owner_token));
for (i, (title, description)) in [
("Dog", "dog pet; standalone; 1x1; brown/grey; cube-shaped dog with floppy ears"),
("Race Car", "race car vehicle; standalone; 2x1; red; sports styling"),
("Blaster Rifle", "blaster rifle weapon; standalone; handheld; grey"),
]
.iter()
.enumerate()
{
let (asset_id, _rev) = publish_prop_http(
&mut owner,
&mut data,
"demo",
&format!("demo/props/syn-{i}"),
format!("glb-syn{i}").as_bytes(),
format!("th-syn{i}").as_bytes(),
);
let ann = jobj(vec![
("title", jstr(*title)),
("description", jstr(*description)),
("kind", jstr("prop")),
("visibility", jstr("public")),
]);
assert_eq!(owner.put_json(&format!("/v1/assets/{asset_id}/annotation"), &ann).status, 204);
}
let total = |c: &mut Client, target: &str| -> i64 {
let r = c.get(target);
assert_eq!(r.status, 200, "{target}");
r.json().get("total").unwrap().as_i64().unwrap()
};
// The words nothing in the catalog says.
assert_eq!(total(&mut owner, "/v1/search?q=puppy"), 1);
assert_eq!(total(&mut owner, "/v1/search?q=automobile"), 1);
assert_eq!(total(&mut owner, "/v1/search?q=gun"), 1);
// The `-` join is still a conjunction, each term through its own group.
assert_eq!(total(&mut owner, "/v1/search?q=red-sports-automobile"), 1);
assert_eq!(total(&mut owner, "/v1/search?q=puppy-automobile"), 0);
// `exact=1` is the escape hatch, on GET and on POST.
assert_eq!(total(&mut owner, "/v1/search?q=puppy&exact=1"), 0);
assert_eq!(total(&mut owner, "/v1/search?q=dog&exact=1"), 1);
let r = owner.post_json(
"/v1/catalog",
&jobj(vec![("q", jstr("puppy")), ("exact", Value::Bool(true))]),
);
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(0));
let r = owner.post_json("/v1/catalog", &jobj(vec![("q", jstr("puppy"))]));
assert_eq!(r.json().get("total").unwrap().as_i64(), Some(1));
// A malformed flag refuses like every other flag.
assert_eq!(owner.get("/v1/search?q=dog&exact=maybe").status, 400);
assert_eq!(
owner
.post_json("/v1/catalog", &jobj(vec![("q", jstr("dog")), ("exact", Value::Int(1))]))
.status,
400
);
}
/// Fetch a fresh cursor for one query shape (used to prove shape binding).
fn cursor_probe(owner: &mut Client) -> String {
let r = owner.get("/v1/search?ns=demo&limit=1");
r.json().get("cursor").unwrap().as_str().unwrap().to_string()
}
#[test]
fn discovery_beacon_and_listener() {
// Listener first: its ephemeral port becomes the beacon target.
let mut listener = DiscoveryListener::start(0, 10_000).expect("listener");
let port = listener.port();
let ts = start_server_with("discovery", |cfg| {
cfg.discovery = Some(DiscoveryConfig {
port,
target_ip: "127.0.0.1".parse().unwrap(),
interval_ms: 50,
capability_bits: makepad_asset_store::discovery::caps::ALL_V1,
});
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let found = loop {
let now = makepad_asset_store::util::now_ms();
let snap = listener.snapshot(now);
if let Some(srv) = snap.iter().find(|s| s.server_id == ts.server.server_id()) {
break srv.clone();
}
assert!(std::time::Instant::now() < deadline, "no beacon received");
std::thread::sleep(std::time::Duration::from_millis(20));
};
// Endpoints derive from the beacon ports and the SENDER address.
assert_eq!(found.control_port, ts.server.control_addr().port());
assert_eq!(found.data_port, ts.server.data_addr().port());
assert!(found.auth_required);
assert_eq!(found.ip.to_string(), "127.0.0.1");
// The discovered endpoint answers health.
let mut probe = Client::new(found.control_endpoint(), None);
assert_eq!(probe.get("/v1/health").status, 200);
listener.stop();
}
#[test]
fn shutdown_restart_and_root_lock() {
let ts = start_server("restart");
let token = ts.admin_token();
let mut admin = ts.control(Some(&token));
let publisher = principal_with(
&mut admin,
&[
("blob_write", "demo"),
("asset_register", "demo"),
("asset_publish", "demo"),
("alias_write", "demo"),
],
);
let mut control = ts.control(Some(&publisher));
let mut data = ts.data(Some(&publisher));
let (asset_id, revision) =
publish_prop_http(&mut control, &mut data, "demo", "demo/props/durable", b"glb-r", b"th-r");
// A second server on the SAME root must refuse while this one lives.
let mut cfg = base_config(ts.root.clone());
cfg.bootstrap_admin = false;
assert!(AssetServer::start(cfg).is_err(), "root lock must exclude a second server");
let control_addr = ts.server.control_addr();
let root = ts.root.clone();
drop(ts.server);
// All ports are really closed after shutdown.
assert!(std::net::TcpStream::connect(control_addr).is_err());
// Restart on the same root: durable state (catalog, alias, CAS, tokens)
// is intact and the admin token file still authenticates.
let mut cfg = base_config(root);
cfg.bootstrap_admin = true;
let server2 = AssetServer::start(cfg).expect("restart");
let mut c2 = Client::new(server2.control_addr(), Some(&publisher));
let r = c2.get("/v1/aliases/demo/props/durable");
assert_eq!(r.status, 200);
assert_eq!(r.str_field("asset_id"), asset_id);
assert_eq!(r.str_field("head_revision"), revision);
let mut a2 = Client::new(server2.control_addr(), Some(&token));
assert_eq!(a2.get("/v1/auth/whoami").status, 200);
}