makepad/libs/asset/store/tests/imports.rs
Admin 8436f955d6 Land the Asset Server protocol stack from rik2.
data, client, store, and chat — the contract Asset UI and VJ speak.
2026-08-18 14:23:59 +02:00

356 lines
14 KiB
Rust

//! Deterministic external-pack import: approved sources, the atomic import
//! transaction, idempotent replay, cross-server determinism, and fail-closed
//! refusals that leave no partial state behind.
mod common;
use common::*;
use makepad_asset_store::*;
use makepad_asset_data::*;
#[test]
fn source_registration_is_idempotent_and_approval_is_not_rewritable() {
let (_root, core) = open_core("import_sources");
let bytes = kenney_collection().to_canonical_bytes().unwrap();
let digest = core.imports().register_source(&bytes, NOW).unwrap();
// Same bytes: idempotent.
assert_eq!(core.imports().register_source(&bytes, NOW + 1).unwrap(), digest);
assert_eq!(core.imports().sources().unwrap(), vec![bytes.clone()]);
assert_eq!(core.imports().source_manifest("kenney").unwrap(), Some(bytes));
// A DIFFERENT collection under the same id refuses: approval is explicit.
let mut changed = kenney_collection();
changed.title = "Kenney assets, rebranded".into();
let changed_bytes = changed.to_canonical_bytes().unwrap();
assert!(matches!(
core.imports().register_source(&changed_bytes, NOW + 2),
Err(ServerError::Conflict { what: "source collection digest" })
));
// Garbage bytes refuse through the content contract.
assert!(matches!(
core.imports().register_source(b"junk", NOW),
Err(ServerError::Content(_))
));
}
#[test]
fn import_publishes_assets_aliases_and_entries_atomically() {
let (root, core) = open_core("import_run");
let generation_before = {
// Force schema/state creation before the raw side-channel read.
core.catalog().register_asset(&asset_id_n(9), "warmup", NOW).unwrap();
read_generation(&root.join("catalog.sqlite3"))
};
let report = run_kenney_import(&core, "1.0", NOW);
assert!(report.created);
assert_eq!(report.entries.len(), 2);
let manifest = kenney_pack("1.0");
let import_rev = manifest.revision().unwrap();
assert_eq!(report.import_revision, import_rev);
// Every entry: registered in the source namespace, published, aliased.
for (asset, entry) in manifest.assets.iter().zip(report.entries.iter()) {
assert_eq!(entry.key, asset.key.as_str());
assert_eq!(entry.asset_id, manifest.asset_id_for(&asset.key));
assert_eq!(
core.catalog().asset_namespace(&entry.asset_id).unwrap().as_deref(),
Some("kenney")
);
assert_eq!(
core.catalog()
.asset_candidate_state(&entry.asset_id, &entry.revision)
.unwrap(),
Some(CandidateState::Published)
);
let alias = manifest.alias_for(&asset.key).unwrap();
assert_eq!(
core.catalog().resolve_asset_alias(&alias).unwrap(),
Some(AssetRevisionRef {
asset_id: entry.asset_id,
revision: entry.revision,
})
);
// The stored manifest pins the exact import lineage.
let stored = core
.catalog()
.asset_revision_manifest(&entry.revision)
.unwrap()
.unwrap();
let decoded = AssetManifest::from_canonical_bytes(&stored).unwrap();
let prov = decoded.provenance.unwrap();
assert_eq!(prov.generator, "import");
assert_eq!(prov.params_digest, Some(*import_rev.as_bytes()));
assert_eq!(decoded.rights.license, "CC0-1.0");
assert!(decoded.rights.credits.contains("Kenney"));
}
// Entry rows are recorded and readable.
assert_eq!(core.imports().entries(&import_rev).unwrap(), report.entries);
// Alias writes went through the search choke point: generation advanced.
assert!(read_generation(&root.join("catalog.sqlite3")) > generation_before);
}
#[test]
fn import_replay_is_idempotent_and_two_servers_agree() {
let (_root_a, a) = open_core("import_det_a");
let (_root_b, b) = open_core("import_det_b");
let ra1 = run_kenney_import(&a, "1.0", NOW);
let ra2 = run_kenney_import(&a, "1.0", NOW + 500);
let rb = run_kenney_import(&b, "1.0", NOW + 999);
assert!(ra1.created);
// Replay on the same server: recorded result, no new work.
assert!(!ra2.created);
assert_eq!(ra1.import_revision, ra2.import_revision);
assert_eq!(ra1.entries, ra2.entries);
// A clean second server produces byte-identical identities.
assert!(rb.created);
assert_eq!(ra1.import_revision, rb.import_revision);
assert_eq!(ra1.entries, rb.entries);
for entry in &ra1.entries {
assert_eq!(
a.catalog().asset_revision_manifest(&entry.revision).unwrap(),
b.catalog().asset_revision_manifest(&entry.revision).unwrap(),
);
}
}
#[test]
fn a_new_pack_version_revises_the_same_assets() {
let (_root, core) = open_core("import_version");
let v1 = run_kenney_import(&core, "1.0", NOW);
let mut pack2 = kenney_pack("2.0");
// v2 ships a bigger watchtower mesh.
let new_glb = b"KENNEY-WATCHTOWER-GLB-v2-BIGGER";
core.put_blob(new_glb, NOW + 1).unwrap();
let watchtower = pack2
.assets
.iter_mut()
.find(|a| a.key.as_str() == "models/watchtower")
.unwrap();
watchtower.files[0].file.blob = BlobId::hash_of(new_glb);
watchtower.files[0].file.byte_len = new_glb.len() as u64;
watchtower.metrics.total_bytes =
(new_glb.len() + PACK_COLLIDER.len() + PACK_PREVIEW.len()) as u64;
let v2 = core
.imports()
.run_import(&pack2.to_canonical_bytes().unwrap(), NOW + 2)
.unwrap();
assert_ne!(v1.import_revision, v2.import_revision);
for (e1, e2) in v1.entries.iter().zip(v2.entries.iter()) {
// Same stable asset identity across versions; every entry gains a
// NEW revision because its provenance pins the new import revision
// (a changed pack creates new asset revisions — the plan's law).
assert_eq!(e1.asset_id, e2.asset_id);
assert_ne!(e1.revision, e2.revision);
// Prior revisions are never edited or unpublished by a later import.
assert_eq!(
core.catalog()
.asset_candidate_state(&e1.asset_id, &e1.revision)
.unwrap(),
Some(CandidateState::Published)
);
}
// Unchanged source bytes deduped at the blob layer: the v2 hull texture
// manifest names the exact v1 blob.
let stored = core
.catalog()
.asset_revision_manifest(&v2.entries[1].revision)
.unwrap()
.unwrap();
let decoded = AssetManifest::from_canonical_bytes(&stored).unwrap();
assert_eq!(decoded.files[0].blob, BlobId::hash_of(PACK_TEXTURE));
// The alias head advanced to the new revision.
let alias = pack2.alias_for(&pack2.assets[0].key).unwrap();
assert_eq!(
core.catalog().resolve_asset_alias(&alias).unwrap().unwrap().revision,
v2.entries[0].revision
);
}
#[test]
fn failed_import_publishes_nothing() {
let (_root, core) = open_core("import_atomic");
// Register the source but DO NOT upload the texture blob: the second
// asset's admission must fail after the first asset already staged.
for bytes in [PACK_GLB, PACK_COLLIDER, PACK_PREVIEW] {
core.put_blob(bytes, NOW).unwrap();
}
let collection_bytes = kenney_collection().to_canonical_bytes().unwrap();
core.imports().register_source(&collection_bytes, NOW).unwrap();
let manifest = kenney_pack("1.0");
let manifest_bytes = manifest.to_canonical_bytes().unwrap();
assert!(matches!(
core.imports().run_import(&manifest_bytes, NOW),
Err(ServerError::NotFound { what: "asset file blob" })
));
// NOTHING is visible: no import row, no entries, no assets, no aliases.
let import_rev = manifest.revision().unwrap();
assert_eq!(core.imports().import_manifest_bytes(&import_rev).unwrap(), None);
assert_eq!(core.imports().entries(&import_rev).unwrap(), vec![]);
for asset in &manifest.assets {
let id = manifest.asset_id_for(&asset.key);
assert_eq!(core.catalog().asset_namespace(&id).unwrap(), None);
let alias = manifest.alias_for(&asset.key).unwrap();
assert_eq!(core.catalog().resolve_asset_alias(&alias).unwrap(), None);
}
// After the missing blob arrives, the same manifest imports cleanly.
core.put_blob(PACK_TEXTURE, NOW + 1).unwrap();
let report = core.imports().run_import(&manifest_bytes, NOW + 1).unwrap();
assert!(report.created);
}
#[test]
fn import_refuses_unapproved_or_divergent_sources() {
let (_root, core) = open_core("import_sources_gate");
for bytes in [PACK_GLB, PACK_COLLIDER, PACK_PREVIEW, PACK_TEXTURE] {
core.put_blob(bytes, NOW).unwrap();
}
let manifest_bytes = kenney_pack("1.0").to_canonical_bytes().unwrap();
// No registered source at all.
assert!(matches!(
core.imports().run_import(&manifest_bytes, NOW),
Err(ServerError::NotFound { what: "source collection" })
));
// A registered collection with the same id but different content: the
// manifest's pinned digest no longer matches the approval.
let mut other = kenney_collection();
other.title = "Different approval".into();
core.imports()
.register_source(&other.to_canonical_bytes().unwrap(), NOW)
.unwrap();
assert!(matches!(
core.imports().run_import(&manifest_bytes, NOW),
Err(ServerError::Conflict { what: "source collection digest" })
));
}
#[test]
fn import_never_resurrects_quarantined_content() {
let (_root, core) = open_core("import_quarantine");
// Compute the exact revision the import WOULD produce for the
// watchtower (pure content math), publish it through the ordinary
// catalog path, then quarantine it — before any import has run.
let manifest = kenney_pack("1.0");
let import_rev = manifest.revision().unwrap();
let watchtower = &manifest.assets[0];
let produced = manifest
.asset_manifest_for(watchtower, &import_rev)
.unwrap();
let produced_bytes = produced.to_canonical_bytes().unwrap();
for bytes in [PACK_GLB, PACK_COLLIDER, PACK_PREVIEW, PACK_TEXTURE] {
core.put_blob(bytes, NOW).unwrap();
}
core.catalog()
.register_asset(&produced.asset_id, "kenney", NOW)
.unwrap();
let revision = core
.catalog()
.stage_asset_revision(&produced_bytes, NOW)
.unwrap();
core.catalog().publish_asset(&produced.asset_id, &revision, NOW).unwrap();
core.catalog()
.quarantine_asset(&produced.asset_id, &revision, NOW + 1)
.unwrap();
// The import now refuses whole rather than resurrecting pulled content —
// and atomicity means the OTHER entry publishes nothing either.
core.imports()
.register_source(&kenney_collection().to_canonical_bytes().unwrap(), NOW + 2)
.unwrap();
let manifest_bytes = manifest.to_canonical_bytes().unwrap();
assert!(matches!(
core.imports().run_import(&manifest_bytes, NOW + 2),
Err(ServerError::InvalidState { what: "imported revision", state: "quarantined" })
));
assert_eq!(core.imports().import_manifest_bytes(&import_rev).unwrap(), None);
let texture_id = manifest.asset_id_for(&manifest.assets[1].key);
assert_eq!(core.catalog().asset_namespace(&texture_id).unwrap(), None);
}
#[test]
fn registered_source_terms_are_authoritative() {
let (_root, core) = open_core("import_rights_gate");
for bytes in [PACK_GLB, PACK_COLLIDER, PACK_PREVIEW, PACK_TEXTURE] {
core.put_blob(bytes, NOW).unwrap();
}
// Register a CC-BY-4.0 attribution-required source.
let ccby = collection_with_terms("attributed", cc_by_terms());
core.imports()
.register_source(&ccby.to_canonical_bytes().unwrap(), NOW)
.unwrap();
// A manifest that claims CC0 for the CC-BY source refuses: license
// laundering is a Conflict, not a warning.
let mut laundered = pack_with_terms(&ccby);
laundered.rights = cc0_rights("Example Author", "https://example.com/pack");
let laundered_bytes = laundered.to_canonical_bytes().unwrap();
assert!(matches!(
core.imports().run_import(&laundered_bytes, NOW),
Err(ServerError::Conflict { what: "import rights vs registered source" })
));
// Dropping the credits line refuses the same way (and could not even
// encode under attribution-required policy — try weakening to Allowed
// AND dropping credits, which encodes but diverges from the approval).
let mut uncredited = pack_with_terms(&ccby);
uncredited.rights.credits = String::new();
uncredited.rights.redistribution = Redistribution::Allowed;
uncredited.rights.derivatives = DerivativePolicy::Allowed;
let uncredited_bytes = uncredited.to_canonical_bytes().unwrap();
assert!(matches!(
core.imports().run_import(&uncredited_bytes, NOW),
Err(ServerError::Conflict { what: "import rights vs registered source" })
));
// Unpinning the terms digest refuses too: the pinned terms ARE the
// approval.
let mut unpinned = pack_with_terms(&ccby);
unpinned.rights.terms_digest = None;
let unpinned_bytes = unpinned.to_canonical_bytes().unwrap();
assert!(matches!(
core.imports().run_import(&unpinned_bytes, NOW),
Err(ServerError::Conflict { what: "import rights vs registered source" })
));
// The exact registered terms import cleanly, and every produced manifest
// carries them verbatim: identifier+revision, terms digest/URL, credits,
// upstream source, archive digest, and both policies survive.
let exact = pack_with_terms(&ccby);
let report = core
.imports()
.run_import(&exact.to_canonical_bytes().unwrap(), NOW)
.unwrap();
for entry in &report.entries {
let stored = core
.catalog()
.asset_revision_manifest(&entry.revision)
.unwrap()
.unwrap();
let decoded = AssetManifest::from_canonical_bytes(&stored).unwrap();
assert_eq!(decoded.rights, cc_by_terms());
}
}
#[test]
fn import_budget_is_enforced() {
let root = test_root("import_budget");
let mut budgets = Budgets::default_v1();
budgets.max_import_assets = 1;
let core = AssetServerCore::open(&root, budgets).unwrap();
for bytes in [PACK_GLB, PACK_COLLIDER, PACK_PREVIEW, PACK_TEXTURE] {
core.put_blob(bytes, NOW).unwrap();
}
core.imports()
.register_source(&kenney_collection().to_canonical_bytes().unwrap(), NOW)
.unwrap();
assert!(matches!(
core.imports()
.run_import(&kenney_pack("1.0").to_canonical_bytes().unwrap(), NOW),
Err(ServerError::OverBudget { what: "import assets", .. })
));
}