参考素材改为提交前预检并补参考链路用例

参考解析前增加纯本地预检:清单归属、受控路径与文件、媒体类型、可解码位图全部通过后才允许产生任何上传副作用,避免第一张参考已上传、第二张坏图才失败
明确拒绝 SVG 等矢量参考,媒体类型或文件扩展名任一命中即拒,本次不做矢量转换,坏图同样在本地失败关闭
asset.upload 门禁提前到预检阶段,显式拒绝该命令的项目连第一个上传凭证都不会签发
预检与实际上传共用同一份本地判据 helper,不新建平行流程,上传侧仍按同一判据重新读取与解码
补参考合同与恢复的账本用例:icon-spec 带用户参考、game-background 规范图加用户参考、图集超限、空白引用写入失败
补提交侧用例:拒绝路径、未登记、非图片、缺失文件、超限、SVG 与坏图,并断言零上传凭证与零生成 POST,反向验证同一张合格参考单独提交确实会签发凭证
补 asset.upload 拒绝用例,以及清单校验与 Direct 身份校验接受用户参考、拒绝用户参考顶替规范引用的覆盖
This commit is contained in:
2026-09-17 18:24:33 +08:00
parent 13b28ebbc7
commit d222aad2ec
3 changed files with 952 additions and 50 deletions
@@ -9808,6 +9808,78 @@ mod tests {
);
}
#[test]
fn direct_taonier_art_package_accepts_manifest_user_references() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-art-references", "直连美术参考")
.expect("init project");
register_direct_taonier_art_package_fixture(root.path());
assert!(direct_taonier_art_package_is_valid(root.path()));
// 规范图与背景图带用户参考:参考只是风格输入,规范身份仍由参考序列首项承担。
mutate_manifest_at(root.path(), |manifest| {
let art_spec = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH)
.expect("art spec asset");
art_spec.source.reference_resource_ids = vec![
"user-reference-1".to_string(),
"user-reference-2".to_string(),
];
let background = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == DIRECT_CODEX_BACKGROUND_ASSET_PATH)
.expect("background asset");
background
.source
.reference_resource_ids
.push("user-reference-1".to_string());
Ok(())
})
.expect("apply user references to the art base");
assert!(
direct_taonier_art_package_is_valid(root.path()),
"user references must not invalidate the art package"
);
// 图集仍只接受唯一规范引用:多一项用户参考必须失败关闭。
mutate_manifest_at(root.path(), |manifest| {
let spritesheet = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH)
.expect("spritesheet asset");
spritesheet
.source
.reference_resource_ids
.push("user-reference-1".to_string());
Ok(())
})
.expect("add an extra spritesheet reference");
assert!(
!direct_taonier_art_package_is_valid(root.path()),
"art spritesheet must reject extra user references"
);
// 用户参考不能顶替图集的规范前置。
mutate_manifest_at(root.path(), |manifest| {
let spritesheet = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH)
.expect("spritesheet asset");
spritesheet.source.reference_resource_ids = vec!["user-reference-1".to_string()];
Ok(())
})
.expect("replace the spritesheet canonical reference");
assert!(
!direct_taonier_art_package_is_valid(root.path()),
"a user reference must not replace the art spritesheet canonical spec"
);
}
#[test]
fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() {
let root = tempfile::tempdir().expect("temp dir");
File diff suppressed because it is too large Load Diff
@@ -378,6 +378,78 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re
.unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}"));
}
// 规范图与派生素材都允许用户参考(同一项目已登记的图片素材):参考只是风格输入,
// 规范身份仍由参考序列第一项承担,多出的用户参考不能让校验失败。
let art_spec = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/art-spec.png")
.expect("art spec asset");
art_spec.source.reference_resource_ids = vec!["user-reference-1".to_string()];
let ui = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/ui-prototype.png")
.expect("ui prototype asset");
ui.source.reference_resource_ids = vec![
"resource-icon-spec".to_string(),
"user-reference-1".to_string(),
];
for task_id in ["art-director", "design-foundation"] {
validate_manifest_required_visual_asset(&root, &manifest, task_id)
.unwrap_or_else(|error| panic!("{task_id} must accept user references: {error}"));
}
// 用户参考不能顶替规范图:首项不是当前规范引用时仍必须失败关闭。
let ui = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/ui-prototype.png")
.expect("ui prototype asset");
ui.source.reference_resource_ids = vec![
"user-reference-1".to_string(),
"resource-icon-spec".to_string(),
];
assert!(
validate_manifest_required_visual_asset(&root, &manifest, "design-foundation")
.expect_err("a leading user reference must not replace the canonical spec")
.contains("未绑定当前统一视觉规范图的本地内容身份")
);
// 只接受单规范引用的图集不接受额外用户参考。
let ui = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/ui-prototype.png")
.expect("ui prototype asset");
ui.source.reference_resource_ids = vec!["resource-icon-spec".to_string()];
let spritesheet = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/art-spritesheet.png")
.expect("art spritesheet asset");
spritesheet.source.reference_resource_ids = vec![
"resource-icon-spec".to_string(),
"user-reference-1".to_string(),
];
assert!(
validate_manifest_required_visual_asset(&root, &manifest, "art-asset-plan")
.expect_err("art spritesheet must reject extra user references")
.contains("未精确引用当前统一视觉规范图")
);
let spritesheet = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/art-spritesheet.png")
.expect("art spritesheet asset");
spritesheet.source.reference_resource_ids = vec!["resource-icon-spec".to_string()];
let art_spec = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/art-spec.png")
.expect("art spec asset");
art_spec.source.reference_resource_ids = Vec::new();
let art_spec_path = root.join("assets/art-spec.png");
let valid_art_spec = fs::read(&art_spec_path).expect("read valid art spec fixture");
fs::write(&art_spec_path, &valid_art_spec[..valid_art_spec.len() / 2])
@@ -1019,6 +1091,13 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
"canvas-project-1",
);
}
if root.join("assets/user-reference.png").is_file() {
bind_canvas_visual_asset_fixture_to_current_editor(
root,
"assets/user-reference.png",
"canvas-project-1",
);
}
request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options)
.await
.expect("prepare canonical visual request");
@@ -1087,9 +1166,385 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
assert!(ui_request.contains(r#""kind":"ui-design""#));
assert!(ui_request.contains(r#""referenceImageSrcs":["resource-icon-spec"]"#));
// 用户参考只接受当前项目已登记图片素材 id,并换成当前账号绑定下的远端资源 ID。
register_canvas_visual_asset_fixture(&root, "assets/user-reference.png", "image");
let user_reference_asset_id = manifest_asset_id(&root, "assets/user-reference.png");
let icon_spec_config_dir = unique_project_path();
let icon_spec_with_reference = capture_generation_request(
&root,
&icon_spec_config_dir,
PlatformArtAssetGenerationOptions {
output_path: Some("assets/icon-spec-custom.png".to_string()),
asset_kind: "icon-spec".to_string(),
asset_label: "带用户参考的图标规范".to_string(),
reference_asset_ids: vec![
user_reference_asset_id.clone(),
user_reference_asset_id.clone(),
],
..PlatformArtAssetGenerationOptions::default()
},
)
.await;
assert!(icon_spec_with_reference.starts_with("POST /api/editor/images/generations "));
// 图标规范没有规范前置:用户参考原样提交,且不带任何伪造的规范引用。
assert!(
icon_spec_with_reference.contains(r#""referenceImageSrcs":["resource-image"]"#),
"{icon_spec_with_reference}"
);
let ui_with_reference_config_dir = unique_project_path();
let ui_with_reference = capture_generation_request(
&root,
&ui_with_reference_config_dir,
PlatformArtAssetGenerationOptions {
output_path: Some("assets/ui-prototype-custom.png".to_string()),
aspect_ratio: "16:9".to_string(),
image_size: "2K".to_string(),
asset_kind: "ui-prototype".to_string(),
asset_label: "带用户参考的界面原型图".to_string(),
reference_asset_ids: vec![user_reference_asset_id.clone()],
..PlatformArtAssetGenerationOptions::default()
},
)
.await;
// 有规范前置的生成:规范图始终是第一项,用户参考按给出顺序追加在后。
assert!(
ui_with_reference
.contains(r#""referenceImageSrcs":["resource-icon-spec","resource-image"]"#),
"{ui_with_reference}"
);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(spec_config_dir).ok();
fs::remove_dir_all(ui_config_dir).ok();
fs::remove_dir_all(icon_spec_config_dir).ok();
fs::remove_dir_all(ui_with_reference_config_dir).ok();
}
/// 参考上传凭证请求:同一路由在 External v1 与平台会话下会落到两种前缀,两边的写操作都要看住。
fn is_reference_upload_ticket_request(request: &str) -> bool {
request.starts_with("POST /api/assets/direct-upload-tickets ")
|| request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ")
}
/// 图片生成提交:同样两种前缀都要算。
fn is_image_generation_request(request: &str) -> bool {
request.starts_with("POST /api/editor/images/generations ")
|| request.starts_with("POST /api/external/v1/editor/images/generations ")
|| request.starts_with("POST /api/editor/icon-spritesheets/generations ")
|| request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ")
}
/// 当前项目清单里某条素材的 manifest 资产 id(参考选择只接受这个身份)。
fn manifest_asset_id(root: &Path, local_path: &str) -> String {
read_manifest_for_project(root)
.expect("read manifest for asset id")
.assets
.into_iter()
.find(|asset| asset.local_path == local_path)
.unwrap_or_else(|| panic!("registered asset is present: {local_path}"))
.id
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reference_selection_rejects_unsupported_inputs_before_any_generation_post() {
let root = unique_project_path();
let config_dir = unique_project_path();
let (request_sender, request_receiver) = mpsc::channel();
// 这条用例要连续跑十几次「画布上下文 + 参考预检」,超过默认 20 次请求预算会被判成 502。
let canvas_base_url =
spawn_mock_external_canvas_api_server_with_capture(200, Some(request_sender));
let _platform_session = crate::platform_session::install_test_platform_session(
"reference-guard-user",
"editor-runtime-key",
&canvas_base_url,
);
fs::create_dir_all(&config_dir).expect("create reference guard config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" }
})
.to_string(),
)
.expect("write reference guard config");
let _config_guard = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(&root, "reference-guard", "参考素材门禁")
.expect("init reference guard project");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow reference guard generation");
let options =
|asset_kind: &str, reference_asset_ids: Vec<String>| PlatformArtAssetGenerationOptions {
output_path: Some(format!("assets/reference-guard-{asset_kind}.png")),
asset_kind: asset_kind.to_string(),
asset_label: "参考素材门禁".to_string(),
reference_asset_ids,
..PlatformArtAssetGenerationOptions::default()
};
async fn reject(root: &Path, options: PlatformArtAssetGenerationOptions) -> String {
request_platform_art_asset_with_options_for_test(root, "参考素材门禁", &options)
.await
.expect_err("reference selection must fail closed")
}
let canvas_source = || GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
};
// 只接受单规范引用的图集不接受用户参考,且必须明确拒绝而不是静默丢弃。
let error = reject(
&root,
options("art-spritesheet", vec!["user-asset".to_string()]),
)
.await;
assert!(error.contains("透明美术图集只接受规范图引用"), "{error}");
// 路径、URL 等形状不是素材身份。
for rejected in [
"assets/hero.png",
"..\\hero.png",
"https://example.com/hero.png",
] {
let error = reject(&root, options("icon-spec", vec![rejected.to_string()])).await;
assert!(error.contains("不接受路径或远端资源 ID"), "{error}");
}
// 未登记的 id 不能冒充当前项目素材(历史账号的远端资源 ID 也不在此列)。
let error = reject(
&root,
options(
"icon-spec",
vec!["editor-resource-from-older-account".to_string()],
),
)
.await;
assert!(
error.contains("参考素材不在当前项目已登记清单中"),
"{error}"
);
// 非图片素材不能当参考。
fs::create_dir_all(root.join("assets")).expect("create reference guard asset dir");
fs::write(root.join("assets/document.json"), b"{}").expect("write non image reference fixture");
register_local_asset_at(
&root,
"assets/document.json",
"image",
"application/json",
"canvas",
canvas_source(),
)
.expect("register non image reference fixture");
let error = reject(
&root,
options(
"icon-spec",
vec![manifest_asset_id(&root, "assets/document.json")],
),
)
.await;
assert!(error.contains("参考素材必须是图片"), "{error}");
// 已登记但本地文件缺失的素材不能被引用。
register_canvas_visual_asset_fixture(&root, "assets/missing-reference.png", "image");
let missing_asset_id = manifest_asset_id(&root, "assets/missing-reference.png");
fs::remove_file(root.join("assets/missing-reference.png"))
.expect("remove missing reference fixture file");
let error = reject(&root, options("icon-spec", vec![missing_asset_id])).await;
assert!(error.contains("不存在;请重新登记后再引用"), "{error}");
// 超过总上限:无规范前置最多 5 张。
let error = reject(
&root,
options(
"icon-spec",
(0..6).map(|index| format!("reference-{index}")).collect(),
),
)
.await;
assert!(error.contains("普通图片生成最多 5 张参考素材"), "{error}");
// SVG 与坏图同样必须在提交前失败:本次不做 SVG 转换,也不允许把「已登记」当成可解码。
register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image");
let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png");
fs::write(
root.join("assets/vector-reference.svg"),
b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
)
.expect("write svg reference fixture");
register_local_asset_at(
&root,
"assets/vector-reference.svg",
"image",
"image/svg+xml",
"canvas",
canvas_source(),
)
.expect("register svg reference fixture");
let vector_asset_id = manifest_asset_id(&root, "assets/vector-reference.svg");
let error = reject(
&root,
options(
"icon-spec",
vec![plain_reference_asset_id.clone(), vector_asset_id],
),
)
.await;
assert!(error.contains("不支持 SVG 等矢量格式"), "{error}");
// 声明成位图、实际是矢量扩展名的素材同样要按矢量拒绝。
fs::write(
root.join("assets/mislabeled-reference.svg"),
valid_test_png_bytes(),
)
.expect("write mislabeled svg reference fixture");
register_local_asset_at(
&root,
"assets/mislabeled-reference.svg",
"image",
"image/png",
"canvas",
canvas_source(),
)
.expect("register mislabeled svg reference fixture");
let mislabeled_asset_id = manifest_asset_id(&root, "assets/mislabeled-reference.svg");
let error = reject(
&root,
options(
"icon-spec",
vec![plain_reference_asset_id.clone(), mislabeled_asset_id],
),
)
.await;
assert!(error.contains("不支持 SVG 等矢量格式"), "{error}");
fs::write(root.join("assets/broken-reference.png"), b"not-a-png")
.expect("write broken reference fixture");
register_local_asset_at(
&root,
"assets/broken-reference.png",
"image",
"image/png",
"canvas",
canvas_source(),
)
.expect("register broken reference fixture");
let broken_asset_id = manifest_asset_id(&root, "assets/broken-reference.png");
let error = reject(
&root,
options(
"icon-spec",
vec![plain_reference_asset_id.clone(), broken_asset_id],
),
)
.await;
assert!(error.contains("不是可解析图片"), "{error}");
// 以上全部在提交前失败:没有生成 POST,也没有任何参考上传凭证被签发。
// 合格参考故意不做 binding:旧路径会先为它签发凭证,所以这条断言对半完成上传有实际约束。
while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) {
assert!(!is_image_generation_request(&request), "{request}");
assert!(!is_reference_upload_ticket_request(&request), "{request}");
}
// 反证:同一张合格参考单独提交时确实会去签发上传凭证,说明上面的「零上传」不是空断言。
let differential = request_platform_art_asset_with_options_for_test(
&root,
"参考素材门禁",
&options("icon-spec", vec![plain_reference_asset_id.clone()]),
)
.await;
let mut upload_ticket_attempted = false;
while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(300)) {
if is_reference_upload_ticket_request(&request) {
upload_ticket_attempted = true;
}
}
assert!(
upload_ticket_attempted,
"the same single reference must attempt an upload ticket: {differential:?}"
);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reference_upload_permission_gate_fails_closed_before_any_upload() {
let root = unique_project_path();
let config_dir = unique_project_path();
let (request_sender, request_receiver) = mpsc::channel();
let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender));
let _platform_session = crate::platform_session::install_test_platform_session(
"reference-permission-user",
"editor-runtime-key",
&canvas_base_url,
);
fs::create_dir_all(&config_dir).expect("create reference permission config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" }
})
.to_string(),
)
.expect("write reference permission config");
let _config_guard = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(&root, "reference-permission", "参考上传门禁")
.expect("init reference permission project");
// 只拒绝 asset.upload:参考素材要上传到平台账号,这个门禁必须在本地失败关闭。
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: vec!["asset.upload".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("deny reference upload");
register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image");
let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png");
let error = request_platform_art_asset_with_options_for_test(
&root,
"参考素材门禁",
&PlatformArtAssetGenerationOptions {
output_path: Some("assets/reference-permission.png".to_string()),
asset_kind: "icon-spec".to_string(),
asset_label: "参考素材门禁".to_string(),
reference_asset_ids: vec![plain_reference_asset_id],
..PlatformArtAssetGenerationOptions::default()
},
)
.await
.expect_err("a denied asset.upload must fail closed");
assert!(
error.contains("项目权限策略拒绝执行:asset.upload"),
"{error}"
);
while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) {
assert!(!is_reference_upload_ticket_request(&request), "{request}");
assert!(!is_image_generation_request(&request), "{request}");
}
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]