//! Search API: one walk serves both scopes, streamed as SSE. mod common; use axum::http::StatusCode; use common::*; /// Root id for the whole-root (".") user root is 1 (first row inserted). const ROOT: i64 = 1; /// The `data:` payloads of an SSE body, in order. fn events(body: &str) -> Vec { body.lines() .filter_map(|l| l.strip_prefix("data:")) .map(|d| serde_json::from_str(d.trim()).expect("event is JSON")) .collect() } #[tokio::test] async fn both_scopes_stream_from_one_walk() { let env = Env::new().await; let admin = env.admin().await; let r = admin .get(&format!("/api/search?q=hello&scope=both&root={ROOT}")) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!( r.header("content-type").as_deref(), Some("text/event-stream") ); assert_eq!(r.header("x-accel-buffering").as_deref(), Some("no")); let evs = events(&r.text()); // The name hit: matched on the entry's own name, with the server's // sniffed kind. let file = evs .iter() .find(|e| e["type"] == "file") .expect("a name hit"); assert_eq!(file["path"], "docs/inner/hello.txt"); assert_eq!(file["kind"], "text"); assert_eq!(file["is_dir"], false); // The content hit, from the same walk. let m = evs .iter() .find(|e| e["type"] == "match") .expect("a content hit"); assert_eq!(m["path"], "docs/inner/hello.txt"); assert_eq!(m["line"], 1); assert_eq!(m["text"], "hello world"); // The stream always ends with the summary. let done = evs.last().expect("a done event"); assert_eq!(done["type"], "done"); assert_eq!(done["stopped"], false); assert!(done["scanned"].as_u64().unwrap() >= 8); } #[tokio::test] async fn path_narrows_the_walk_to_a_subfolder() { let env = Env::new().await; let admin = env.admin().await; let paths = |body: String| -> Vec { events(&body) .iter() .filter(|e| e["type"] == "file") .map(|e| e["path"].as_str().unwrap().to_string()) .collect() }; // Inside `docs`: the hit is found and its path stays root-relative. let r = admin .get(&format!( "/api/search?q=hello&scope=name&root={ROOT}&path=docs" )) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(paths(r.text()), vec!["docs/inner/hello.txt".to_string()]); // The start folder itself is not a result. let r = admin .get(&format!( "/api/search?q=docs&scope=name&root={ROOT}&path=docs" )) .await; assert!(paths(r.text()).is_empty()); // Outside `docs`: nothing. let r = admin .get(&format!( "/api/search?q=hello&scope=name&root={ROOT}&path=src" )) .await; assert!(paths(r.text()).is_empty()); // A missing or escaping start folder is rejected. let r = admin .get(&format!("/api/search?q=hello&root={ROOT}&path=nope")) .await; assert!(r.status.is_client_error()); let r = admin .get(&format!("/api/search?q=hello&root={ROOT}&path=../")) .await; assert!(r.status.is_client_error()); } #[tokio::test] async fn name_scope_ignores_the_parent_path() { let env = Env::new().await; let admin = env.admin().await; let r = admin .get(&format!("/api/search?q=docs&scope=name&root={ROOT}")) .await; let paths: Vec = events(&r.text()) .iter() .filter(|e| e["type"] == "file") .map(|e| e["path"].as_str().unwrap().to_string()) .collect(); // The folder itself, never the files inside it. assert_eq!(paths, vec!["docs".to_string()]); } #[tokio::test] async fn search_rejects_bad_input_and_anonymous_callers() { let env = Env::new().await; let admin = env.admin().await; let anon = Client::new(env.app.clone()); assert_eq!( anon.get("/api/search?q=hello").await.status, StatusCode::UNAUTHORIZED ); assert_eq!( admin.get("/api/search?q=%20").await.status, StatusCode::BAD_REQUEST ); assert_eq!( admin.get("/api/search?q=hello&scope=nope").await.status, StatusCode::BAD_REQUEST ); assert_eq!( admin.get("/api/search?q=hello&root=999").await.status, StatusCode::FORBIDDEN ); } #[tokio::test] async fn hidden_and_gitignored_entries_are_searched() { let env = Env::new().await; let p = env.root.path(); std::fs::create_dir_all(p.join(".hidden")).unwrap(); std::fs::write(p.join(".hidden/secretnote.txt"), "needle here").unwrap(); std::fs::write(p.join("ignoredfile.log"), "needle here").unwrap(); std::fs::write(p.join(".gitignore"), "*.log\n").unwrap(); let admin = env.admin().await; let r = admin .get(&format!("/api/search?q=needle&scope=content&root={ROOT}")) .await; let evs = events(&r.text()); let paths: Vec = evs .iter() .filter_map(|e| e["path"].as_str().map(str::to_string)) .collect(); assert!( paths.contains(&".hidden/secretnote.txt".to_string()), "{paths:?}" ); assert!(paths.contains(&"ignoredfile.log".to_string()), "{paths:?}"); } /// An excluded folder is invisible to search: not as a name hit, and not as /// a source of content hits from inside it. #[tokio::test] async fn excluded_folders_never_appear_in_results() { let env = Env::new().await; let admin = env.admin().await; // Baseline: "docs" and the file under it are both findable. let r = admin .get(&format!("/api/search?q=hello&scope=both&root={ROOT}")) .await; let paths: Vec = events(&r.text()) .iter() .filter(|e| e["type"] == "file") .map(|e| e["path"].as_str().unwrap_or_default().to_string()) .collect(); assert!(paths.contains(&"docs/inner/hello.txt".to_string())); let r = admin .put_json( "/api/admin/settings", &serde_json::json!({ "allow_writable_shares": false, // Normalisation: the stored form has no surrounding slashes. "search_excludes": ["/docs/"], }), ) .await; assert_eq!(r.status, StatusCode::OK, "settings: {}", r.text()); assert_eq!(r.json()["search_excludes"][0], "docs"); let r = admin .get(&format!("/api/search?q=hello&scope=both&root={ROOT}")) .await; let evs = events(&r.text()); for e in &evs { let p = e["path"].as_str().unwrap_or_default(); assert!( !p.starts_with("docs"), "excluded folder leaked into results: {e}" ); } // A search for the folder's own name finds nothing either. let r = admin .get(&format!("/api/search?q=docs&scope=name&root={ROOT}")) .await; assert!( !events(&r.text()).iter().any(|e| e["type"] == "file"), "the excluded folder itself was still listed" ); // Everything outside it is untouched. let r = admin .get(&format!("/api/search?q=main&scope=name&root={ROOT}")) .await; assert!( events(&r.text()).iter().any(|e| e["path"] == "src/main.rs"), "an unrelated folder was excluded too" ); } /// "." would exclude the whole root, which turns search off rather than /// narrowing it. Blank and duplicate entries are dropped the same way. #[tokio::test] async fn exclude_list_is_normalised() { let env = Env::new().await; let admin = env.admin().await; let r = admin .put_json( "/api/admin/settings", &serde_json::json!({ "allow_writable_shares": false, "search_excludes": [".", "", " ", "docs", "docs/", "/src"], }), ) .await; assert_eq!(r.status, StatusCode::OK); let got = r.json(); let list: Vec = got["search_excludes"] .as_array() .unwrap() .iter() .map(|v| v.as_str().unwrap().to_string()) .collect(); assert_eq!(list, vec!["docs".to_string(), "src".to_string()]); // And it survives a round trip. let r = admin.get("/api/admin/settings").await; assert_eq!(r.json()["search_excludes"], got["search_excludes"]); } /// Windows-style separators must survive normalisation. Trimming the /// slashes before converting the backslashes left `\docs\` stored as /// `/docs/`, which then matched nothing. #[tokio::test] async fn backslash_paths_are_normalised_before_trimming() { let env = Env::new().await; let admin = env.admin().await; let r = admin .put_json( "/api/admin/settings", &serde_json::json!({ "allow_writable_shares": false, "search_excludes": ["\\docs\\"], }), ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.json()["search_excludes"][0], "docs"); // And it actually excludes. let r = admin .get(&format!("/api/search?q=hello&scope=both&root={ROOT}")) .await; for e in events(&r.text()) { let p = e["path"].as_str().unwrap_or_default(); assert!(!p.starts_with("docs"), "not excluded: {e}"); } }