修复图片生成鉴权刷新重试
将上下文、参考图和首次提交阶段的401账本保留为可恢复状态。 区分401鉴权失效与403权限拒绝,刷新后复用原生成身份和幂等请求。 补充持久化账本重放回归测试与登录刷新验收合同。
This commit is contained in:
@@ -2704,7 +2704,10 @@ fn preserve_submit_reconciliation(
|
||||
) -> bool {
|
||||
reconciliation
|
||||
|| (code == "authentication-required"
|
||||
&& phase == &GenerationLedgerPhase::ReconciliationRequired)
|
||||
&& matches!(
|
||||
phase,
|
||||
GenerationLedgerPhase::Prepared | GenerationLedgerPhase::ReconciliationRequired
|
||||
))
|
||||
}
|
||||
|
||||
async fn reconcile_generation(
|
||||
@@ -2788,12 +2791,14 @@ async fn reconcile_generation(
|
||||
{
|
||||
Ok(context) => context,
|
||||
Err(error) => {
|
||||
let code = if error.contains("HTTP 401") || error.contains("HTTP 403") {
|
||||
"authentication-required"
|
||||
let (reconciliation, code) = if error.contains("HTTP 401") {
|
||||
(true, "authentication-required")
|
||||
} else if error.contains("HTTP 403") {
|
||||
(false, "permission-denied")
|
||||
} else {
|
||||
"platform-service-configuration"
|
||||
(false, "platform-service-configuration")
|
||||
};
|
||||
mark_generation_error(root, &mut ledger, false, code, emit)?;
|
||||
mark_generation_error(root, &mut ledger, reconciliation, code, emit)?;
|
||||
return Err(sanitized_generation_error(code));
|
||||
}
|
||||
};
|
||||
@@ -2804,7 +2809,8 @@ async fn reconcile_generation(
|
||||
ensure_reference_states(root, &mut ledger, &client, api_base_url, api_mode).await
|
||||
{
|
||||
let code = error.code();
|
||||
mark_generation_error(root, &mut ledger, false, code, emit)?;
|
||||
let reconciliation = code == "authentication-required";
|
||||
mark_generation_error(root, &mut ledger, reconciliation, code, emit)?;
|
||||
return Err(sanitized_generation_error(code));
|
||||
}
|
||||
let (endpoint, body_json) = build_generation_request_snapshot(&ledger)?;
|
||||
@@ -2883,7 +2889,11 @@ async fn reconcile_generation(
|
||||
}
|
||||
}
|
||||
mark_generation_error(root, &mut ledger, reconciliation, code, emit)?;
|
||||
return Err(sanitized_classified_generation_error(code, reconciliation));
|
||||
return Err(if code == "authentication-required" {
|
||||
sanitized_generation_error(code)
|
||||
} else {
|
||||
sanitized_classified_generation_error(code, reconciliation)
|
||||
});
|
||||
}
|
||||
let submission = match response.json::<serde_json::Value>().await {
|
||||
Ok(value) => value,
|
||||
@@ -4065,6 +4075,176 @@ mod tests {
|
||||
.contains("安全画布边界"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn context_authentication_required_replays_the_same_generation_after_refresh() {
|
||||
let project_id = "phase-five-auth-refresh-project";
|
||||
let project_name = "阶段五登录刷新重放测试";
|
||||
let (directory, draft) = create_generation_fixture(project_id, project_name);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind auth refresh server");
|
||||
let base_url = format!(
|
||||
"http://{}",
|
||||
listener.local_addr().expect("auth refresh address")
|
||||
);
|
||||
let signed_url = format!("{base_url}/auth-refresh.png");
|
||||
let server_signed_url = signed_url.clone();
|
||||
let png = test_png();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.expect("set auth refresh fixture nonblocking");
|
||||
for request_index in 0..7 {
|
||||
let mut stream = accept_generation_fixture_connection(
|
||||
&listener,
|
||||
"auth refresh fixture",
|
||||
request_index,
|
||||
);
|
||||
let request = read_http_request(&mut stream);
|
||||
sender
|
||||
.send(request.clone())
|
||||
.expect("capture auth refresh request");
|
||||
if request_index == 0 {
|
||||
assert!(request.starts_with("GET /api/external/v1/editor/projects "));
|
||||
assert!(request
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer expired-key"));
|
||||
write_json(
|
||||
&mut stream,
|
||||
"401 Unauthorized",
|
||||
serde_json::json!({"error": {"code": "authentication-required"}}),
|
||||
);
|
||||
} else if request.starts_with("GET /api/external/v1/editor/projects ") {
|
||||
write_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
serde_json::json!({"data": {"projects": [{
|
||||
"projectId": "auth-refresh-remote-project",
|
||||
"title": project_name,
|
||||
}]}}),
|
||||
);
|
||||
} else if request.starts_with("GET /api/external/v1/editor/assets/library ") {
|
||||
write_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
serde_json::json!({"data": {"library": {"folders": [{
|
||||
"folderId": "auth-refresh-remote-folder",
|
||||
"label": project_name,
|
||||
}]}}}),
|
||||
);
|
||||
} else if request.starts_with("POST /api/external/v1/editor/images/generations ") {
|
||||
write_json(
|
||||
&mut stream,
|
||||
"202 Accepted",
|
||||
serde_json::json!({"data": {
|
||||
"operationId": "auth-refresh-operation",
|
||||
"status": "queued",
|
||||
"pollAfterMs": 0,
|
||||
}}),
|
||||
);
|
||||
} else if request
|
||||
.starts_with("GET /api/external/v1/generations/auth-refresh-operation ")
|
||||
{
|
||||
write_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
serde_json::json!({"data": {
|
||||
"operationId": "auth-refresh-operation",
|
||||
"status": "completed",
|
||||
"pollAfterMs": 0,
|
||||
"result": {"resource": {
|
||||
"resourceId": "auth-refresh-resource",
|
||||
"objectKey": "generated/auth-refresh.png",
|
||||
"assetObjectId": "auth-refresh-object",
|
||||
}}
|
||||
}}),
|
||||
);
|
||||
} else if request.starts_with("GET /api/external/v1/assets/read-url?") {
|
||||
write_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
serde_json::json!({"read": {"signedUrl": server_signed_url}}),
|
||||
);
|
||||
} else if request.starts_with("GET /auth-refresh.png ") {
|
||||
write_png(&mut stream, &png);
|
||||
} else {
|
||||
panic!("unexpected auth refresh request: {request}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let input = generation_input(
|
||||
directory.path(),
|
||||
project_id,
|
||||
&draft,
|
||||
"刷新登录态后继续原图片生成",
|
||||
);
|
||||
let mut first_progress = Vec::new();
|
||||
let first_error =
|
||||
match with_test_external_editor_credentials(&base_url, "expired-key", async {
|
||||
generate_asset_canvas_image_at(directory.path(), &input, |event| {
|
||||
first_progress.push(event)
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("first request must expose authentication failure"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
first_error.contains("登录已失效") || first_error.contains("API Key 无效"),
|
||||
"authentication failure must remain recognizable without exposing credentials"
|
||||
);
|
||||
assert_eq!(
|
||||
first_progress.last().map(|event| event.phase.as_str()),
|
||||
Some("reconciliation-required")
|
||||
);
|
||||
let recoverable = read_generation_ledger(directory.path(), &input.generation_id)
|
||||
.expect("read authentication ledger")
|
||||
.expect("authentication ledger exists");
|
||||
assert_eq!(
|
||||
recoverable.phase,
|
||||
GenerationLedgerPhase::ReconciliationRequired
|
||||
);
|
||||
assert_eq!(
|
||||
recoverable.error_code.as_deref(),
|
||||
Some("authentication-required")
|
||||
);
|
||||
assert!(recoverable.operation_id.is_none());
|
||||
assert!(recoverable.request_body_json.is_none());
|
||||
|
||||
let recovered = with_test_external_editor_credentials(&base_url, "refreshed-key", async {
|
||||
generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await
|
||||
})
|
||||
.await
|
||||
.expect("refreshed credentials replay the original generation");
|
||||
server.join().expect("join auth refresh server");
|
||||
assert_eq!(
|
||||
recovered.result.generation.phase,
|
||||
AssetCanvasGenerationStatus::CandidateReady
|
||||
);
|
||||
assert_eq!(
|
||||
recovered.result.generation.generation_id,
|
||||
input.generation_id
|
||||
);
|
||||
|
||||
let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::<Vec<_>>();
|
||||
assert_eq!(requests.len(), 7);
|
||||
assert!(requests[1]
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer refreshed-key"));
|
||||
let submits = requests
|
||||
.iter()
|
||||
.filter(|request| {
|
||||
request.starts_with("POST /api/external/v1/editor/images/generations ")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(submits.len(), 1);
|
||||
assert!(submits[0]
|
||||
.to_ascii_lowercase()
|
||||
.contains(&format!("idempotency-key: {}", input.idempotency_key)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirmed_generation_posts_once_replays_without_network_and_keeps_public_state_clean()
|
||||
{
|
||||
@@ -5857,7 +6037,7 @@ mod tests {
|
||||
assert_eq!(progress[0].error_code.as_deref(), Some(code));
|
||||
assert!(sanitized_classified_generation_error(code, reconciliation)
|
||||
.starts_with("reconciliation-required:"));
|
||||
assert!(!preserve_submit_reconciliation(
|
||||
assert!(preserve_submit_reconciliation(
|
||||
&GenerationLedgerPhase::Prepared,
|
||||
code,
|
||||
false,
|
||||
@@ -5867,6 +6047,11 @@ mod tests {
|
||||
code,
|
||||
false,
|
||||
));
|
||||
assert!(!preserve_submit_reconciliation(
|
||||
&GenerationLedgerPhase::Prepared,
|
||||
"permission-denied",
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -902,6 +902,7 @@ cancelling
|
||||
| A26 | 导出 | PNG/JPEG/WebP | Web 下载/云端、Tauri 保存对话框均成功;共享 UI 不接收绝对路径 |
|
||||
| A27 | 取消 | clean、dirty、generating、staging、committing | 分别符合第 12 节;committing 不伪装成可取消 |
|
||||
| A28 | 恢复草稿 | 主文件损坏但恢复副本可信/不可信 | 可信副本恢复到 clean history 基线;不可信进入对账,不猜测 |
|
||||
| A29 | 登录刷新重放 | context、参考图准备或首次提交返回 401,刷新后以相同 generationId 和幂等身份重放 | 401 账本保持可恢复且第二次真实访问平台;403 直接失败且不刷新;远端最多受理一次 |
|
||||
|
||||
阶段一至五最终审计只有在矩阵对应的纯模型、共享 React、Web adapter、Tauri adapter、Rust 持久化与 AppSurface 测试全部通过后,才可宣称图片素材创作正式闭环完成。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user