//! 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:?}"); }