aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe/tests/advisory/auditor_test.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-06-27 17:21:00 +0900
committernsfisis <nsfisis@gmail.com>2026-06-27 17:26:28 +0900
commite98823e599eb375b30037cc714710e3309d927d1 (patch)
treee1689ec164086798fc46aa6a58d1e914cf4873b2 /crates/shirabe/tests/advisory/auditor_test.rs
parent20f620bdd0b5764ed2e9812dc0772f907b0d6f29 (diff)
downloadphp-shirabe-e98823e599eb375b30037cc714710e3309d927d1.tar.gz
php-shirabe-e98823e599eb375b30037cc714710e3309d927d1.tar.zst
php-shirabe-e98823e599eb375b30037cc714710e3309d927d1.zip
test: port Composer tests unblocked by mockall, add seams
Port 11 categories of previously-ignored Composer tests now reachable with the mockall crate: DownloadManager, VCS/Perforce/File downloaders, VersionSelector, PlatformRepository, Auditor, installer/FilesystemRepository, RootPackageLoader, util auth/http, commands, and Cache. Extract test seams additively on concrete structs as *Interface traits (Runtime, HhvmDetector, VersionGuesser, RepositorySet, Perforce, BinaryInstaller) plus mock-field seams (Cache, Filesystem); consumers take trait objects. Mocks are defined locally in the test crates via mockall::mock!, since automock-generated mocks are cfg(test)-gated and invisible across the integration-test boundary. dataProviders are ported in full; tests blocked by unported shims stay #[ignore] with documented reasons rather than reduced or weakened. Fix product bugs surfaced by the ports: - util/github: use the exception code, not the HTTP status, for 401/403 - advisory: serialize empty audit maps as [] to match PHP json_encode - repository/filesystem and downloader/file: fix RefCell double-borrow panics Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe/tests/advisory/auditor_test.rs')
-rw-r--r--crates/shirabe/tests/advisory/auditor_test.rs1015
1 files changed, 1005 insertions, 10 deletions
diff --git a/crates/shirabe/tests/advisory/auditor_test.rs b/crates/shirabe/tests/advisory/auditor_test.rs
index 7e02db8..516c4d1 100644
--- a/crates/shirabe/tests/advisory/auditor_test.rs
+++ b/crates/shirabe/tests/advisory/auditor_test.rs
@@ -6,8 +6,30 @@ use shirabe::advisory::AnySecurityAdvisory;
use shirabe::advisory::Auditor;
use shirabe::advisory::PartialSecurityAdvisory;
use shirabe::advisory::SecurityAdvisory;
+use shirabe::downloader::TransportException;
+use shirabe::io::BufferIO;
+use shirabe::io::io_interface;
+use shirabe::package::BasePackageHandle;
+use shirabe::package::PackageInterfaceHandle;
+use shirabe::repository::AdvisoryProviderInterface;
+use shirabe::repository::FindPackageConstraint;
+use shirabe::repository::LoadPackagesResult;
+use shirabe::repository::ProviderInfo;
+use shirabe::repository::RepositoryInterface;
+use shirabe::repository::RepositoryInterfaceHandle;
+use shirabe::repository::RepositorySet;
+use shirabe::repository::SearchResult;
+use shirabe::repository::SecurityAdvisoryResult;
+use shirabe_external_packages::symfony::console::output::output_interface;
+use shirabe_php_shim::date_create;
+use shirabe_semver::VersionParser;
+use shirabe_semver::constraint::AnyConstraint;
use shirabe_semver::constraint::SimpleConstraint;
+use crate::io_mock::{Expectation, get_io_mock};
+use crate::test_case::get_complete_package;
+use crate::test_case::get_package;
+
fn constraint(operator: &str, version: &str) -> shirabe_semver::constraint::AnyConstraint {
SimpleConstraint::new(operator.to_string(), version.to_string(), None).into()
}
@@ -61,30 +83,1003 @@ fn ignore_list(pairs: Vec<(&str, Option<&str>)>) -> IndexMap<String, Option<Stri
.collect()
}
-// These run the Auditor against a mocked HttpDownloader/IO and packages built from version
-// constraints (parsed via a look-around regex the regex crate cannot compile).
+/// Behavior of the mocked repository's advisory provider. Replaces PHP's
+/// `getMockBuilder(ComposerRepository::class)` partial mock; the real `RepositorySet` still drives
+/// `getMatchingSecurityAdvisories`, only the per-repo advisory lookup is stubbed.
+#[derive(Debug)]
+enum AdvisorySource {
+ /// Mirrors `AuditorTest::getMockAdvisories()` filtered by the package constraint map.
+ Table,
+ /// Returns a fixed advisory map regardless of the request (reachable repo).
+ Fixed(IndexMap<String, Vec<AnySecurityAdvisory>>),
+ /// Throws a `TransportException`, simulating an unreachable repository.
+ Unreachable(String),
+}
+
+#[derive(Debug)]
+struct MockAdvisoryRepository {
+ source: AdvisorySource,
+}
+
+impl AdvisoryProviderInterface for MockAdvisoryRepository {
+ fn has_security_advisories(&mut self) -> anyhow::Result<bool> {
+ Ok(true)
+ }
+
+ fn get_security_advisories(
+ &mut self,
+ package_constraint_map: IndexMap<String, AnyConstraint>,
+ _allow_partial_advisories: bool,
+ ) -> anyhow::Result<SecurityAdvisoryResult> {
+ match &self.source {
+ AdvisorySource::Unreachable(message) => {
+ Err(TransportException::new(message.clone(), 404).into())
+ }
+ AdvisorySource::Fixed(advisories) => Ok(SecurityAdvisoryResult {
+ names_found: package_constraint_map.keys().cloned().collect(),
+ advisories: advisories.clone(),
+ }),
+ AdvisorySource::Table => {
+ let mut advisories: IndexMap<String, Vec<AnySecurityAdvisory>> = IndexMap::new();
+ for (package, list) in mock_advisories() {
+ let Some(constraint) = package_constraint_map.get(&package) else {
+ continue;
+ };
+ let filtered: Vec<AnySecurityAdvisory> = list
+ .into_iter()
+ .filter(|advisory| advisory.affected_versions().matches(constraint))
+ .collect();
+ if !filtered.is_empty() {
+ advisories.insert(package, filtered);
+ }
+ }
+ Ok(SecurityAdvisoryResult {
+ names_found: package_constraint_map.keys().cloned().collect(),
+ advisories,
+ })
+ }
+ }
+ }
+}
+
+impl RepositoryInterface for MockAdvisoryRepository {
+ fn count(&self) -> anyhow::Result<usize> {
+ unimplemented!("not used by Auditor")
+ }
+ fn has_package(&self, _package: PackageInterfaceHandle) -> bool {
+ unimplemented!("not used by Auditor")
+ }
+ fn find_package(
+ &mut self,
+ _name: &str,
+ _constraint: FindPackageConstraint,
+ ) -> anyhow::Result<Option<BasePackageHandle>> {
+ unimplemented!("not used by Auditor")
+ }
+ fn find_packages(
+ &mut self,
+ _name: &str,
+ _constraint: Option<FindPackageConstraint>,
+ ) -> anyhow::Result<Vec<BasePackageHandle>> {
+ unimplemented!("not used by Auditor")
+ }
+ fn get_packages(&mut self) -> anyhow::Result<Vec<BasePackageHandle>> {
+ unimplemented!("not used by Auditor")
+ }
+ fn load_packages(
+ &mut self,
+ _package_name_map: IndexMap<String, Option<AnyConstraint>>,
+ _acceptable_stabilities: IndexMap<String, i64>,
+ _stability_flags: IndexMap<String, i64>,
+ _already_loaded: IndexMap<String, IndexMap<String, PackageInterfaceHandle>>,
+ ) -> anyhow::Result<LoadPackagesResult> {
+ unimplemented!("not used by Auditor")
+ }
+ fn search(
+ &mut self,
+ _query: String,
+ _mode: i64,
+ _type: Option<String>,
+ ) -> anyhow::Result<Vec<SearchResult>> {
+ unimplemented!("not used by Auditor")
+ }
+ fn get_providers(
+ &mut self,
+ _package_name: String,
+ ) -> anyhow::Result<IndexMap<String, ProviderInfo>> {
+ unimplemented!("not used by Auditor")
+ }
+ fn get_repo_name(&self) -> String {
+ "mock advisory repo".to_string()
+ }
+ fn as_advisory_provider_mut(&mut self) -> Option<&mut dyn AdvisoryProviderInterface> {
+ Some(self)
+ }
+ fn as_any(&self) -> &dyn std::any::Any {
+ self
+ }
+}
+
+/// ref: AuditorTest::getMockAdvisories. All entries carry full data so they load as
+/// `SecurityAdvisory` (never partial) regardless of `allowPartialAdvisories`.
+fn mock_advisories() -> IndexMap<String, Vec<AnySecurityAdvisory>> {
+ let mut advisories: IndexMap<String, Vec<AnySecurityAdvisory>> = IndexMap::new();
+ advisories.insert(
+ "vendor1/package1".to_string(),
+ vec![
+ mock_advisory(
+ "vendor1/package1",
+ "ID1",
+ "advisory1",
+ "https://advisory.example.com/advisory1",
+ "CVE1",
+ ">=3,<3.4.3|>=1,<2.5.6",
+ "source1",
+ "RemoteID1",
+ "2022-05-25 13:21:00",
+ "medium",
+ ),
+ mock_advisory(
+ "vendor1/package1",
+ "ID4",
+ "advisory4",
+ "https://advisory.example.com/advisory4",
+ "CVE3",
+ ">=8,<8.2.2|>=1,<2.5.6",
+ "source2",
+ "RemoteID4",
+ "2022-05-25 13:21:00",
+ "high",
+ ),
+ mock_advisory(
+ "vendor1/package1",
+ "ID5",
+ "advisory5",
+ "https://advisory.example.com/advisory5",
+ "",
+ ">=8,<8.2.2|>=1,<2.5.6",
+ "source1",
+ "RemoteID3",
+ "2022-05-25 13:21:00",
+ "medium",
+ ),
+ ],
+ );
+ advisories.insert(
+ "vendor1/package2".to_string(),
+ vec![mock_advisory(
+ "vendor1/package2",
+ "ID2",
+ "advisory2",
+ "https://advisory.example.com/advisory2",
+ "",
+ ">=3,<3.4.3|>=1,<2.5.6",
+ "source1",
+ "RemoteID2",
+ "2022-05-25 13:21:00",
+ "medium",
+ )],
+ );
+ advisories.insert(
+ "vendorx/packagex".to_string(),
+ vec![mock_advisory(
+ "vendorx/packagex",
+ "IDx",
+ "advisory17",
+ "https://advisory.example.com/advisory17",
+ "CVE5",
+ ">=3,<3.4.3|>=1,<2.5.6",
+ "source2",
+ "RemoteIDx",
+ "2015-05-25 13:21:00",
+ "medium",
+ )],
+ );
+ advisories.insert(
+ "vendor2/package1".to_string(),
+ vec![
+ mock_advisory(
+ "vendor2/package1",
+ "ID3",
+ "advisory3",
+ "https://advisory.example.com/advisory3",
+ "CVE2",
+ ">=3,<3.4.3|>=1,<2.5.6",
+ "source2",
+ "RemoteID1",
+ "2022-05-25 13:21:00",
+ "medium",
+ ),
+ mock_advisory(
+ "vendor2/package1",
+ "ID6",
+ "advisory6",
+ "https://advisory.example.com/advisory6",
+ "CVE4",
+ ">=3,<3.4.3|>=1,<2.5.6",
+ "source2",
+ "RemoteID3",
+ "2015-05-25 13:21:00",
+ "medium",
+ ),
+ ],
+ );
+ advisories.insert(
+ "vendory/packagey".to_string(),
+ vec![mock_advisory(
+ "vendory/packagey",
+ "IDy",
+ "advisory7",
+ "https://advisory.example.com/advisory7",
+ "CVE5",
+ ">=3,<3.4.3|>=1,<2.5.6",
+ "source2",
+ "RemoteID4",
+ "2015-05-25 13:21:00",
+ "medium",
+ )],
+ );
+ advisories.insert(
+ "vendor3/package1".to_string(),
+ vec![mock_advisory(
+ "vendor3/package1",
+ "ID7",
+ "advisory7",
+ "https://advisory.example.com/advisory7",
+ "CVE5",
+ ">=3,<3.4.3|>=1,<2.5.6",
+ "source2",
+ "RemoteID4",
+ "2015-05-25 13:21:00",
+ "medium",
+ )],
+ );
+ advisories
+}
+
+#[allow(
+ clippy::too_many_arguments,
+ reason = "mirrors the PHP advisory data shape"
+)]
+fn mock_advisory(
+ package_name: &str,
+ advisory_id: &str,
+ title: &str,
+ link: &str,
+ cve: &str,
+ affected_versions: &str,
+ source_name: &str,
+ remote_id: &str,
+ reported_at: &str,
+ severity: &str,
+) -> AnySecurityAdvisory {
+ let mut source: IndexMap<String, String> = IndexMap::new();
+ source.insert("name".to_string(), source_name.to_string());
+ source.insert("remoteId".to_string(), remote_id.to_string());
+ AnySecurityAdvisory::Full(SecurityAdvisory::new(
+ package_name.to_string(),
+ advisory_id.to_string(),
+ VersionParser.parse_constraints(affected_versions).unwrap(),
+ title.to_string(),
+ vec![source],
+ date_create::<Utc>(reported_at).unwrap(),
+ Some(cve.to_string()),
+ Some(link.to_string()),
+ Some(severity.to_string()),
+ ))
+}
+
+/// ref: AuditorTest::getRepoSet. Real `RepositorySet` holding a single repository whose advisory
+/// provider serves `getMockAdvisories()`.
+fn get_repo_set() -> RepositorySet {
+ let mut repo_set = RepositorySet::new(
+ "stable",
+ IndexMap::new(),
+ vec![],
+ IndexMap::new(),
+ IndexMap::new(),
+ IndexMap::new(),
+ );
+ repo_set
+ .add_repository(RepositoryInterfaceHandle::new(MockAdvisoryRepository {
+ source: AdvisorySource::Table,
+ }))
+ .unwrap();
+ repo_set
+}
+
+/// ref: AuditorTest::testAudit (auditProvider).
#[test]
-#[ignore = "requires PHPUnit getMockBuilder partial mock of ComposerRepository (hasSecurityAdvisories/getSecurityAdvisories) and BufferIO; no mocking infrastructure exists"]
+#[ignore = "all 10 cases faithfully ported; the 2 FORMAT_TABLE cases cannot run because \
+ BufferIO does not downcast to ConsoleIO (PHP's `BufferIO extends ConsoleIO` is \
+ modeled as composition in Rust), so the table-rendering path is unreachable and \
+ the whole function stays ignored until the src supports it"]
fn test_audit() {
- todo!()
+ #[derive(Clone)]
+ struct Case {
+ packages: Vec<PackageInterfaceHandle>,
+ warning_only: bool,
+ abandoned: &'static str,
+ format: &'static str,
+ ignore_abandoned: IndexMap<String, Option<String>>,
+ expected: i64,
+ output: &'static str,
+ }
+
+ let abandoned_with_replacement = || {
+ let p = get_complete_package("vendor/abandoned", "1.0.0");
+ p.set_abandoned(shirabe_php_shim::PhpMixed::String("foo/bar".to_string()));
+ let handle: PackageInterfaceHandle = p.into();
+ handle
+ };
+ let abandoned_no_replacement = || {
+ let p = get_complete_package("vendor/abandoned2", "1.0.0");
+ p.set_abandoned(shirabe_php_shim::PhpMixed::Bool(true));
+ let handle: PackageInterfaceHandle = p.into();
+ handle
+ };
+
+ let cases: Vec<Case> = vec![
+ Case {
+ packages: vec![
+ get_package("vendor1/package2", "9.0.0"),
+ get_package("vendor1/package1", "9.0.0"),
+ get_package("vendor3/package1", "9.0.0"),
+ ],
+ warning_only: true,
+ abandoned: Auditor::ABANDONED_IGNORE,
+ format: Auditor::FORMAT_PLAIN,
+ ignore_abandoned: IndexMap::new(),
+ expected: Auditor::STATUS_OK,
+ output: "No security vulnerability advisories found.",
+ },
+ Case {
+ packages: vec![
+ get_package("vendor1/package2", "9.0.0"),
+ get_package("vendor1/package1", "8.2.1"),
+ get_package("vendor3/package1", "9.0.0"),
+ ],
+ warning_only: true,
+ abandoned: Auditor::ABANDONED_IGNORE,
+ format: Auditor::FORMAT_PLAIN,
+ ignore_abandoned: IndexMap::new(),
+ expected: Auditor::STATUS_VULNERABLE,
+ output:
+ "<warning>Found 2 security vulnerability advisories affecting 1 package:</warning>
+Package: vendor1/package1
+Severity: high
+Advisory ID: ID4
+CVE: CVE3
+Title: advisory4
+URL: https://advisory.example.com/advisory4
+Affected versions: >=8,<8.2.2|>=1,<2.5.6
+Reported at: 2022-05-25T13:21:00+00:00
+--------
+Package: vendor1/package1
+Severity: medium
+Advisory ID: ID5
+CVE: \nTitle: advisory5
+URL: https://advisory.example.com/advisory5
+Affected versions: >=8,<8.2.2|>=1,<2.5.6
+Reported at: 2022-05-25T13:21:00+00:00",
+ },
+ Case {
+ packages: vec![abandoned_with_replacement(), abandoned_no_replacement()],
+ warning_only: false,
+ abandoned: Auditor::ABANDONED_IGNORE,
+ format: Auditor::FORMAT_PLAIN,
+ ignore_abandoned: IndexMap::new(),
+ expected: Auditor::STATUS_OK,
+ output: "No security vulnerability advisories found.",
+ },
+ Case {
+ packages: vec![abandoned_with_replacement(), abandoned_no_replacement()],
+ warning_only: false,
+ abandoned: Auditor::ABANDONED_FAIL,
+ format: Auditor::FORMAT_PLAIN,
+ ignore_abandoned: ignore_list(vec![("vendor/*", None)]),
+ expected: Auditor::STATUS_OK,
+ output: "No security vulnerability advisories found.",
+ },
+ Case {
+ packages: vec![abandoned_with_replacement(), abandoned_no_replacement()],
+ warning_only: false,
+ abandoned: Auditor::ABANDONED_FAIL,
+ format: Auditor::FORMAT_PLAIN,
+ ignore_abandoned: ignore_list(vec![
+ ("vendor/abandoned", None),
+ ("vendor/abandoned2", None),
+ ]),
+ expected: Auditor::STATUS_OK,
+ output: "No security vulnerability advisories found.",
+ },
+ Case {
+ packages: vec![abandoned_with_replacement(), abandoned_no_replacement()],
+ warning_only: false,
+ abandoned: Auditor::ABANDONED_FAIL,
+ format: Auditor::FORMAT_PLAIN,
+ ignore_abandoned: ignore_list(vec![("acme/test", Some("ignoring because yolo"))]),
+ expected: Auditor::STATUS_ABANDONED,
+ output: "No security vulnerability advisories found.
+Found 2 abandoned packages:
+vendor/abandoned is abandoned. Use foo/bar instead.
+vendor/abandoned2 is abandoned. No replacement was suggested.",
+ },
+ Case {
+ packages: vec![abandoned_with_replacement(), abandoned_no_replacement()],
+ warning_only: true,
+ abandoned: Auditor::ABANDONED_REPORT,
+ format: Auditor::FORMAT_PLAIN,
+ ignore_abandoned: IndexMap::new(),
+ expected: Auditor::STATUS_OK,
+ output: "No security vulnerability advisories found.
+Found 2 abandoned packages:
+vendor/abandoned is abandoned. Use foo/bar instead.
+vendor/abandoned2 is abandoned. No replacement was suggested.",
+ },
+ Case {
+ packages: vec![abandoned_with_replacement(), abandoned_no_replacement()],
+ warning_only: false,
+ abandoned: Auditor::ABANDONED_FAIL,
+ format: Auditor::FORMAT_TABLE,
+ ignore_abandoned: IndexMap::new(),
+ expected: Auditor::STATUS_ABANDONED,
+ output: "No security vulnerability advisories found.
+Found 2 abandoned packages:
++-------------------+----------------------------------------------------------------------------------+
+| Abandoned Package | Suggested Replacement |
++-------------------+----------------------------------------------------------------------------------+
+| vendor/abandoned | foo/bar |
+| vendor/abandoned2 | none |
++-------------------+----------------------------------------------------------------------------------+",
+ },
+ Case {
+ packages: vec![
+ get_package("vendor1/package1", "8.2.1"),
+ abandoned_with_replacement(),
+ abandoned_no_replacement(),
+ ],
+ warning_only: false,
+ abandoned: Auditor::ABANDONED_FAIL,
+ format: Auditor::FORMAT_TABLE,
+ ignore_abandoned: IndexMap::new(),
+ expected: Auditor::STATUS_VULNERABLE | Auditor::STATUS_ABANDONED,
+ output: "Found 2 security vulnerability advisories affecting 1 package:
++-------------------+----------------------------------------------------------------------------------+
+| Package | vendor1/package1 |
+| Severity | high |
+| Advisory ID | ID4 |
+| CVE | CVE3 |
+| Title | advisory4 |
+| URL | https://advisory.example.com/advisory4 |
+| Affected versions | >=8,<8.2.2|>=1,<2.5.6 |
+| Reported at | 2022-05-25T13:21:00+00:00 |
++-------------------+----------------------------------------------------------------------------------+
++-------------------+----------------------------------------------------------------------------------+
+| Package | vendor1/package1 |
+| Severity | medium |
+| Advisory ID | ID5 |
+| CVE | |
+| Title | advisory5 |
+| URL | https://advisory.example.com/advisory5 |
+| Affected versions | >=8,<8.2.2|>=1,<2.5.6 |
+| Reported at | 2022-05-25T13:21:00+00:00 |
++-------------------+----------------------------------------------------------------------------------+
+Found 2 abandoned packages:
++-------------------+----------------------------------------------------------------------------------+
+| Abandoned Package | Suggested Replacement |
++-------------------+----------------------------------------------------------------------------------+
+| vendor/abandoned | foo/bar |
+| vendor/abandoned2 | none |
++-------------------+----------------------------------------------------------------------------------+",
+ },
+ Case {
+ packages: vec![abandoned_with_replacement(), abandoned_no_replacement()],
+ warning_only: false,
+ abandoned: Auditor::ABANDONED_FAIL,
+ format: Auditor::FORMAT_JSON,
+ ignore_abandoned: IndexMap::new(),
+ expected: Auditor::STATUS_ABANDONED,
+ output: "{
+ \"advisories\": [],
+ \"abandoned\": {
+ \"vendor/abandoned\": \"foo/bar\",
+ \"vendor/abandoned2\": null
+ }
+}",
+ },
+ ];
+
+ for case in cases {
+ let repo_set = get_repo_set();
+ let mut io =
+ BufferIO::new(String::new(), output_interface::VERBOSITY_NORMAL, None).unwrap();
+ let auditor = Auditor;
+ let result = auditor
+ .audit(
+ &mut io,
+ &repo_set,
+ case.packages.clone(),
+ case.format,
+ case.warning_only,
+ IndexMap::new(),
+ case.abandoned,
+ IndexMap::new(),
+ false,
+ case.ignore_abandoned.clone(),
+ )
+ .unwrap();
+ assert_eq!(case.expected, result);
+ assert_eq!(case.output, io.get_output().replace('\r', "").trim());
+ }
}
+/// ref: AuditorTest::testAuditWithIgnore (ignoredIdsProvider).
#[test]
-#[ignore = "requires getIOMock with expects() output expectations and getMockBuilder mock of ComposerRepository; no mocking infrastructure exists"]
fn test_audit_with_ignore() {
- todo!()
+ struct Case {
+ packages: Vec<PackageInterfaceHandle>,
+ ignored_ids: IndexMap<String, Option<String>>,
+ exit_code: i64,
+ expected_output: Vec<Expectation>,
+ }
+
+ let cases: Vec<Case> = vec![
+ Case {
+ packages: vec![get_package("vendor1/package1", "3.0.0.0")],
+ ignored_ids: ignore_list(vec![("CVE1", None)]),
+ exit_code: 0,
+ expected_output: vec![
+ Expectation::text(
+ "Found 1 ignored security vulnerability advisory affecting 1 package:",
+ ),
+ Expectation::text("Package: vendor1/package1"),
+ Expectation::text("Severity: medium"),
+ Expectation::text("Advisory ID: ID1"),
+ Expectation::text("CVE: CVE1"),
+ Expectation::text("Title: advisory1"),
+ Expectation::text("URL: https://advisory.example.com/advisory1"),
+ Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"),
+ Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"),
+ ],
+ },
+ Case {
+ packages: vec![get_package("vendor1/package1", "3.0.0.0")],
+ ignored_ids: ignore_list(vec![("CVE1", Some("A good reason"))]),
+ exit_code: 0,
+ expected_output: vec![
+ Expectation::text(
+ "Found 1 ignored security vulnerability advisory affecting 1 package:",
+ ),
+ Expectation::text("Package: vendor1/package1"),
+ Expectation::text("Severity: medium"),
+ Expectation::text("Advisory ID: ID1"),
+ Expectation::text("CVE: CVE1"),
+ Expectation::text("Title: advisory1"),
+ Expectation::text("URL: https://advisory.example.com/advisory1"),
+ Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"),
+ Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"),
+ Expectation::text("Ignore reason: A good reason"),
+ ],
+ },
+ Case {
+ packages: vec![get_package("vendor1/package2", "3.0.0.0")],
+ ignored_ids: ignore_list(vec![("ID2", None)]),
+ exit_code: 0,
+ expected_output: vec![
+ Expectation::text(
+ "Found 1 ignored security vulnerability advisory affecting 1 package:",
+ ),
+ Expectation::text("Package: vendor1/package2"),
+ Expectation::text("Severity: medium"),
+ Expectation::text("Advisory ID: ID2"),
+ Expectation::text("CVE: "),
+ Expectation::text("Title: advisory2"),
+ Expectation::text("URL: https://advisory.example.com/advisory2"),
+ Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"),
+ Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"),
+ ],
+ },
+ Case {
+ packages: vec![get_package("vendorx/packagex", "3.0.0.0")],
+ ignored_ids: ignore_list(vec![("RemoteIDx", None)]),
+ exit_code: 0,
+ expected_output: vec![
+ Expectation::text(
+ "Found 1 ignored security vulnerability advisory affecting 1 package:",
+ ),
+ Expectation::text("Package: vendorx/packagex"),
+ Expectation::text("Severity: medium"),
+ Expectation::text("Advisory ID: IDx"),
+ Expectation::text("CVE: CVE5"),
+ Expectation::text("Title: advisory17"),
+ Expectation::text("URL: https://advisory.example.com/advisory17"),
+ Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"),
+ Expectation::text("Reported at: 2015-05-25T13:21:00+00:00"),
+ ],
+ },
+ Case {
+ packages: vec![get_package("vendor1/package1", "3.0.0.0")],
+ ignored_ids: ignore_list(vec![("vendor1/package1", None)]),
+ exit_code: 0,
+ expected_output: vec![
+ Expectation::text(
+ "Found 1 ignored security vulnerability advisory affecting 1 package:",
+ ),
+ Expectation::text("Package: vendor1/package1"),
+ Expectation::text("Severity: medium"),
+ Expectation::text("Advisory ID: ID1"),
+ Expectation::text("CVE: CVE1"),
+ Expectation::text("Title: advisory1"),
+ Expectation::text("URL: https://advisory.example.com/advisory1"),
+ Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"),
+ Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"),
+ ],
+ },
+ Case {
+ packages: vec![get_package("vendor1/package1", "3.0.0.0")],
+ ignored_ids: ignore_list(vec![(
+ "vendor1/package1",
+ Some("Package has known safe usage"),
+ )]),
+ exit_code: 0,
+ expected_output: vec![
+ Expectation::text(
+ "Found 1 ignored security vulnerability advisory affecting 1 package:",
+ ),
+ Expectation::text("Package: vendor1/package1"),
+ Expectation::text("Severity: medium"),
+ Expectation::text("Advisory ID: ID1"),
+ Expectation::text("CVE: CVE1"),
+ Expectation::text("Title: advisory1"),
+ Expectation::text("URL: https://advisory.example.com/advisory1"),
+ Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"),
+ Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"),
+ Expectation::text("Ignore reason: Package has known safe usage"),
+ ],
+ },
+ Case {
+ packages: vec![get_package("vendor1/package1", "3.0.0.0")],
+ ignored_ids: IndexMap::new(),
+ exit_code: 1,
+ expected_output: vec![
+ Expectation::text("Found 1 security vulnerability advisory affecting 1 package:"),
+ Expectation::text("Package: vendor1/package1"),
+ Expectation::text("Severity: medium"),
+ Expectation::text("Advisory ID: ID1"),
+ Expectation::text("CVE: CVE1"),
+ Expectation::text("Title: advisory1"),
+ Expectation::text("URL: https://advisory.example.com/advisory1"),
+ Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"),
+ Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"),
+ ],
+ },
+ Case {
+ packages: vec![
+ get_package("vendor3/package1", "3.0.0.0"),
+ get_package("vendorx/packagex", "3.0.0.0"),
+ get_package("vendor2/package1", "3.0.0.0"),
+ ],
+ ignored_ids: ignore_list(vec![("RemoteIDx", None), ("ID3", None), ("ID6", None)]),
+ exit_code: 1,
+ expected_output: vec![
+ Expectation::text(
+ "Found 3 ignored security vulnerability advisories affecting 2 packages:",
+ ),
+ Expectation::text("Package: vendor2/package1"),
+ Expectation::text("Severity: medium"),
+ Expectation::text("Advisory ID: ID3"),
+ Expectation::text("CVE: CVE2"),
+ Expectation::text("Title: advisory3"),
+ Expectation::text("URL: https://advisory.example.com/advisory3"),
+ Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"),
+ Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"),
+ Expectation::text("Ignore reason: None specified"),
+ Expectation::text("--------"),
+ Expectation::text("Package: vendor2/package1"),
+ Expectation::text("Severity: medium"),
+ Expectation::text("Advisory ID: ID6"),
+ Expectation::text("CVE: CVE4"),
+ Expectation::text("Title: advisory6"),
+ Expectation::text("URL: https://advisory.example.com/advisory6"),
+ Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"),
+ Expectation::text("Reported at: 2015-05-25T13:21:00+00:00"),
+ Expectation::text("Ignore reason: None specified"),
+ Expectation::text("--------"),
+ Expectation::text("Package: vendorx/packagex"),
+ Expectation::text("Severity: medium"),
+ Expectation::text("Advisory ID: IDx"),
+ Expectation::text("CVE: CVE5"),
+ Expectation::text("Title: advisory17"),
+ Expectation::text("URL: https://advisory.example.com/advisory17"),
+ Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"),
+ Expectation::text("Reported at: 2015-05-25T13:21:00+00:00"),
+ Expectation::text("Ignore reason: None specified"),
+ Expectation::text("Found 1 security vulnerability advisory affecting 1 package:"),
+ Expectation::text("Package: vendor3/package1"),
+ Expectation::text("Severity: medium"),
+ Expectation::text("Advisory ID: ID7"),
+ Expectation::text("CVE: CVE5"),
+ Expectation::text("Title: advisory7"),
+ Expectation::text("URL: https://advisory.example.com/advisory7"),
+ Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"),
+ Expectation::text("Reported at: 2015-05-25T13:21:00+00:00"),
+ ],
+ },
+ ];
+
+ for case in cases {
+ let repo_set = get_repo_set();
+ let (io_mock, _io_guard) = get_io_mock(io_interface::NORMAL).unwrap();
+ let auditor = Auditor;
+ let result = {
+ let mut io_guard = io_mock.borrow_mut();
+ auditor
+ .audit(
+ &mut *io_guard,
+ &repo_set,
+ case.packages.clone(),
+ Auditor::FORMAT_PLAIN,
+ false,
+ case.ignored_ids.clone(),
+ Auditor::ABANDONED_FAIL,
+ IndexMap::new(),
+ false,
+ IndexMap::new(),
+ )
+ .unwrap()
+ };
+ io_mock
+ .borrow_mut()
+ .expects(case.expected_output.clone(), true)
+ .unwrap();
+ assert_eq!(case.exit_code, result);
+ }
}
+/// ref: AuditorTest::testAuditWithIgnoreUnreachable. The whole `RepositorySet` is mocked in PHP;
+/// here a real `RepositorySet` holds a reachable repo (fixed advisories) followed by an
+/// unreachable repo (throws `TransportException`), reproducing the same merged result.
#[test]
-#[ignore = "requires getMockBuilder partial mock of RepositorySet (getMatchingSecurityAdvisories willReturnCallback); no mocking infrastructure exists"]
fn test_audit_with_ignore_unreachable() {
- todo!()
+ let packages = vec![get_package("vendor1/package1", "3.0.0.0")];
+
+ let error_message =
+ "The \"https://example.org/packages.json\" file could not be downloaded: HTTP/1.1 404 Not Found"
+ .to_string();
+
+ let make_repo_set = || {
+ let mut fixed: IndexMap<String, Vec<AnySecurityAdvisory>> = IndexMap::new();
+ fixed.insert(
+ "vendor1/package1".to_string(),
+ vec![
+ AnySecurityAdvisory::Full(SecurityAdvisory::new(
+ "vendor1/package1".to_string(),
+ "CVE-2023-12345".to_string(),
+ constraint("=", "3.0.0.0"),
+ "First repo advisory".to_string(),
+ vec![{
+ let mut s: IndexMap<String, String> = IndexMap::new();
+ s.insert("name".to_string(), "test".to_string());
+ s.insert("remoteId".to_string(), "1".to_string());
+ s
+ }],
+ date_create::<Utc>("2023-01-01").unwrap(),
+ Some("CVE-2023-12345".to_string()),
+ Some("https://example.com/advisory/1".to_string()),
+ Some("medium".to_string()),
+ )),
+ AnySecurityAdvisory::Full(SecurityAdvisory::new(
+ "vendor1/package1".to_string(),
+ "CVE-2023-67890".to_string(),
+ constraint("=", "3.0.0.0"),
+ "Third repo advisory".to_string(),
+ vec![{
+ let mut s: IndexMap<String, String> = IndexMap::new();
+ s.insert("name".to_string(), "test".to_string());
+ s.insert("remoteId".to_string(), "3".to_string());
+ s
+ }],
+ date_create::<Utc>("2023-01-01").unwrap(),
+ Some("CVE-2023-67890".to_string()),
+ Some("https://example.com/advisory/3".to_string()),
+ Some("high".to_string()),
+ )),
+ ],
+ );
+
+ let mut repo_set = RepositorySet::new(
+ "stable",
+ IndexMap::new(),
+ vec![],
+ IndexMap::new(),
+ IndexMap::new(),
+ IndexMap::new(),
+ );
+ repo_set
+ .add_repository(RepositoryInterfaceHandle::new(MockAdvisoryRepository {
+ source: AdvisorySource::Fixed(fixed),
+ }))
+ .unwrap();
+ repo_set
+ .add_repository(RepositoryInterfaceHandle::new(MockAdvisoryRepository {
+ source: AdvisorySource::Unreachable(error_message.clone()),
+ }))
+ .unwrap();
+ repo_set
+ };
+
+ let auditor = Auditor;
+
+ // Without the ignoreUnreachable flag the TransportException propagates.
+ {
+ let repo_set = make_repo_set();
+ let mut io =
+ BufferIO::new(String::new(), output_interface::VERBOSITY_NORMAL, None).unwrap();
+ let err = auditor
+ .audit(
+ &mut io,
+ &repo_set,
+ packages.clone(),
+ Auditor::FORMAT_PLAIN,
+ false,
+ IndexMap::new(),
+ Auditor::ABANDONED_IGNORE,
+ IndexMap::new(),
+ false,
+ IndexMap::new(),
+ )
+ .expect_err("Expected TransportException was not thrown");
+ assert!(err.to_string().contains("HTTP/1.1 404 Not Found"));
+ }
+
+ // With the ignoreUnreachable flag the advisories from reachable repos are reported.
+ {
+ let repo_set = make_repo_set();
+ let mut io =
+ BufferIO::new(String::new(), output_interface::VERBOSITY_NORMAL, None).unwrap();
+ let result = auditor
+ .audit(
+ &mut io,
+ &repo_set,
+ packages.clone(),
+ Auditor::FORMAT_PLAIN,
+ false,
+ IndexMap::new(),
+ Auditor::ABANDONED_IGNORE,
+ IndexMap::new(),
+ true,
+ IndexMap::new(),
+ )
+ .unwrap();
+ assert_eq!(Auditor::STATUS_VULNERABLE, result);
+
+ let output = io.get_output();
+ assert!(output.contains("The following repositories were unreachable:"));
+ assert!(output.contains("HTTP/1.1 404 Not Found"));
+ assert!(output.contains("First repo advisory"));
+ assert!(output.contains("Third repo advisory"));
+ assert!(output.contains("CVE-2023-12345"));
+ assert!(output.contains("CVE-2023-67890"));
+ }
+
+ // With JSON format the unreachable repositories and advisories are both included.
+ {
+ let repo_set = make_repo_set();
+ let mut io =
+ BufferIO::new(String::new(), output_interface::VERBOSITY_NORMAL, None).unwrap();
+ let result = auditor
+ .audit(
+ &mut io,
+ &repo_set,
+ packages.clone(),
+ Auditor::FORMAT_JSON,
+ false,
+ IndexMap::new(),
+ Auditor::ABANDONED_IGNORE,
+ IndexMap::new(),
+ true,
+ IndexMap::new(),
+ )
+ .unwrap();
+ assert_eq!(Auditor::STATUS_VULNERABLE, result);
+
+ let json: serde_json::Value = serde_json::from_str(&io.get_output()).unwrap();
+ let unreachable = json
+ .get("unreachable-repositories")
+ .and_then(|v| v.as_array())
+ .unwrap();
+ assert_eq!(1, unreachable.len());
+ assert!(
+ unreachable[0]
+ .as_str()
+ .unwrap()
+ .contains("HTTP/1.1 404 Not Found")
+ );
+
+ let pkg_advisories = json
+ .get("advisories")
+ .and_then(|v| v.get("vendor1/package1"))
+ .and_then(|v| v.as_array())
+ .unwrap();
+ assert_eq!(2, pkg_advisories.len());
+ assert_eq!("CVE-2023-12345", pkg_advisories[0]["cve"].as_str().unwrap());
+ assert_eq!(
+ "First repo advisory",
+ pkg_advisories[0]["title"].as_str().unwrap()
+ );
+ assert_eq!("CVE-2023-67890", pkg_advisories[1]["cve"].as_str().unwrap());
+ assert_eq!(
+ "Third repo advisory",
+ pkg_advisories[1]["title"].as_str().unwrap()
+ );
+ }
}
+/// ref: AuditorTest::testAuditWithIgnoreSeverity (ignoreSeverityProvider).
#[test]
-#[ignore = "requires getIOMock with expects() output expectations and getMockBuilder mock of ComposerRepository; no mocking infrastructure exists"]
fn test_audit_with_ignore_severity() {
- todo!()
+ struct Case {
+ packages: Vec<PackageInterfaceHandle>,
+ ignored_severities: IndexMap<String, Option<String>>,
+ exit_code: i64,
+ expected_output: Vec<Expectation>,
+ }
+
+ let cases: Vec<Case> = vec![
+ Case {
+ packages: vec![get_package("vendor1/package1", "2.0.0.0")],
+ ignored_severities: ignore_list(vec![("medium", None)]),
+ exit_code: 1,
+ expected_output: vec![Expectation::text(
+ "Found 2 ignored security vulnerability advisories affecting 1 package:",
+ )],
+ },
+ Case {
+ packages: vec![get_package("vendor1/package1", "2.0.0.0")],
+ ignored_severities: ignore_list(vec![("high", None)]),
+ exit_code: 1,
+ expected_output: vec![Expectation::text(
+ "Found 1 ignored security vulnerability advisory affecting 1 package:",
+ )],
+ },
+ Case {
+ packages: vec![get_package("vendor1/package1", "2.0.0.0")],
+ ignored_severities: ignore_list(vec![("high", None), ("medium", None)]),
+ exit_code: 0,
+ expected_output: vec![Expectation::text(
+ "Found 3 ignored security vulnerability advisories affecting 1 package:",
+ )],
+ },
+ ];
+
+ for case in cases {
+ let repo_set = get_repo_set();
+ let (io_mock, _io_guard) = get_io_mock(io_interface::NORMAL).unwrap();
+ let auditor = Auditor;
+ let result = {
+ let mut io_guard = io_mock.borrow_mut();
+ auditor
+ .audit(
+ &mut *io_guard,
+ &repo_set,
+ case.packages.clone(),
+ Auditor::FORMAT_PLAIN,
+ false,
+ IndexMap::new(),
+ Auditor::ABANDONED_IGNORE,
+ case.ignored_severities.clone(),
+ false,
+ IndexMap::new(),
+ )
+ .unwrap()
+ };
+ io_mock
+ .borrow_mut()
+ .expects(case.expected_output.clone(), true)
+ .unwrap();
+ assert_eq!(case.exit_code, result);
+ }
}
#[test]