Skip to content

Commit bd2ca92

Browse files
committed
test(crawler/nuget): 15 integration tests for find_by_purls + crawl_all + paths
New `crawler_nuget_e2e.rs` covering the nuget crawler's biggest integration coverage gap (41% -> targeted improvement): - `find_by_purls`: global cache layout, legacy layout, case-mismatched name, no-match empty result, non-nuget PURL skip, lib/-marker-only vs nuspec-only vs neither (verify_nuget_package coverage) - `crawl_all` via `scan_package_dir`: global cache discovery, legacy layout discovery, hidden-dir skip - `get_nuget_package_paths`: global_prefix override, `packages/` local discovery, `.csproj` triggers global fallback, `.sln` triggers global fallback, non-.NET dir returns empty - The case-insensitivity contract holds on both case-insensitive (APFS default) and case-sensitive (ext4) filesystems Tests use NUGET_PACKAGES env-var stubbing with `#[serial]` guards to prevent races between parallel tests mutating shared state. Assisted-by: Claude Code:claude-opus-4-7
1 parent 690e648 commit bd2ca92

1 file changed

Lines changed: 348 additions & 0 deletions

File tree

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
//! Integration coverage for `crawlers::nuget_crawler`. The
2+
//! apply-CLI suite drives the global-cache `find_by_purls` happy
3+
//! path with `SOCKET_EXPERIMENTAL_NUGET=1`; everything else here —
4+
//! legacy `Packages/<Name>.<Version>` layout, case-insensitive
5+
//! lookup, `crawl_all` directory scanning, `scan_package_dir`'s
6+
//! hidden-dir skip, `get_nuget_package_paths` discovery branches —
7+
//! goes uncovered without these tests.
8+
9+
#![cfg(feature = "nuget")]
10+
11+
use std::path::Path;
12+
13+
use serial_test::serial;
14+
use socket_patch_core::crawlers::types::CrawlerOptions;
15+
use socket_patch_core::crawlers::NuGetCrawler;
16+
17+
const ORG_PURL_A: &str = "pkg:nuget/Newtonsoft.Json@13.0.3";
18+
const ORG_PURL_B: &str = "pkg:nuget/Serilog@4.0.0";
19+
20+
fn options_at(root: &Path) -> CrawlerOptions {
21+
CrawlerOptions {
22+
cwd: root.to_path_buf(),
23+
global: false,
24+
global_prefix: None,
25+
batch_size: 100,
26+
}
27+
}
28+
29+
/// Stage a global-cache layout: <root>/<lowercase>/<version>/ with
30+
/// a minimal `.nuspec` so verify_nuget_package returns true.
31+
async fn stage_global_cache_pkg(root: &Path, name: &str, version: &str) -> std::path::PathBuf {
32+
let pkg_dir = root.join(name.to_lowercase()).join(version);
33+
tokio::fs::create_dir_all(&pkg_dir).await.unwrap();
34+
tokio::fs::write(
35+
pkg_dir.join(format!("{}.nuspec", name.to_lowercase())),
36+
format!(
37+
r#"<?xml version="1.0"?><package><metadata><id>{name}</id><version>{version}</version></metadata></package>"#
38+
),
39+
)
40+
.await
41+
.unwrap();
42+
pkg_dir
43+
}
44+
45+
/// Stage a legacy <Name>.<Version> layout. Used by older
46+
/// `packages.config` projects.
47+
async fn stage_legacy_pkg(root: &Path, name: &str, version: &str) -> std::path::PathBuf {
48+
let pkg_dir = root.join(format!("{name}.{version}"));
49+
tokio::fs::create_dir_all(pkg_dir.join("lib")).await.unwrap();
50+
tokio::fs::write(
51+
pkg_dir.join(format!("{name}.nuspec")),
52+
format!(
53+
r#"<?xml version="1.0"?><package><metadata><id>{name}</id><version>{version}</version></metadata></package>"#
54+
),
55+
)
56+
.await
57+
.unwrap();
58+
pkg_dir
59+
}
60+
61+
// ── find_by_purls ──────────────────────────────────────────────
62+
63+
#[tokio::test]
64+
async fn find_by_purls_global_cache_layout_finds_package() {
65+
let tmp = tempfile::tempdir().unwrap();
66+
let pkg_dir = stage_global_cache_pkg(tmp.path(), "Newtonsoft.Json", "13.0.3").await;
67+
68+
let crawler = NuGetCrawler;
69+
let result = crawler
70+
.find_by_purls(tmp.path(), &[ORG_PURL_A.to_string()])
71+
.await
72+
.unwrap();
73+
assert_eq!(result.len(), 1);
74+
let pkg = result.get(ORG_PURL_A).expect("must find by purl");
75+
assert_eq!(pkg.path, pkg_dir);
76+
assert_eq!(pkg.name, "Newtonsoft.Json");
77+
assert_eq!(pkg.version, "13.0.3");
78+
}
79+
80+
#[tokio::test]
81+
async fn find_by_purls_legacy_layout_finds_package() {
82+
let tmp = tempfile::tempdir().unwrap();
83+
let pkg_dir = stage_legacy_pkg(tmp.path(), "Newtonsoft.Json", "13.0.3").await;
84+
85+
let crawler = NuGetCrawler;
86+
let result = crawler
87+
.find_by_purls(tmp.path(), &[ORG_PURL_A.to_string()])
88+
.await
89+
.unwrap();
90+
assert_eq!(result.len(), 1);
91+
assert_eq!(result.get(ORG_PURL_A).unwrap().path, pkg_dir);
92+
}
93+
94+
/// PURL with a case-mismatched name. NuGet package names are
95+
/// case-insensitive — the case-insensitive legacy scan must locate
96+
/// the package even when only a differently-cased dir exists.
97+
///
98+
/// On case-insensitive filesystems (default macOS APFS), this exercises
99+
/// the same fast-path `legacy_dir` branch since the filesystem itself
100+
/// folds names. On case-sensitive filesystems (Linux ext4), the
101+
/// case-insensitive scan branch fires.
102+
#[tokio::test]
103+
async fn find_by_purls_case_insensitive_legacy_layout() {
104+
let tmp = tempfile::tempdir().unwrap();
105+
let _pkg_dir = stage_legacy_pkg(tmp.path(), "newtonsoft.json", "13.0.3").await;
106+
107+
let crawler = NuGetCrawler;
108+
let result = crawler
109+
.find_by_purls(tmp.path(), &[ORG_PURL_A.to_string()])
110+
.await
111+
.unwrap();
112+
assert_eq!(result.len(), 1, "package must be found via either fast or case-insensitive path");
113+
let found = result.get(ORG_PURL_A).unwrap();
114+
// Either casing is acceptable; the contract is "matched something".
115+
assert!(found.path.exists(), "returned path must exist; got {:?}", found.path);
116+
}
117+
118+
#[tokio::test]
119+
async fn find_by_purls_no_match_returns_empty() {
120+
let tmp = tempfile::tempdir().unwrap();
121+
// Empty dir — no packages.
122+
let crawler = NuGetCrawler;
123+
let result = crawler
124+
.find_by_purls(tmp.path(), &[ORG_PURL_A.to_string()])
125+
.await
126+
.unwrap();
127+
assert!(result.is_empty());
128+
}
129+
130+
#[tokio::test]
131+
async fn find_by_purls_invalid_purl_skipped() {
132+
let tmp = tempfile::tempdir().unwrap();
133+
stage_global_cache_pkg(tmp.path(), "Newtonsoft.Json", "13.0.3").await;
134+
let crawler = NuGetCrawler;
135+
let result = crawler
136+
.find_by_purls(
137+
tmp.path(),
138+
&["pkg:not-nuget/Foo@1.0".to_string()],
139+
)
140+
.await
141+
.unwrap();
142+
assert!(result.is_empty(), "non-nuget PURLs must be skipped");
143+
}
144+
145+
// ── crawl_all (scan_package_dir) ───────────────────────────────
146+
147+
#[tokio::test]
148+
async fn crawl_all_discovers_global_cache_layout() {
149+
let tmp = tempfile::tempdir().unwrap();
150+
stage_global_cache_pkg(tmp.path(), "Newtonsoft.Json", "13.0.3").await;
151+
stage_global_cache_pkg(tmp.path(), "Serilog", "4.0.0").await;
152+
153+
let crawler = NuGetCrawler;
154+
// Use --global-prefix to point at our staged root.
155+
let opts = CrawlerOptions {
156+
cwd: tmp.path().to_path_buf(),
157+
global: true,
158+
global_prefix: Some(tmp.path().to_path_buf()),
159+
batch_size: 100,
160+
};
161+
let result = crawler.crawl_all(&opts).await;
162+
assert_eq!(result.len(), 2);
163+
// The crawler lowercases the discovered name from the directory.
164+
let purls: Vec<String> = result
165+
.iter()
166+
.map(|p| p.purl.to_ascii_lowercase())
167+
.collect();
168+
assert!(purls.iter().any(|p| p.contains("newtonsoft.json")));
169+
assert!(purls.iter().any(|p| p.contains("serilog")));
170+
}
171+
172+
#[tokio::test]
173+
async fn crawl_all_discovers_legacy_layout() {
174+
let tmp = tempfile::tempdir().unwrap();
175+
stage_legacy_pkg(tmp.path(), "Newtonsoft.Json", "13.0.3").await;
176+
stage_legacy_pkg(tmp.path(), "Serilog", "4.0.0").await;
177+
178+
let crawler = NuGetCrawler;
179+
let opts = CrawlerOptions {
180+
cwd: tmp.path().to_path_buf(),
181+
global: true,
182+
global_prefix: Some(tmp.path().to_path_buf()),
183+
batch_size: 100,
184+
};
185+
let result = crawler.crawl_all(&opts).await;
186+
assert!(result.len() >= 2, "legacy layout must be discovered; got {result:?}");
187+
}
188+
189+
#[tokio::test]
190+
async fn crawl_all_skips_hidden_directories() {
191+
let tmp = tempfile::tempdir().unwrap();
192+
// Real package.
193+
stage_global_cache_pkg(tmp.path(), "Newtonsoft.Json", "13.0.3").await;
194+
// Hidden dir that mimics a package layout — must be skipped.
195+
let hidden = tmp.path().join(".cache").join("13.0.3");
196+
tokio::fs::create_dir_all(&hidden).await.unwrap();
197+
tokio::fs::write(hidden.join(".cache.nuspec"), b"<package/>").await.unwrap();
198+
199+
let crawler = NuGetCrawler;
200+
let opts = CrawlerOptions {
201+
cwd: tmp.path().to_path_buf(),
202+
global: true,
203+
global_prefix: Some(tmp.path().to_path_buf()),
204+
batch_size: 100,
205+
};
206+
let result = crawler.crawl_all(&opts).await;
207+
// Only the real package should show up.
208+
assert_eq!(result.len(), 1);
209+
assert!(
210+
result[0].purl.to_ascii_lowercase().contains("newtonsoft.json"),
211+
"expected newtonsoft.json; got {:?}",
212+
result[0].purl
213+
);
214+
}
215+
216+
// ── get_nuget_package_paths ─────────────────────────────────────
217+
218+
#[tokio::test]
219+
#[serial]
220+
async fn get_nuget_package_paths_with_global_prefix_returns_only_prefix() {
221+
let tmp = tempfile::tempdir().unwrap();
222+
let crawler = NuGetCrawler;
223+
let opts = CrawlerOptions {
224+
cwd: tmp.path().to_path_buf(),
225+
global: true,
226+
global_prefix: Some(tmp.path().to_path_buf()),
227+
batch_size: 100,
228+
};
229+
let paths = crawler.get_nuget_package_paths(&opts).await.unwrap();
230+
assert_eq!(paths, vec![tmp.path().to_path_buf()]);
231+
}
232+
233+
#[tokio::test]
234+
#[serial]
235+
async fn get_nuget_package_paths_local_discovers_packages_dir() {
236+
let tmp = tempfile::tempdir().unwrap();
237+
let pkg = tmp.path().join("packages");
238+
tokio::fs::create_dir_all(&pkg).await.unwrap();
239+
240+
let crawler = NuGetCrawler;
241+
let paths = crawler.get_nuget_package_paths(&options_at(tmp.path())).await.unwrap();
242+
assert!(paths.iter().any(|p| p == &pkg), "packages/ must be discovered; got {paths:?}");
243+
}
244+
245+
#[tokio::test]
246+
#[serial]
247+
async fn get_nuget_package_paths_local_with_csproj_falls_back_to_global() {
248+
let tmp = tempfile::tempdir().unwrap();
249+
// Marker file that triggers .NET-project detection.
250+
tokio::fs::write(
251+
tmp.path().join("MyProj.csproj"),
252+
r#"<Project Sdk="Microsoft.NET.Sdk"></Project>"#,
253+
)
254+
.await
255+
.unwrap();
256+
// Stub NUGET_PACKAGES to a writable temp location.
257+
let nuget_root = tempfile::tempdir().unwrap();
258+
let prev = std::env::var("NUGET_PACKAGES").ok();
259+
std::env::set_var("NUGET_PACKAGES", nuget_root.path());
260+
261+
let crawler = NuGetCrawler;
262+
let paths = crawler.get_nuget_package_paths(&options_at(tmp.path())).await.unwrap();
263+
264+
std::env::remove_var("NUGET_PACKAGES");
265+
if let Some(v) = prev {
266+
std::env::set_var("NUGET_PACKAGES", v);
267+
}
268+
269+
assert!(
270+
paths.iter().any(|p| p == nuget_root.path()),
271+
"csproj must trigger global-cache fallback; got {paths:?}"
272+
);
273+
}
274+
275+
#[tokio::test]
276+
#[serial]
277+
async fn get_nuget_package_paths_local_no_project_returns_empty() {
278+
let tmp = tempfile::tempdir().unwrap();
279+
// No `packages/`, no `.csproj`, no `.sln`, no `obj/`.
280+
let crawler = NuGetCrawler;
281+
let paths = crawler.get_nuget_package_paths(&options_at(tmp.path())).await.unwrap();
282+
assert!(paths.is_empty(), "non-.NET dir must return empty paths");
283+
}
284+
285+
#[tokio::test]
286+
#[serial]
287+
async fn get_nuget_package_paths_with_sln_falls_back_to_global() {
288+
let tmp = tempfile::tempdir().unwrap();
289+
tokio::fs::write(tmp.path().join("MySolution.sln"), b"Microsoft Visual Studio Solution File")
290+
.await
291+
.unwrap();
292+
let nuget_root = tempfile::tempdir().unwrap();
293+
let prev = std::env::var("NUGET_PACKAGES").ok();
294+
std::env::set_var("NUGET_PACKAGES", nuget_root.path());
295+
296+
let crawler = NuGetCrawler;
297+
let paths = crawler.get_nuget_package_paths(&options_at(tmp.path())).await.unwrap();
298+
299+
std::env::remove_var("NUGET_PACKAGES");
300+
if let Some(v) = prev {
301+
std::env::set_var("NUGET_PACKAGES", v);
302+
}
303+
304+
assert!(
305+
paths.iter().any(|p| p == nuget_root.path()),
306+
".sln must trigger global-cache fallback"
307+
);
308+
}
309+
310+
// ── verify_nuget_package indirectly via find_by_purls ───────────
311+
312+
#[tokio::test]
313+
async fn find_by_purls_rejects_dir_without_nuspec_or_lib() {
314+
let tmp = tempfile::tempdir().unwrap();
315+
// Create a global-cache-shaped dir but with neither .nuspec nor lib/ — verify fails.
316+
let pkg_dir = tmp.path().join("newtonsoft.json").join("13.0.3");
317+
tokio::fs::create_dir_all(&pkg_dir).await.unwrap();
318+
// No .nuspec, no lib/ — just an unrelated file.
319+
tokio::fs::write(pkg_dir.join("README.md"), b"hello").await.unwrap();
320+
321+
let crawler = NuGetCrawler;
322+
let result = crawler
323+
.find_by_purls(tmp.path(), &[ORG_PURL_A.to_string()])
324+
.await
325+
.unwrap();
326+
assert!(result.is_empty(), "dir without nuspec or lib/ must not match");
327+
}
328+
329+
#[tokio::test]
330+
async fn find_by_purls_with_lib_dir_marker_succeeds() {
331+
let tmp = tempfile::tempdir().unwrap();
332+
let pkg_dir = tmp.path().join("newtonsoft.json").join("13.0.3");
333+
tokio::fs::create_dir_all(pkg_dir.join("lib")).await.unwrap();
334+
// No .nuspec but lib/ is present — verify accepts it.
335+
336+
let crawler = NuGetCrawler;
337+
let result = crawler
338+
.find_by_purls(tmp.path(), &[ORG_PURL_A.to_string()])
339+
.await
340+
.unwrap();
341+
assert_eq!(result.len(), 1);
342+
}
343+
344+
// Marker so ORG_PURL_B import isn't unused.
345+
#[allow(dead_code)]
346+
fn _used_in_doc() -> &'static str {
347+
ORG_PURL_B
348+
}

0 commit comments

Comments
 (0)