From a4ee36e7d3bc5b6a4fa04488eb9203a3d1df139f Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Fri, 27 Mar 2026 06:59:18 -0400 Subject: [PATCH] Integration tests for `uv audit` (#18673) --- crates/uv-test/src/lib.rs | 8 + crates/uv/src/commands/project/audit.rs | 19 +- crates/uv/src/settings.rs | 6 +- crates/uv/tests/it/audit.rs | 628 ++++++++++++++++++++++++ crates/uv/tests/it/main.rs | 3 + 5 files changed, 655 insertions(+), 9 deletions(-) create mode 100644 crates/uv/tests/it/audit.rs diff --git a/crates/uv-test/src/lib.rs b/crates/uv-test/src/lib.rs index acbf6b1bd0..ba0b537625 100755 --- a/crates/uv-test/src/lib.rs +++ b/crates/uv-test/src/lib.rs @@ -1391,6 +1391,14 @@ impl TestContext { command } + /// Create a `uv audit` command with options shared across scenarios. + pub fn audit(&self) -> Command { + let mut command = self.new_command(); + command.arg("audit"); + self.add_shared_options(&mut command, false); + command + } + /// Create a `uv workspace metadata` command with options shared across scenarios. pub fn workspace_metadata(&self) -> Command { let mut command = self.new_command(); diff --git a/crates/uv/src/commands/project/audit.rs b/crates/uv/src/commands/project/audit.rs index b4ba705e8f..d6f64e2e3d 100644 --- a/crates/uv/src/commands/project/audit.rs +++ b/crates/uv/src/commands/project/audit.rs @@ -82,8 +82,8 @@ pub(crate) async fn audit( // Determine the extras to include. let default_extras = match &target { - LockTarget::Workspace(_) => DefaultExtras::default(), - LockTarget::Script(_) => DefaultExtras::default(), + LockTarget::Workspace(_) => DefaultExtras::All, + LockTarget::Script(_) => DefaultExtras::All, }; let extras = extras.with_defaults(default_extras); @@ -206,7 +206,7 @@ pub(crate) async fn audit( .parse() .expect("invalid OSV service URL"); let client = base_client.for_host(&osv_url).raw_client().clone(); - let service = osv::Osv::new(client, None, concurrency); + let service = osv::Osv::new(client, Some(osv_url), concurrency); trace!("Auditing {n} dependencies against OSV", n = auditable.len()); service.query_batch(&dependencies).await? } @@ -260,7 +260,16 @@ impl AuditResults { writeln!( self.printer.stderr(), "Found {vuln_banner} and {status_banner} in {packages}", - packages = format!("{npackages} packages", npackages = self.n_packages).bold() + packages = format!( + "{npackages} {label}", + npackages = self.n_packages, + label = if self.n_packages == 1 { + "package" + } else { + "packages" + } + ) + .bold() )?; let has_findings = !vulns.is_empty() || !statuses.is_empty(); @@ -318,8 +327,6 @@ impl AuditResults { )?; } } - - writeln!(self.printer.stdout_important())?; } } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index ed6b70a355..fd57b2e7c1 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -2548,14 +2548,14 @@ impl AuditSettings { true, ), groups: DependencyGroups::from_args( - true, + only_group.is_empty() && !only_dev, no_dev, only_dev, vec![], no_group, no_default_groups, - only_group, - true, + only_group.clone(), + only_group.is_empty() && !only_dev, ), lock_check: resolve_lock_check(locked), frozen: resolve_frozen(frozen), diff --git a/crates/uv/tests/it/audit.rs b/crates/uv/tests/it/audit.rs new file mode 100644 index 0000000000..08d13d2153 --- /dev/null +++ b/crates/uv/tests/it/audit.rs @@ -0,0 +1,628 @@ +use assert_cmd::assert::OutputAssertExt; +use assert_fs::prelude::*; +use indoc::indoc; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use uv_test::uv_snapshot; + +/// Audit a project with no vulnerabilities found. +#[tokio::test] +async fn audit_no_vulnerabilities() { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig==2.0.0"] + "#}) + .unwrap(); + + context.lock().assert().success(); + + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{"vulns": []}] + }))) + .mount(&server) + .await; + + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--service-url") + .arg(server.uri()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Found no known vulnerabilities and no adverse project statuses in 1 package + "); +} + +/// Audit a project and find a single vulnerability with summary, fix version, and advisory link. +#[tokio::test] +async fn audit_vulnerability_found() { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig==2.0.0"] + "#}) + .unwrap(); + + context.lock().assert().success(); + + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{"vulns": [{"id": "PYSEC-2023-0001"}]}] + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/vulns/PYSEC-2023-0001")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "PYSEC-2023-0001", + "modified": "2026-01-01T00:00:00Z", + "summary": "A test vulnerability in iniconfig", + "affected": [{ + "ranges": [{ + "type": "ECOSYSTEM", + "events": [ + {"introduced": "0"}, + {"fixed": "2.1.0"} + ] + }] + }], + "references": [{ + "type": "ADVISORY", + "url": "https://example.com/advisory/PYSEC-2023-0001" + }] + }))) + .mount(&server) + .await; + + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--service-url") + .arg(server.uri()), @" + success: false + exit_code: 1 + ----- stdout ----- + + Vulnerabilities: + + iniconfig 2.0.0 has 1 known vulnerability: + + - PYSEC-2023-0001: A test vulnerability in iniconfig + + Fixed in: 2.1.0 + + Advisory information: https://example.com/advisory/PYSEC-2023-0001 + + + ----- stderr ----- + Found 1 known vulnerability and no adverse project statuses in 1 package + "); +} + +/// Audit a project with no dependencies. +#[tokio::test] +async fn audit_no_dependencies() { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#}) + .unwrap(); + + context.lock().assert().success(); + + let server = MockServer::start().await; + + // No querybatch call expected since there are no dependencies to audit. + + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--service-url") + .arg(server.uri()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Found no known vulnerabilities and no adverse project statuses in 0 packages + "); +} + +/// When a vulnerability has aliases, the best ID (PYSEC > GHSA > CVE) is displayed. +#[tokio::test] +async fn audit_best_id_selection() { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig==2.0.0"] + "#}) + .unwrap(); + + context.lock().assert().success(); + + let server = MockServer::start().await; + + // The primary ID is an OSV ID, but aliases include a PYSEC ID which should be preferred. + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{"vulns": [{"id": "OSV-2023-0001"}]}] + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/vulns/OSV-2023-0001")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "OSV-2023-0001", + "modified": "2026-01-01T00:00:00Z", + "summary": "A vulnerability with many aliases", + "aliases": ["PYSEC-2023-0042", "CVE-2023-9999", "GHSA-xxxx-yyyy-zzzz"] + }))) + .mount(&server) + .await; + + // The output should show PYSEC-2023-0042 as the display ID (PYSEC preferred over GHSA, CVE). + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--service-url") + .arg(server.uri()), @" + success: false + exit_code: 1 + ----- stdout ----- + + Vulnerabilities: + + iniconfig 2.0.0 has 1 known vulnerability: + + - PYSEC-2023-0042: A vulnerability with many aliases + + No fix versions available + + Advisory information: https://osv.dev/vulnerability/OSV-2023-0001 + + + ----- stderr ----- + Found 1 known vulnerability and no adverse project statuses in 1 package + "); +} + +/// A vulnerability without fix versions shows "No fix versions available". +#[tokio::test] +async fn audit_no_fix_versions() { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig==2.0.0"] + "#}) + .unwrap(); + + context.lock().assert().success(); + + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{"vulns": [{"id": "VULN-NO-FIX"}]}] + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/vulns/VULN-NO-FIX")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "VULN-NO-FIX", + "modified": "2026-01-01T00:00:00Z", + "summary": "A vulnerability with no fix available" + }))) + .mount(&server) + .await; + + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--service-url") + .arg(server.uri()), @" + success: false + exit_code: 1 + ----- stdout ----- + + Vulnerabilities: + + iniconfig 2.0.0 has 1 known vulnerability: + + - VULN-NO-FIX: A vulnerability with no fix available + + No fix versions available + + Advisory information: https://osv.dev/vulnerability/VULN-NO-FIX + + + ----- stderr ----- + Found 1 known vulnerability and no adverse project statuses in 1 package + "); +} + +/// Multiple vulnerabilities on the same package are grouped together. +#[tokio::test] +async fn audit_multiple_vulnerabilities_same_package() { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig==2.0.0"] + "#}) + .unwrap(); + + context.lock().assert().success(); + + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{"vulns": [{"id": "VULN-A"}, {"id": "VULN-B"}]}] + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/vulns/VULN-A")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "VULN-A", + "modified": "2026-01-01T00:00:00Z", + "summary": "First vulnerability", + "affected": [{ + "ranges": [{ + "type": "ECOSYSTEM", + "events": [ + {"introduced": "0"}, + {"fixed": "2.1.0"} + ] + }] + }], + "references": [{ + "type": "ADVISORY", + "url": "https://example.com/advisory/VULN-A" + }] + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/vulns/VULN-B")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "VULN-B", + "modified": "2026-01-02T00:00:00Z", + "summary": "Second vulnerability", + "affected": [{ + "ranges": [{ + "type": "ECOSYSTEM", + "events": [ + {"introduced": "2.0.0"}, + {"fixed": "2.0.1"} + ] + }] + }], + "references": [{ + "type": "WEB", + "url": "https://example.com/web/VULN-B" + }] + }))) + .mount(&server) + .await; + + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--service-url") + .arg(server.uri()), @" + success: false + exit_code: 1 + ----- stdout ----- + + Vulnerabilities: + + iniconfig 2.0.0 has 2 known vulnerabilities: + + - VULN-A: First vulnerability + + Fixed in: 2.1.0 + + Advisory information: https://example.com/advisory/VULN-A + + - VULN-B: Second vulnerability + + Fixed in: 2.0.1 + + Advisory information: https://example.com/web/VULN-B + + + ----- stderr ----- + Found 2 known vulnerabilities and no adverse project statuses in 1 package + "); +} + +/// `--no-dev` excludes dev dependencies from the audit. +#[tokio::test] +async fn audit_no_dev() { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig==2.0.0"] + + [dependency-groups] + dev = ["typing-extensions==4.10.0"] + "#}) + .unwrap(); + + context.lock().assert().success(); + + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{"vulns": []}] + }))) + .mount(&server) + .await; + + // With --no-dev, only "iniconfig" should be audited (not "typing-extensions"). + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--no-dev") + .arg("--service-url") + .arg(server.uri()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Found no known vulnerabilities and no adverse project statuses in 1 package + "); + + // Without --no-dev, both packages should be audited. + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--service-url") + .arg(server.uri()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Found no known vulnerabilities and no adverse project statuses in 2 packages + "); +} + +/// Extras are included in the audit by default, and can be excluded. +#[tokio::test] +async fn audit_extras() { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig==2.0.0"] + + [project.optional-dependencies] + web = ["typing-extensions==4.10.0"] + "#}) + .unwrap(); + + context.lock().assert().success(); + + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{"vulns": []}] + }))) + .mount(&server) + .await; + + // By default, extras are included: both iniconfig and typing-extensions are audited. + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--service-url") + .arg(server.uri()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Found no known vulnerabilities and no adverse project statuses in 2 packages + "); + + // With --no-extra web, only iniconfig should be audited. + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--no-extra") + .arg("web") + .arg("--service-url") + .arg(server.uri()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Found no known vulnerabilities and no adverse project statuses in 1 package + "); +} + +/// Non-default dependency groups are included when explicitly requested. +#[tokio::test] +async fn audit_dependency_groups() { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig==2.0.0"] + + [dependency-groups] + dev = ["typing-extensions==4.10.0"] + lint = ["sniffio==1.3.1"] + + "#}) + .unwrap(); + + context.lock().assert().success(); + + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{"vulns": []}] + }))) + .mount(&server) + .await; + + // Default: all groups are included (iniconfig + typing-extensions + sniffio = 3). + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--service-url") + .arg(server.uri()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Found no known vulnerabilities and no adverse project statuses in 3 packages + "); + + // --no-dev: excludes the dev group (iniconfig + sniffio = 2). + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--no-dev") + .arg("--service-url") + .arg(server.uri()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Found no known vulnerabilities and no adverse project statuses in 2 packages + "); + + // --no-group lint: excludes the lint group (iniconfig + typing-extensions = 2). + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--no-group") + .arg("lint") + .arg("--service-url") + .arg(server.uri()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Found no known vulnerabilities and no adverse project statuses in 2 packages + "); + + // --only-group lint: only the "lint" group, project deps omitted (sniffio = 1). + uv_snapshot!(context.filters(), context + .audit() + .arg("--frozen") + .arg("--preview") + .arg("--only-group") + .arg("lint") + .arg("--service-url") + .arg(server.uri()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Found no known vulnerabilities and no adverse project statuses in 1 package + "); +} diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index 5054f4da7a..003c83a665 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -3,6 +3,9 @@ mod auth; +#[cfg(all(feature = "test-python", feature = "test-pypi"))] +mod audit; + mod branching_urls; #[cfg(all(feature = "test-python", feature = "test-pypi"))]