api_search.rs
⎇
Raw
1//! Search API: one walk serves both scopes, streamed as SSE.
2
3mod common;
4
5use axum::http::StatusCode;
6use common::*;
7
8/// Root id for the whole-root (".") user root is 1 (first row inserted).
9const ROOT: i64 = 1;
10
11/// The `data:` payloads of an SSE body, in order.
12fn 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]
20async 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]
59async 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]
103async 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]
119async 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]
143async 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
167/// An excluded folder is invisible to search: not as a name hit, and not as
168/// a source of content hits from inside it.
169#[tokio::test]
170async fn excluded_folders_never_appear_in_results() {
171 let env = Env::new().await;
172 let admin = env.admin().await;
173
174 // Baseline: "docs" and the file under it are both findable.
175 let r = admin
176 .get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
177 .await;
178 let paths: Vec<String> = events(&r.text())
179 .iter()
180 .filter(|e| e["type"] == "file")
181 .map(|e| e["path"].as_str().unwrap_or_default().to_string())
182 .collect();
183 assert!(paths.contains(&"docs/inner/hello.txt".to_string()));
184
185 let r = admin
186 .put_json(
187 "/api/admin/settings",
188 &serde_json::json!({
189 "allow_writable_shares": false,
190 // Normalisation: the stored form has no surrounding slashes.
191 "search_excludes": ["/docs/"],
192 }),
193 )
194 .await;
195 assert_eq!(r.status, StatusCode::OK, "settings: {}", r.text());
196 assert_eq!(r.json()["search_excludes"][0], "docs");
197
198 let r = admin
199 .get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
200 .await;
201 let evs = events(&r.text());
202 for e in &evs {
203 let p = e["path"].as_str().unwrap_or_default();
204 assert!(
205 !p.starts_with("docs"),
206 "excluded folder leaked into results: {e}"
207 );
208 }
209 // A search for the folder's own name finds nothing either.
210 let r = admin
211 .get(&format!("/api/search?q=docs&scope=name&root={ROOT}"))
212 .await;
213 assert!(
214 !events(&r.text()).iter().any(|e| e["type"] == "file"),
215 "the excluded folder itself was still listed"
216 );
217
218 // Everything outside it is untouched.
219 let r = admin
220 .get(&format!("/api/search?q=main&scope=name&root={ROOT}"))
221 .await;
222 assert!(
223 events(&r.text()).iter().any(|e| e["path"] == "src/main.rs"),
224 "an unrelated folder was excluded too"
225 );
226}
227
228/// "." would exclude the whole root, which turns search off rather than
229/// narrowing it. Blank and duplicate entries are dropped the same way.
230#[tokio::test]
231async fn exclude_list_is_normalised() {
232 let env = Env::new().await;
233 let admin = env.admin().await;
234 let r = admin
235 .put_json(
236 "/api/admin/settings",
237 &serde_json::json!({
238 "allow_writable_shares": false,
239 "search_excludes": [".", "", " ", "docs", "docs/", "/src"],
240 }),
241 )
242 .await;
243 assert_eq!(r.status, StatusCode::OK);
244 let got = r.json();
245 let list: Vec<String> = got["search_excludes"]
246 .as_array()
247 .unwrap()
248 .iter()
249 .map(|v| v.as_str().unwrap().to_string())
250 .collect();
251 assert_eq!(list, vec!["docs".to_string(), "src".to_string()]);
252
253 // And it survives a round trip.
254 let r = admin.get("/api/admin/settings").await;
255 assert_eq!(r.json()["search_excludes"], got["search_excludes"]);
256}
257
258/// Windows-style separators must survive normalisation. Trimming the
259/// slashes before converting the backslashes left `\docs\` stored as
260/// `/docs/`, which then matched nothing.
261#[tokio::test]
262async fn backslash_paths_are_normalised_before_trimming() {
263 let env = Env::new().await;
264 let admin = env.admin().await;
265 let r = admin
266 .put_json(
267 "/api/admin/settings",
268 &serde_json::json!({
269 "allow_writable_shares": false,
270 "search_excludes": ["\\docs\\"],
271 }),
272 )
273 .await;
274 assert_eq!(r.status, StatusCode::OK);
275 assert_eq!(r.json()["search_excludes"][0], "docs");
276
277 // And it actually excludes.
278 let r = admin
279 .get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
280 .await;
281 for e in events(&r.text()) {
282 let p = e["path"].as_str().unwrap_or_default();
283 assert!(!p.starts_with("docs"), "not excluded: {e}");
284 }
285}
286