api_search.rs
| 1 | //! Search API: one walk serves both scopes, streamed as SSE. |
| 2 | |
| 3 | mod common; |
| 4 | |
| 5 | use axum::http::StatusCode; |
| 6 | use common::*; |
| 7 | |
| 8 | /// Root id for the whole-root (".") user root is 1 (first row inserted). |
| 9 | const ROOT: i64 = 1; |
| 10 | |
| 11 | /// The `data:` payloads of an SSE body, in order. |
| 12 | fn events(body: &str) -> Vec<serde_json::Value> { |
| 13 | body.lines() |
| 14 | .filter_map(|l| l.strip_prefix("data:")) |
| 15 | .map(|d| serde_json::from_str(d.trim()).expect("event is JSON")) |
| 16 | .collect() |
| 17 | } |
| 18 | |
| 19 | #[tokio::test] |
| 20 | async fn both_scopes_stream_from_one_walk() { |
| 21 | let env = Env::new().await; |
| 22 | let admin = env.admin().await; |
| 23 | let r = admin |
| 24 | .get(&format!("/api/search?q=hello&scope=both&root={ROOT}")) |
| 25 | .await; |
| 26 | assert_eq!(r.status, StatusCode::OK); |
| 27 | assert_eq!( |
| 28 | r.header("content-type").as_deref(), |
| 29 | Some("text/event-stream") |
| 30 | ); |
| 31 | assert_eq!(r.header("x-accel-buffering").as_deref(), Some("no")); |
| 32 | |
| 33 | let evs = events(&r.text()); |
| 34 | // The name hit: matched on the entry's own name, with the server's |
| 35 | // sniffed kind. |
| 36 | let file = evs |
| 37 | .iter() |
| 38 | .find(|e| e["type"] == "file") |
| 39 | .expect("a name hit"); |
| 40 | assert_eq!(file["path"], "docs/inner/hello.txt"); |
| 41 | assert_eq!(file["kind"], "text"); |
| 42 | assert_eq!(file["is_dir"], false); |
| 43 | // The content hit, from the same walk. |
| 44 | let m = evs |
| 45 | .iter() |
| 46 | .find(|e| e["type"] == "match") |
| 47 | .expect("a content hit"); |
| 48 | assert_eq!(m["path"], "docs/inner/hello.txt"); |
| 49 | assert_eq!(m["line"], 1); |
| 50 | assert_eq!(m["text"], "hello world"); |
| 51 | // The stream always ends with the summary. |
| 52 | let done = evs.last().expect("a done event"); |
| 53 | assert_eq!(done["type"], "done"); |
| 54 | assert_eq!(done["stopped"], false); |
| 55 | assert!(done["scanned"].as_u64().unwrap() >= 8); |
| 56 | } |
| 57 | |
| 58 | #[tokio::test] |
| 59 | async fn path_narrows_the_walk_to_a_subfolder() { |
| 60 | let env = Env::new().await; |
| 61 | let admin = env.admin().await; |
| 62 | let paths = |body: String| -> Vec<String> { |
| 63 | events(&body) |
| 64 | .iter() |
| 65 | .filter(|e| e["type"] == "file") |
| 66 | .map(|e| e["path"].as_str().unwrap().to_string()) |
| 67 | .collect() |
| 68 | }; |
| 69 | // Inside `docs`: the hit is found and its path stays root-relative. |
| 70 | let r = admin |
| 71 | .get(&format!( |
| 72 | "/api/search?q=hello&scope=name&root={ROOT}&path=docs" |
| 73 | )) |
| 74 | .await; |
| 75 | assert_eq!(r.status, StatusCode::OK); |
| 76 | assert_eq!(paths(r.text()), vec!["docs/inner/hello.txt".to_string()]); |
| 77 | // The start folder itself is not a result. |
| 78 | let r = admin |
| 79 | .get(&format!( |
| 80 | "/api/search?q=docs&scope=name&root={ROOT}&path=docs" |
| 81 | )) |
| 82 | .await; |
| 83 | assert!(paths(r.text()).is_empty()); |
| 84 | // Outside `docs`: nothing. |
| 85 | let r = admin |
| 86 | .get(&format!( |
| 87 | "/api/search?q=hello&scope=name&root={ROOT}&path=src" |
| 88 | )) |
| 89 | .await; |
| 90 | assert!(paths(r.text()).is_empty()); |
| 91 | // A missing or escaping start folder is rejected. |
| 92 | let r = admin |
| 93 | .get(&format!("/api/search?q=hello&root={ROOT}&path=nope")) |
| 94 | .await; |
| 95 | assert!(r.status.is_client_error()); |
| 96 | let r = admin |
| 97 | .get(&format!("/api/search?q=hello&root={ROOT}&path=../")) |
| 98 | .await; |
| 99 | assert!(r.status.is_client_error()); |
| 100 | } |
| 101 | |
| 102 | #[tokio::test] |
| 103 | async fn name_scope_ignores_the_parent_path() { |
| 104 | let env = Env::new().await; |
| 105 | let admin = env.admin().await; |
| 106 | let r = admin |
| 107 | .get(&format!("/api/search?q=docs&scope=name&root={ROOT}")) |
| 108 | .await; |
| 109 | let paths: Vec<String> = events(&r.text()) |
| 110 | .iter() |
| 111 | .filter(|e| e["type"] == "file") |
| 112 | .map(|e| e["path"].as_str().unwrap().to_string()) |
| 113 | .collect(); |
| 114 | // The folder itself, never the files inside it. |
| 115 | assert_eq!(paths, vec!["docs".to_string()]); |
| 116 | } |
| 117 | |
| 118 | #[tokio::test] |
| 119 | async fn search_rejects_bad_input_and_anonymous_callers() { |
| 120 | let env = Env::new().await; |
| 121 | let admin = env.admin().await; |
| 122 | let anon = Client::new(env.app.clone()); |
| 123 | |
| 124 | assert_eq!( |
| 125 | anon.get("/api/search?q=hello").await.status, |
| 126 | StatusCode::UNAUTHORIZED |
| 127 | ); |
| 128 | assert_eq!( |
| 129 | admin.get("/api/search?q=%20").await.status, |
| 130 | StatusCode::BAD_REQUEST |
| 131 | ); |
| 132 | assert_eq!( |
| 133 | admin.get("/api/search?q=hello&scope=nope").await.status, |
| 134 | StatusCode::BAD_REQUEST |
| 135 | ); |
| 136 | assert_eq!( |
| 137 | admin.get("/api/search?q=hello&root=999").await.status, |
| 138 | StatusCode::FORBIDDEN |
| 139 | ); |
| 140 | } |
| 141 | |
| 142 | #[tokio::test] |
| 143 | async fn hidden_and_gitignored_entries_are_searched() { |
| 144 | let env = Env::new().await; |
| 145 | let p = env.root.path(); |
| 146 | std::fs::create_dir_all(p.join(".hidden")).unwrap(); |
| 147 | std::fs::write(p.join(".hidden/secretnote.txt"), "needle here").unwrap(); |
| 148 | std::fs::write(p.join("ignoredfile.log"), "needle here").unwrap(); |
| 149 | std::fs::write(p.join(".gitignore"), "*.log\n").unwrap(); |
| 150 | let admin = env.admin().await; |
| 151 | |
| 152 | let r = admin |
| 153 | .get(&format!("/api/search?q=needle&scope=content&root={ROOT}")) |
| 154 | .await; |
| 155 | let evs = events(&r.text()); |
| 156 | let paths: Vec<String> = evs |
| 157 | .iter() |
| 158 | .filter_map(|e| e["path"].as_str().map(str::to_string)) |
| 159 | .collect(); |
| 160 | assert!( |
| 161 | paths.contains(&".hidden/secretnote.txt".to_string()), |
| 162 | "{paths:?}" |
| 163 | ); |
| 164 | assert!(paths.contains(&"ignoredfile.log".to_string()), "{paths:?}"); |
| 165 | } |
| 166 |