feat: complete tree shell cutover and regression coverage

This commit is contained in:
lix-2026
2026-04-18 09:38:16 +08:00
parent d3876e56eb
commit 111a87d4fd
53 changed files with 5440 additions and 516 deletions
+112 -24
View File
@@ -1,8 +1,6 @@
use crate::app::AppConfig;
use crate::context::RequestContext;
use crate::error::WebError;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use bridge_runtime::{RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan};
use serde_json::{json, Value};
use std::fs;
@@ -14,6 +12,7 @@ const HEADER_WORKSPACE_ID: &str = "x-mnote-workspace-id";
const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) {
@@ -57,6 +56,10 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
return Ok(authorization.to_string());
}
if let Some(convex_token) = extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN) {
return Ok(format!("Bearer {convex_token}"));
}
let admin_key = config
.convex_admin_key
.clone()
@@ -71,27 +74,32 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
.with_header("x-upstream-service", "convex")
})?;
let effective_actor_id = if context.auth.actor_id.trim().is_empty()
|| context.auth.actor_id == "anonymous"
{
config.dev_user_id.clone()
} else {
context.auth.actor_id.clone()
};
// 说明:
// - Next 侧 `setAdminAuth(adminKey)` 走的是纯 admin auth,而不是伪造用户身份。
// - Convex 业务函数内部会在缺少真实 token 时自行回退到 DEV_USER_ID。
// - 这里若强行附带伪造 identity,会让 getAuthUserId(ctx) 命中一个未映射用户,
// 反而绕过 DEV_USER_ID fallback,导致 workspace membership 校验失败。
Ok(format!("Convex {admin_key}"))
}
let identity = json!({
"subject": effective_actor_id,
"issuer": "https://mnote.local/dev-auth",
"tokenIdentifier": format!("dev-user|{}", effective_actor_id),
"name": config.dev_user_name,
"email": config.dev_user_email,
});
let encoded = STANDARD.encode(
serde_json::to_string(&identity)
.map_err(|error| WebError::internal(format!("开发用户身份序列化失败: {error}")))?,
);
Ok(format!("Convex {admin_key}:{encoded}"))
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
context
.auth
.cookie_header
.as_deref()
.and_then(|cookie_header| {
cookie_header.split(';').find_map(|segment| {
let (key, value) = segment.trim().split_once('=')?;
if key.trim() != name {
return None;
}
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.to_string())
})
})
}
fn convex_url(config: &AppConfig, context: &RequestContext) -> Result<String, WebError> {
@@ -303,7 +311,10 @@ pub async fn execute_convex_command_plan(
if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") {
return Err(WebError::bad_request_code(
"transport_command_unsupported",
format!("mnote-web transport 暂不支持 command: {}", plan.function_name),
format!(
"mnote-web transport 暂不支持 command: {}",
plan.function_name
),
)
.with_context(context)
.with_header("x-error-phase", "plan_validation"));
@@ -359,7 +370,10 @@ pub async fn execute_convex_command_plan(
let response = request.send().await.map_err(|error| {
let base = if error.is_timeout() {
WebError::gateway_timeout_code("convex_timeout", format!("Convex mutation 超时: {error}"))
WebError::gateway_timeout_code(
"convex_timeout",
format!("Convex mutation 超时: {error}"),
)
} else {
WebError::service_unavailable_code(
"convex_unavailable",
@@ -419,3 +433,77 @@ pub async fn execute_convex_command_plan(
.with_header("x-upstream-status", status.as_u16().to_string())),
}
}
#[cfg(test)]
mod tests {
use super::build_authorization;
use crate::app::AppConfig;
use crate::context::RequestContext;
use axum::http::{HeaderMap, HeaderValue, Method, Uri};
fn config() -> AppConfig {
AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:3104".into(),
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some("http://127.0.0.1:3210".into()),
convex_admin_key: Some("admin-demo".into()),
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}
}
fn request_context(headers: HeaderMap) -> RequestContext {
RequestContext::from_http_parts(
&Method::GET,
&"/api/compat/next/sidebar?workspaceId=ws_demo"
.parse::<Uri>()
.expect("uri"),
&headers,
)
}
#[test]
fn build_authorization_prefers_forwarded_authorization() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_static("Bearer real-token"),
);
let authorization =
build_authorization(&config(), &request_context(headers)).expect("authorization");
assert_eq!(authorization, "Bearer real-token");
}
#[test]
fn build_authorization_falls_back_to_plain_admin_auth() {
let authorization = build_authorization(&config(), &request_context(HeaderMap::new()))
.expect("authorization");
assert_eq!(authorization, "Convex admin-demo");
}
#[test]
fn build_authorization_reads_convex_token_from_cookie() {
let mut headers = HeaderMap::new();
headers.insert(
"cookie",
HeaderValue::from_static(
"foo=bar; mnote_web_convex_token=token-from-cookie; theme=light",
),
);
let authorization =
build_authorization(&config(), &request_context(headers)).expect("authorization");
assert_eq!(authorization, "Bearer token-from-cookie");
}
}