687 lines
24 KiB
Rust
687 lines
24 KiB
Rust
use crate::app::AppState;
|
|
use crate::context::RequestContext;
|
|
use crate::error::WebError;
|
|
use crate::routes::gateway::current_actor_id;
|
|
use crate::routes::local_folder_source::ensure_local_workspace_read_access_with_state;
|
|
use axum::extract::{Extension, Query, State};
|
|
use axum::http::StatusCode;
|
|
use axum::Json;
|
|
use control_plane::{NavigationRecentRecord, UpsertNavigationRecentInput};
|
|
use serde::Deserialize;
|
|
use serde_json::{json, Value};
|
|
use std::path::{Component, Path, PathBuf};
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct NavigationRecentListQuery {
|
|
#[serde(default)]
|
|
kind: Option<String>,
|
|
#[serde(default)]
|
|
limit: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct NavigationRecentUpsertRequest {
|
|
#[serde(default)]
|
|
id: Option<String>,
|
|
#[serde(default, alias = "workspace_id")]
|
|
workspace_id: Option<String>,
|
|
#[serde(default)]
|
|
kind: String,
|
|
#[serde(default, alias = "source_kind")]
|
|
source_kind: String,
|
|
#[serde(default, alias = "root_uri")]
|
|
root_uri: String,
|
|
#[serde(default, alias = "relative_path")]
|
|
relative_path: Option<String>,
|
|
#[serde(default, alias = "document_id")]
|
|
document_id: Option<String>,
|
|
#[serde(default)]
|
|
title: String,
|
|
#[serde(default, alias = "metadata_json")]
|
|
metadata_json: Option<String>,
|
|
}
|
|
|
|
pub async fn list_recent(
|
|
State(state): State<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Query(query): Query<NavigationRecentListQuery>,
|
|
) -> Result<Json<Value>, WebError> {
|
|
let actor_id = require_actor_id(&state, &context)?;
|
|
let records = load_navigation_recent_records(
|
|
&state,
|
|
&context,
|
|
&actor_id,
|
|
query.kind.as_deref(),
|
|
query.limit.unwrap_or(40),
|
|
);
|
|
Ok(Json(json!({
|
|
"ok": true,
|
|
"owner": "mnote-web",
|
|
"folders": records.iter().filter(|record| record.kind == "folder").map(recent_to_json).collect::<Vec<_>>(),
|
|
"pages": records.iter().filter(|record| record.kind == "page").map(recent_to_json).collect::<Vec<_>>(),
|
|
"items": records.iter().map(recent_to_json).collect::<Vec<_>>(),
|
|
})))
|
|
}
|
|
|
|
pub async fn upsert_recent(
|
|
State(state): State<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Json(request): Json<NavigationRecentUpsertRequest>,
|
|
) -> Result<Json<Value>, WebError> {
|
|
let actor_id = require_actor_id(&state, &context)?;
|
|
let metadata_json = normalize_metadata_json(request.metadata_json)?;
|
|
let source_kind = request
|
|
.source_kind
|
|
.trim()
|
|
.to_string()
|
|
.if_empty_else(|| "local_folder".to_string());
|
|
let root_uri = request.root_uri.trim().to_string();
|
|
let kind = request.kind.trim().to_string();
|
|
let document_id = request
|
|
.document_id
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty());
|
|
let relative_path = normalize_navigation_relative_path(request.relative_path.as_deref())?
|
|
.or_else(|| {
|
|
document_id
|
|
.as_deref()
|
|
.and_then(local_markdown_relative_path_from_document_id)
|
|
});
|
|
if source_kind == "local_folder" {
|
|
let root_path = ensure_local_workspace_read_access_with_state(&state, &context, &root_uri)?;
|
|
ensure_navigation_recent_target_exists(&root_path, &kind, relative_path.as_deref())?;
|
|
}
|
|
let record = state
|
|
.control_plane()
|
|
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
|
id: request.id,
|
|
user_id: actor_id,
|
|
workspace_id: request
|
|
.workspace_id
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty()),
|
|
kind,
|
|
source_kind,
|
|
root_uri,
|
|
relative_path,
|
|
document_id,
|
|
title: request.title.trim().to_string(),
|
|
metadata_json,
|
|
})
|
|
.map_err(|error| {
|
|
WebError::bad_request_code(
|
|
"navigation_recent_upsert_failed",
|
|
format!("写入最近访问失败: {error}"),
|
|
)
|
|
.with_context(&context)
|
|
})?;
|
|
Ok(Json(json!({
|
|
"ok": true,
|
|
"owner": "mnote-web",
|
|
"recent": recent_to_json(&record),
|
|
})))
|
|
}
|
|
|
|
pub(crate) fn load_navigation_recent_records(
|
|
state: &AppState,
|
|
context: &RequestContext,
|
|
actor_id: &str,
|
|
kind: Option<&str>,
|
|
limit: usize,
|
|
) -> Vec<NavigationRecentRecord> {
|
|
state
|
|
.control_plane()
|
|
.list_navigation_recent(actor_id, kind, limit)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|record| navigation_recent_is_accessible(state, context, record))
|
|
.collect()
|
|
}
|
|
|
|
fn navigation_recent_is_accessible(
|
|
state: &AppState,
|
|
context: &RequestContext,
|
|
record: &NavigationRecentRecord,
|
|
) -> bool {
|
|
if record.source_kind != "local_folder" {
|
|
return true;
|
|
}
|
|
let Ok(root_path) =
|
|
ensure_local_workspace_read_access_with_state(state, context, &record.root_uri)
|
|
else {
|
|
return false;
|
|
};
|
|
let relative_path = if record
|
|
.relative_path
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.is_some()
|
|
{
|
|
let Ok(relative_path) = normalize_navigation_relative_path(record.relative_path.as_deref())
|
|
else {
|
|
return false;
|
|
};
|
|
relative_path
|
|
} else {
|
|
record
|
|
.document_id
|
|
.as_deref()
|
|
.and_then(local_markdown_relative_path_from_document_id)
|
|
};
|
|
let Ok(target_path) = relative_path
|
|
.as_deref()
|
|
.map(|path| canonical_navigation_target(&root_path, path))
|
|
.transpose()
|
|
else {
|
|
return false;
|
|
};
|
|
match record.kind.as_str() {
|
|
"folder" => target_path
|
|
.as_deref()
|
|
.map(Path::is_dir)
|
|
.unwrap_or_else(|| root_path.is_dir()),
|
|
"page" => target_path.as_deref().map(Path::is_file).unwrap_or(false),
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
fn ensure_navigation_recent_target_exists(
|
|
root_path: &Path,
|
|
kind: &str,
|
|
relative_path: Option<&str>,
|
|
) -> Result<(), WebError> {
|
|
match kind {
|
|
"folder" => {
|
|
if let Some(path) = relative_path {
|
|
let target = canonical_navigation_target(root_path, path)?;
|
|
if !target.is_dir() {
|
|
return Err(WebError::bad_request_code(
|
|
"navigation_recent_folder_not_found",
|
|
"最近访问文件夹不存在",
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
"page" => {
|
|
let Some(path) = relative_path else {
|
|
return Err(WebError::bad_request_code(
|
|
"navigation_recent_page_path_required",
|
|
"最近访问页面缺少相对路径",
|
|
));
|
|
};
|
|
let target = canonical_navigation_target(root_path, path)?;
|
|
if !target.is_file() {
|
|
return Err(WebError::bad_request_code(
|
|
"navigation_recent_page_not_found",
|
|
"最近访问页面不存在",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn canonical_navigation_target(root_path: &Path, relative_path: &str) -> Result<PathBuf, WebError> {
|
|
let relative_path = normalize_navigation_relative_path(Some(relative_path))?
|
|
.as_deref()
|
|
.map(Path::new)
|
|
.map(|path| root_path.join(path))
|
|
.unwrap_or_else(|| root_path.to_path_buf());
|
|
let canonical_target = relative_path.canonicalize().map_err(|error| {
|
|
WebError::bad_request_code(
|
|
"navigation_recent_target_unavailable",
|
|
format!("最近访问目标不可用: {error}"),
|
|
)
|
|
})?;
|
|
if !canonical_target.starts_with(root_path) {
|
|
return Err(WebError::bad_request_code(
|
|
"navigation_recent_root_escape",
|
|
"最近访问路径不能越过授权目录",
|
|
));
|
|
}
|
|
Ok(canonical_target)
|
|
}
|
|
|
|
fn normalize_navigation_relative_path(value: Option<&str>) -> Result<Option<String>, WebError> {
|
|
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
return Ok(None);
|
|
};
|
|
let normalized = value.replace('\\', "/");
|
|
let raw_path = Path::new(&normalized);
|
|
if raw_path.is_absolute()
|
|
|| raw_path
|
|
.components()
|
|
.any(|component| matches!(component, Component::ParentDir))
|
|
{
|
|
return Err(WebError::bad_request_code(
|
|
"navigation_recent_root_escape",
|
|
"最近访问路径不能越过授权目录",
|
|
));
|
|
}
|
|
let cleaned = normalized
|
|
.split('/')
|
|
.filter(|segment| !segment.trim().is_empty() && *segment != ".")
|
|
.collect::<Vec<_>>()
|
|
.join("/");
|
|
Ok(Some(cleaned).filter(|value| !value.is_empty()))
|
|
}
|
|
|
|
fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<String> {
|
|
Some(
|
|
document_id
|
|
.trim()
|
|
.strip_prefix("local-md:")?
|
|
.replace("~2F", "/"),
|
|
)
|
|
}
|
|
|
|
fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<String, WebError> {
|
|
current_actor_id(state, context)
|
|
.filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous")
|
|
.ok_or_else(|| {
|
|
WebError::new(
|
|
StatusCode::UNAUTHORIZED,
|
|
"navigation_recent_auth_required",
|
|
"最近访问需要登录用户",
|
|
)
|
|
.with_context(context)
|
|
})
|
|
}
|
|
|
|
fn recent_to_json(record: &NavigationRecentRecord) -> Value {
|
|
json!({
|
|
"id": record.id,
|
|
"userId": record.user_id,
|
|
"workspaceId": record.workspace_id,
|
|
"kind": record.kind,
|
|
"sourceKind": record.source_kind,
|
|
"rootUri": record.root_uri,
|
|
"relativePath": record.relative_path,
|
|
"documentId": record.document_id,
|
|
"title": record.title,
|
|
"status": record.status,
|
|
"metadata": serde_json::from_str::<Value>(&record.metadata_json).unwrap_or(Value::Null),
|
|
"visitedAt": record.visited_at,
|
|
"createdAt": record.created_at,
|
|
"updatedAt": record.updated_at,
|
|
"revision": record.revision,
|
|
})
|
|
}
|
|
|
|
fn normalize_metadata_json(metadata_json: Option<String>) -> Result<String, WebError> {
|
|
let metadata = metadata_json
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| serde_json::from_str::<Value>(value))
|
|
.transpose()
|
|
.map_err(|error| {
|
|
WebError::bad_request_code(
|
|
"navigation_recent_metadata_invalid",
|
|
format!("最近访问 metadataJson 必须是 JSON 对象: {error}"),
|
|
)
|
|
})?
|
|
.unwrap_or_else(|| json!({}));
|
|
let normalized = if metadata.is_object() {
|
|
metadata
|
|
} else {
|
|
json!({})
|
|
};
|
|
serde_json::to_string(&normalized)
|
|
.map_err(|error| WebError::internal(format!("最近访问 metadataJson 序列化失败: {error}")))
|
|
}
|
|
|
|
trait EmptyStringExt {
|
|
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
|
}
|
|
|
|
impl EmptyStringExt for String {
|
|
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
|
|
if self.trim().is_empty() {
|
|
fallback()
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app::{build_app, AppConfig, AppState};
|
|
use axum::body::{to_bytes, Body};
|
|
use axum::http::{header, Request, StatusCode};
|
|
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
|
|
use tower::ServiceExt;
|
|
|
|
fn test_state() -> AppState {
|
|
AppState::new(AppConfig {
|
|
service_name: "mnote-web".into(),
|
|
service_version: "0.1.0".into(),
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
enable_legacy_next_compat: false,
|
|
enable_debug_shell_routes: false,
|
|
enable_editor_actor: true,
|
|
hermes_base_path: "/api/hermes".into(),
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
convex_url: None,
|
|
convex_admin_key: None,
|
|
allow_dev_fixtures: true,
|
|
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 temp_root(name: &str) -> std::path::PathBuf {
|
|
let root = std::env::temp_dir().join(format!("{name}-{}", uuid::Uuid::new_v4().simple()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(&root).expect("create temp root");
|
|
root
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn navigation_recent_requires_logged_in_actor() {
|
|
let response = build_app(test_state())
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/navigation/recent")
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn navigation_recent_post_and_get_returns_folder_and_page_groups() {
|
|
let root = temp_root("mnote-navigation-recent-api");
|
|
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
|
std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_real",
|
|
&root_uri,
|
|
)
|
|
.expect("init local workspace");
|
|
let state = test_state();
|
|
state
|
|
.control_plane()
|
|
.upsert_user(UpsertUserInput {
|
|
id: Some("user_real".to_string()),
|
|
email: Some("user_real@example.com".to_string()),
|
|
username: "user_real".to_string(),
|
|
display_name: "user_real".to_string(),
|
|
role: None,
|
|
password_hash: None,
|
|
})
|
|
.expect("upsert user");
|
|
let app = build_app(state);
|
|
|
|
let folder_body = json!({
|
|
"kind": "folder",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"relativePath": "docs",
|
|
"title": "docs"
|
|
})
|
|
.to_string();
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/navigation/recent")
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header("x-mnote-actor-id", "user_real")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(folder_body))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let page_body = json!({
|
|
"kind": "page",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"relativePath": "docs/Plan.md",
|
|
"documentId": "local-md:docs~2FPlan.md",
|
|
"title": "Plan"
|
|
})
|
|
.to_string();
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/navigation/recent")
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header("x-mnote-actor-id", "user_real")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(page_body))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/navigation/recent")
|
|
.header("x-mnote-actor-id", "user_real")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["folders"][0]["relativePath"], "docs");
|
|
assert_eq!(payload["pages"][0]["documentId"], "local-md:docs~2FPlan.md");
|
|
assert_eq!(payload["items"].as_array().map(Vec::len), Some(2));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn navigation_recent_list_filters_deleted_local_paths() {
|
|
let root = temp_root("mnote-navigation-recent-filter-deleted");
|
|
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
|
std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_real",
|
|
&root_uri,
|
|
)
|
|
.expect("init local workspace");
|
|
let state = test_state();
|
|
state
|
|
.control_plane()
|
|
.upsert_user(UpsertUserInput {
|
|
id: Some("user_real".to_string()),
|
|
email: Some("user_real@example.com".to_string()),
|
|
username: "user_real".to_string(),
|
|
display_name: "user_real".to_string(),
|
|
role: None,
|
|
password_hash: None,
|
|
})
|
|
.expect("upsert user");
|
|
state
|
|
.control_plane()
|
|
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
|
id: None,
|
|
user_id: "user_real".to_string(),
|
|
workspace_id: None,
|
|
kind: "page".to_string(),
|
|
source_kind: "local_folder".to_string(),
|
|
root_uri: root_uri.clone(),
|
|
relative_path: Some("docs/Plan.md".to_string()),
|
|
document_id: Some("local-md:docs~2FPlan.md".to_string()),
|
|
title: "Plan".to_string(),
|
|
metadata_json: "{}".to_string(),
|
|
})
|
|
.expect("insert recent");
|
|
std::fs::remove_file(root.join("docs").join("Plan.md")).expect("delete plan");
|
|
|
|
let response = build_app(state)
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/navigation/recent")
|
|
.header("x-mnote-actor-id", "user_real")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["pages"].as_array().map(Vec::len), Some(0));
|
|
assert_eq!(payload["items"].as_array().map(Vec::len), Some(0));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn navigation_recent_post_rejects_root_escape_relative_path() {
|
|
let root = temp_root("mnote-navigation-recent-reject-escape");
|
|
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_real",
|
|
&root_uri,
|
|
)
|
|
.expect("init local workspace");
|
|
let state = test_state();
|
|
state
|
|
.control_plane()
|
|
.upsert_user(UpsertUserInput {
|
|
id: Some("user_real".to_string()),
|
|
email: Some("user_real@example.com".to_string()),
|
|
username: "user_real".to_string(),
|
|
display_name: "user_real".to_string(),
|
|
role: None,
|
|
password_hash: None,
|
|
})
|
|
.expect("upsert user");
|
|
let app = build_app(state);
|
|
let body = json!({
|
|
"kind": "folder",
|
|
"sourceKind": "local_folder",
|
|
"rootUri": root_uri,
|
|
"relativePath": "../",
|
|
"title": "escape"
|
|
})
|
|
.to_string();
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/navigation/recent")
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header("x-mnote-actor-id", "user_real")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::from(body))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("x-error-code")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("navigation_recent_root_escape")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn navigation_recent_list_filters_root_escape_records() {
|
|
let root = temp_root("mnote-navigation-recent-filter-escape");
|
|
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
|
let root_uri = format!("file://{}", root.display());
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
"user_real",
|
|
&root_uri,
|
|
)
|
|
.expect("init local workspace");
|
|
let state = test_state();
|
|
state
|
|
.control_plane()
|
|
.upsert_user(UpsertUserInput {
|
|
id: Some("user_real".to_string()),
|
|
email: Some("user_real@example.com".to_string()),
|
|
username: "user_real".to_string(),
|
|
display_name: "user_real".to_string(),
|
|
role: None,
|
|
password_hash: None,
|
|
})
|
|
.expect("upsert user");
|
|
state
|
|
.control_plane()
|
|
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
|
id: None,
|
|
user_id: "user_real".to_string(),
|
|
workspace_id: None,
|
|
kind: "folder".to_string(),
|
|
source_kind: "local_folder".to_string(),
|
|
root_uri: root_uri.clone(),
|
|
relative_path: Some("../".to_string()),
|
|
document_id: None,
|
|
title: "escape".to_string(),
|
|
metadata_json: "{}".to_string(),
|
|
})
|
|
.expect("insert escaped recent through store");
|
|
|
|
let response = build_app(state)
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/navigation/recent")
|
|
.header("x-mnote-actor-id", "user_real")
|
|
.header("x-mnote-actor-type", "user")
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["folders"].as_array().map(Vec::len), Some(0));
|
|
assert_eq!(payload["items"].as_array().map(Vec::len), Some(0));
|
|
}
|
|
}
|