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>
910 lines
37 KiB
Rust
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);
|
|
}
|