feat: advance local-first workspace checklist
- add admin access-policy UI and local access control surfaces - add local markdown conflict resolution UI and smoke coverage - add ACP local agent changed-files audit scaffold and read-only write guard - document current P0-P2 checklist progress and verification evidence
This commit is contained in:
@@ -6,9 +6,9 @@ use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
execute_local_tree_command, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
||||
local_workspace_id_from_root_uri,
|
||||
ensure_local_workspace_access, ensure_local_workspace_read_access, execute_local_tree_command,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, local_workspace_id_from_root_uri,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
|
||||
@@ -6122,6 +6122,8 @@ pub async fn local_folder_watch(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalFolderWatchQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
ensure_local_workspace_read_access(&context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let revision = local_folder_watch_revision(&query.root_uri)?;
|
||||
Ok(json_response(
|
||||
&context,
|
||||
@@ -6218,6 +6220,8 @@ pub async fn tree_shell(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(&effective_context, root_uri)
|
||||
.map_err(|error| error.with_context(&effective_context))?;
|
||||
(
|
||||
local_workspace_id_from_root_uri(root_uri)?,
|
||||
if mode == "filetree" {
|
||||
@@ -6839,6 +6843,8 @@ pub async fn tree_command(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let execution = execute_local_tree_command(
|
||||
root_uri,
|
||||
action,
|
||||
@@ -6959,7 +6965,7 @@ mod tests {
|
||||
use crate::context::RequestContext;
|
||||
use crate::routes::command_support::build_runtime_command_plan;
|
||||
use axum::body::Body;
|
||||
use axum::http::{HeaderMap, Method, Request, StatusCode, Uri};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -6984,6 +6990,30 @@ mod tests {
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
.layer(axum::middleware::from_fn(inject_test_actor))
|
||||
}
|
||||
|
||||
async fn inject_test_actor(
|
||||
mut request: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
request
|
||||
.headers_mut()
|
||||
.entry("x-mnote-actor-id")
|
||||
.or_insert(HeaderValue::from_static("user_test"));
|
||||
request
|
||||
.headers_mut()
|
||||
.entry("x-mnote-actor-type")
|
||||
.or_insert(HeaderValue::from_static("user"));
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
fn init_local_workspace(root: &std::path::Path, actor_id: &str) {
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
actor_id,
|
||||
&format!("file://{}", root.display()),
|
||||
)
|
||||
.expect("init local workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7196,12 +7226,15 @@ mod tests {
|
||||
std::fs::write(root.join("image.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -7236,12 +7269,15 @@ mod tests {
|
||||
std::fs::write(root.join("asset.txt"), "asset").expect("write local asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -7277,12 +7313,15 @@ mod tests {
|
||||
std::fs::write(root.join("image.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/tree?mode=page&sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -7311,6 +7350,7 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
|
||||
let create_response = app()
|
||||
.oneshot(
|
||||
@@ -7498,6 +7538,7 @@ mod tests {
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs").join("photo.png"), b"png").expect("write asset");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let asset_id = "local:asset:docs/photo.png";
|
||||
|
||||
let delete_response = app()
|
||||
@@ -7608,6 +7649,7 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
@@ -7653,6 +7695,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_command_local_folder_rejects_non_owner_root() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-tree-owner-denied-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
init_local_workspace(&root, "owner_user");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "other_user")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"local-md:README.md"}}"#
|
||||
)))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let status = response.status();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("error json");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(payload["ok"], false);
|
||||
assert_eq!(payload["code"], "local_workspace_access_denied");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_shell_embeds_renderer_input_contract() {
|
||||
let filetree_response = app()
|
||||
|
||||
Reference in New Issue
Block a user