//! ref: composer/tests/Composer/Test/Command/UpdateCommandTest.php
use crate::test_case::{
RunOptions, create_composer_lock, create_installed_json, get_application_tester, get_package,
init_temp_composer,
};
use serial_test::serial;
use shirabe::package::Link;
use shirabe_php_shim::PhpMixed;
use shirabe_semver::constraint::{AnyConstraint, MatchAllConstraint};
fn input(pairs: Vec<(&str, PhpMixed)>) -> Vec<(PhpMixed, PhpMixed)> {
pairs
.into_iter()
.map(|(k, v)| (PhpMixed::from(k), v))
.collect()
}
/// Minimal port of PHPUnit's `assertStringMatchesFormat` covering the placeholders these fixtures
/// use: `%d` (integer), `%f` (float), `%s` (non-newline run), `%a` (any run incl. newlines).
fn assert_string_matches_format(expected: &str, actual: &str) {
let mut pattern = String::from("(?s)^");
let mut chars = expected.chars().peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(&next) = chars.peek()
{
match next {
'd' => {
chars.next();
pattern.push_str(r"[0-9]+");
continue;
}
'f' => {
chars.next();
pattern.push_str(r"[+-]?[0-9]*\.?[0-9]+([eE][+-]?[0-9]+)?");
continue;
}
's' => {
chars.next();
pattern.push_str(r"[^\r\n]+");
continue;
}
'a' => {
chars.next();
pattern.push_str(r".+");
continue;
}
_ => {}
}
}
pattern.push_str(®ex::escape(&c.to_string()));
}
pattern.push('$');
let re = regex::Regex::new(&pattern).unwrap();
assert!(
re.is_match(actual),
"string did not match format.\n--- expected format ---\n{expected}\n--- actual ---\n{actual}"
);
}
fn root_dep_and_transitive_dep() -> serde_json::Value {
serde_json::json!({
"repositories": { "packages": { "type": "package", "package": [
{ "name": "root/req", "version": "1.0.0", "require": { "dep/pkg": "^1" } },
{ "name": "dep/pkg", "version": "1.0.0", "replace": { "replaced/pkg": "1.0.0" } },
{ "name": "dep/pkg", "version": "1.0.1", "replace": { "replaced/pkg": "1.0.1" } },
{ "name": "dep/pkg", "version": "1.0.2", "replace": { "replaced/pkg": "1.0.2" } },
] } },
"require": { "root/req": "1.*" },
})
}
/// ref: UpdateCommandTest::provideUpdates
fn provide_updates() -> Vec<(
&'static str,
serde_json::Value,
Vec<(&'static str, PhpMixed)>,
&'static str,
bool,
)> {
vec![
(
"simple update",
root_dep_and_transitive_dep(),
vec![],
"Loading composer repositories with package information
Updating dependencies
Lock file operations: 2 installs, 0 updates, 0 removals
- Locking dep/pkg (1.0.2)
- Locking root/req (1.0.0)
Installing dependencies from lock file (including require-dev)
Package operations: 2 installs, 0 updates, 0 removals
- Installing dep/pkg (1.0.2)
- Installing root/req (1.0.0)",
false,
),
(
"simple update with very verbose output",
root_dep_and_transitive_dep(),
vec![("-vv", PhpMixed::from(true))],
"Loading composer repositories with package information
Updating dependencies
Dependency resolution completed in %f seconds
Analyzed %d packages to resolve dependencies
Analyzed %d rules to resolve dependencies
Lock file operations: 2 installs, 0 updates, 0 removals
Installs: dep/pkg:1.0.2, root/req:1.0.0
- Locking dep/pkg (1.0.2) from package repo (defining 4 packages)
- Locking root/req (1.0.0) from package repo (defining 4 packages)
Installing dependencies from lock file (including require-dev)
Package operations: 2 installs, 0 updates, 0 removals
Installs: dep/pkg:1.0.2, root/req:1.0.0
- Installing dep/pkg (1.0.2)
- Installing root/req (1.0.0)",
false,
),
(
"update with temporary constraint + --no-install",
root_dep_and_transitive_dep(),
vec![
("--with", PhpMixed::List(vec![PhpMixed::from("dep/pkg:1.0.0")])),
("--no-install", PhpMixed::from(true)),
],
"Loading composer repositories with package information
Updating dependencies
Lock file operations: 2 installs, 0 updates, 0 removals
- Locking dep/pkg (1.0.0)
- Locking root/req (1.0.0)",
false,
),
(
"update with temporary constraint failing resolution",
root_dep_and_transitive_dep(),
vec![("--with", PhpMixed::List(vec![PhpMixed::from("dep/pkg:^2")]))],
"Loading composer repositories with package information
Updating dependencies
Your requirements could not be resolved to an installable set of packages.
Problem 1
- Root composer.json requires root/req 1.* -> satisfiable by root/req[1.0.0].
- root/req 1.0.0 requires dep/pkg ^1 -> found dep/pkg[1.0.0, 1.0.1, 1.0.2] but it conflicts with your temporary update constraint (dep/pkg:^2).",
false,
),
(
"update with temporary constraint failing resolution on root package",
root_dep_and_transitive_dep(),
vec![("--with", PhpMixed::List(vec![PhpMixed::from("root/req:^2")]))],
"The temporary constraint \"^2\" for \"root/req\" must be a subset of the constraint in your composer.json (1.*)
Run `composer require root/req` or `composer require root/req:^2` instead to replace the constraint",
false,
),
(
"update & bump",
root_dep_and_transitive_dep(),
vec![("--bump-after-update", PhpMixed::from(true))],
"Loading composer repositories with package information
Updating dependencies
Lock file operations: 2 installs, 0 updates, 0 removals
- Locking dep/pkg (1.0.2)
- Locking root/req (1.0.0)
Installing dependencies from lock file (including require-dev)
Package operations: 2 installs, 0 updates, 0 removals
- Installing dep/pkg (1.0.2)
- Installing root/req (1.0.0)
Bumping dependencies
Warning: Bumping dependency constraints is not recommended for libraries as it will narrow down your dependencies and may cause problems for your users.
If your package is not a library, you can explicitly specify the \"type\" by using \"composer config type project\".
Alternatively you can use --bump-after-update=dev to only bump dependencies within \"require-dev\".
No requirements to update in ./composer.json.",
true,
),
(
"update & bump with lock",
root_dep_and_transitive_dep(),
vec![
("--bump-after-update", PhpMixed::from(true)),
("--lock", PhpMixed::from(true)),
],
"Loading composer repositories with package information
Updating dependencies
Nothing to modify in lock file
Installing dependencies from lock file (including require-dev)
Nothing to install, update or remove",
true,
),
(
"update & bump dev only",
root_dep_and_transitive_dep(),
vec![("--bump-after-update", PhpMixed::from("dev"))],
"Loading composer repositories with package information
Updating dependencies
Lock file operations: 2 installs, 0 updates, 0 removals
- Locking dep/pkg (1.0.2)
- Locking root/req (1.0.0)
Installing dependencies from lock file (including require-dev)
Package operations: 2 installs, 0 updates, 0 removals
- Installing dep/pkg (1.0.2)
- Installing root/req (1.0.0)
Bumping dependencies
No requirements to update in ./composer.json.",
true,
),
(
"update & dump with failing update",
root_dep_and_transitive_dep(),
vec![
("--with", PhpMixed::List(vec![PhpMixed::from("dep/pkg:^2")])),
("--bump-after-update", PhpMixed::from(true)),
],
"Loading composer repositories with package information
Updating dependencies
Your requirements could not be resolved to an installable set of packages.
Problem 1
- Root composer.json requires root/req 1.* -> satisfiable by root/req[1.0.0].
- root/req 1.0.0 requires dep/pkg ^1 -> found dep/pkg[1.0.0, 1.0.1, 1.0.2] but it conflicts with your temporary update constraint (dep/pkg:^2).",
false,
),
(
"update with replaced name filter fails to resolve",
root_dep_and_transitive_dep(),
vec![("--with", PhpMixed::List(vec![PhpMixed::from("replaced/pkg:^2")]))],
"Loading composer repositories with package information
Updating dependencies
Your requirements could not be resolved to an installable set of packages.
Problem 1
- Root composer.json requires root/req 1.* -> satisfiable by root/req[1.0.0].
- root/req 1.0.0 requires dep/pkg ^1 -> found dep/pkg[1.0.0, 1.0.1, 1.0.2] but it conflicts with your temporary update constraint (replaced/pkg:^2).",
false,
),
]
}
#[test]
#[serial]
#[ignore = "regex porting: a Preg pattern starting with \"^array \" fails to compile in shirabe-php-shim preg.rs (\"unterminated regex pattern\"); unrelated to InputDefinition"]
fn test_update() {
for (label, composer_json, command, expected, create_lock) in provide_updates() {
let _tear_down = init_temp_composer(Some(&composer_json), None, None, true);
if create_lock {
create_composer_lock(&[], &[]);
}
let mut app_tester = get_application_tester();
let mut args = vec![
("command", PhpMixed::from("update")),
("--dry-run", PhpMixed::from(true)),
("--no-audit", PhpMixed::from(true)),
];
args.extend(command);
app_tester.run(input(args), RunOptions::default()).unwrap();
assert_string_matches_format(expected.trim(), app_tester.get_display().trim());
let _ = label;
}
}
#[test]
#[serial]
#[ignore = "resolver does not emit the temporary-update-constraint conflict; it performs the upgrade instead of reporting \"conflicts with your temporary update constraint\""]
fn test_update_with_patch_only() {
let composer_json = serde_json::json!({
"repositories": { "packages": { "type": "package", "package": [
{ "name": "root/req", "version": "1.0.0" },
{ "name": "root/req", "version": "1.0.1" },
{ "name": "root/req", "version": "1.1.0" },
{ "name": "root/req2", "version": "1.0.0" },
{ "name": "root/req2", "version": "1.0.1" },
{ "name": "root/req2", "version": "1.1.0" },
{ "name": "root/req3", "version": "1.0.0" },
{ "name": "root/req3", "version": "1.0.1" },
{ "name": "root/req3", "version": "1.1.0" },
] } },
"require": { "root/req": "1.*", "root/req2": "1.*", "root/req3": "1.*" },
});
let _tear_down = init_temp_composer(Some(&composer_json), None, None, true);
let package = get_package("root/req", "1.0.0");
let package2 = get_package("root/req2", "1.0.0");
let package3 = get_package("root/req3", "1.0.0");
create_composer_lock(&[package, package2, package3], &[]);
let mut app_tester = get_application_tester();
// root/req fails because of incompatible --with requirement
app_tester
.run(
input(vec![
("command", PhpMixed::from("update")),
("--dry-run", PhpMixed::from(true)),
("--no-audit", PhpMixed::from(true)),
("--no-install", PhpMixed::from(true)),
("--patch-only", PhpMixed::from(true)),
(
"--with",
PhpMixed::List(vec![PhpMixed::from("root/req:^1.1")]),
),
]),
RunOptions::default(),
)
.unwrap();
assert_string_matches_format(
"Loading composer repositories with package information
Updating dependencies
Your requirements could not be resolved to an installable set of packages.
Problem 1
- Root composer.json requires root/req 1.*, found root/req[1.0.0, 1.0.1, 1.1.0] but it conflicts with your temporary update constraint (root/req:[[>= 1.1.0.0-dev < 2.0.0.0-dev] [>= 1.0.0.0-dev < 1.1.0.0-dev]]).",
app_tester.get_display().trim(),
);
let mut app_tester = get_application_tester();
// root/req upgrades to 1.0.1 as that is compatible with the --with requirement now
// root/req2 upgrades to 1.0.1 only due to --patch-only
// root/req3 does not update as it is not in the allowlist
app_tester
.run(
input(vec![
("command", PhpMixed::from("update")),
("--dry-run", PhpMixed::from(true)),
("--no-audit", PhpMixed::from(true)),
("--no-install", PhpMixed::from(true)),
("--patch-only", PhpMixed::from(true)),
(
"--with",
PhpMixed::List(vec![PhpMixed::from("root/req:^1.0.1")]),
),
(
"packages",
PhpMixed::List(vec![
PhpMixed::from("root/req"),
PhpMixed::from("root/req2"),
]),
),
]),
RunOptions::default(),
)
.unwrap();
assert_string_matches_format(
"Loading composer repositories with package information
Updating dependencies
Lock file operations: 0 installs, 2 updates, 0 removals
- Upgrading root/req (1.0.0 => 1.0.1)
- Upgrading root/req2 (1.0.0 => 1.0.1)",
app_tester.get_display().trim(),
);
}
#[test]
#[serial]
#[ignore = "interactive selection path unported: --interactive requires a TTY and errors with \"--interactive cannot be used in non-interactive terminals.\""]
fn test_interactive_mode_throws_if_no_package_to_update() {
let composer_json = serde_json::json!({
"repositories": { "packages": { "type": "package", "package": [
{ "name": "root/req", "version": "1.0.0" },
] } },
"require": { "root/req": "1.*" },
});
let _tear_down = init_temp_composer(Some(&composer_json), None, None, true);
create_composer_lock(&[get_package("root/req", "1.0.0")], &[]);
let mut app_tester = get_application_tester();
app_tester.set_inputs(vec!["".to_string()]);
let err = app_tester
.run(
input(vec![
("command", PhpMixed::from("update")),
("--interactive", PhpMixed::from(true)),
]),
RunOptions::default(),
)
.expect_err("expected an exception when there is no package to update");
assert!(
err.to_string()
.contains("Could not find any package with new versions available"),
"got: {err}"
);
}
#[test]
#[serial]
#[ignore = "interactive selection path unported: --interactive requires a TTY and errors with \"--interactive cannot be used in non-interactive terminals.\""]
fn test_interactive_mode_throws_if_no_package_entered() {
let composer_json = serde_json::json!({
"repositories": { "packages": { "type": "package", "package": [
{ "name": "root/req", "version": "1.0.0" },
{ "name": "root/req", "version": "1.0.1" },
] } },
"require": { "root/req": "1.*" },
});
let _tear_down = init_temp_composer(Some(&composer_json), None, None, true);
create_composer_lock(&[get_package("root/req", "1.0.0")], &[]);
let mut app_tester = get_application_tester();
app_tester.set_inputs(vec!["".to_string()]);
let err = app_tester
.run(
input(vec![
("command", PhpMixed::from("update")),
("--interactive", PhpMixed::from(true)),
]),
RunOptions::default(),
)
.expect_err("expected an exception when no package name is entered");
assert!(
err.to_string()
.contains(r#"No package named "" is installed."#),
"got: {err}"
);
}
/// ref: UpdateCommandTest::provideInteractiveUpdates
fn provide_interactive_updates() -> Vec<(Vec<(&'static str, &'static str)>, &'static str)> {
vec![
(
vec![("dep/pkg", "1.0.1")],
"Lock file operations: 1 install, 1 update, 0 removals
- Locking another-dep/pkg (1.0.2)
- Upgrading dep/pkg (1.0.1 => 1.0.2)
Installing dependencies from lock file (including require-dev)
Package operations: 1 install, 1 update, 0 removals
- Upgrading dep/pkg (1.0.1 => 1.0.2)
- Installing another-dep/pkg (1.0.2)",
),
(
vec![("dep/pkg", "1.0.1"), ("another-dep/pkg", "1.0.2")],
"Lock file operations: 0 installs, 1 update, 0 removals
- Upgrading dep/pkg (1.0.1 => 1.0.2)
Installing dependencies from lock file (including require-dev)
Package operations: 0 installs, 1 update, 0 removals
- Upgrading dep/pkg (1.0.1 => 1.0.2)",
),
]
}
#[test]
#[serial]
#[ignore = "interactive selection path unported: --interactive requires a TTY and errors with \"--interactive cannot be used in non-interactive terminals.\""]
fn test_interactive_tmp() {
for (package_names, expected) in provide_interactive_updates() {
let composer_json = serde_json::json!({
"repositories": { "packages": { "type": "package", "package": [
{ "name": "root/req", "version": "1.0.0", "require": { "dep/pkg": "^1" } },
{ "name": "dep/pkg", "version": "1.0.0" },
{ "name": "dep/pkg", "version": "1.0.1" },
{ "name": "dep/pkg", "version": "1.0.2" },
{ "name": "another-dep/pkg", "version": "1.0.2" },
] } },
"require": { "root/req": "1.*" },
});
let _tear_down = init_temp_composer(Some(&composer_json), None, None, true);
let root_package = get_package("root/req", "1.0.0");
let mut packages = vec![root_package.clone()];
for (pkg, ver) in &package_names {
packages.push(get_package(pkg, ver));
}
let mut requires = indexmap::IndexMap::new();
requires.insert(
"dep/pkg".to_string(),
Link::new(
"root/req".to_string(),
"dep/pkg".to_string(),
AnyConstraint::MatchAll(MatchAllConstraint::new(None)),
Some(Link::TYPE_REQUIRE.to_string()),
"^1".to_string(),
),
);
requires.insert(
"another-dep/pkg".to_string(),
Link::new(
"root/req".to_string(),
"another-dep/pkg".to_string(),
AnyConstraint::MatchAll(MatchAllConstraint::new(None)),
Some(Link::TYPE_REQUIRE.to_string()),
"^1".to_string(),
),
);
root_package.__set_requires(requires);
create_composer_lock(&packages, &[]);
create_installed_json(&packages, &[], true);
let mut app_tester = get_application_tester();
let mut inputs: Vec = package_names
.iter()
.map(|(pkg, _)| pkg.to_string())
.collect();
inputs.push("".to_string());
inputs.push("yes".to_string());
app_tester.set_inputs(inputs);
app_tester
.run(
input(vec![
("command", PhpMixed::from("update")),
("--interactive", PhpMixed::from(true)),
("--no-audit", PhpMixed::from(true)),
("--dry-run", PhpMixed::from(true)),
]),
RunOptions {
interactive: Some(true),
..RunOptions::default()
},
)
.unwrap();
assert!(
app_tester.get_display().trim().ends_with(expected.trim()),
"expected display to end with:\n{expected}\n--- got ---\n{}",
app_tester.get_display()
);
}
}
#[test]
#[serial]
fn test_no_security_blocking_allows_insecure_packages() {
let composer_json = serde_json::json!({
"repositories": { "packages": {
"type": "package",
"package": [
{ "name": "vulnerable/pkg", "version": "1.0.0" },
{ "name": "vulnerable/pkg", "version": "1.1.0" },
],
"security-advisories": {
"vulnerable/pkg": [
{
"advisoryId": "PKSA-test-001",
"packageName": "vulnerable/pkg",
"remoteId": "CVE-2024-1234",
"title": "Test Security Vulnerability",
"link": "https://example.com/advisory",
"cve": "CVE-2024-1234",
"affectedVersions": ">=1.1.0,<2.0.0",
"source": "test",
"reportedAt": "2024-01-01 00:00:00",
"composerRepository": "Package Repository",
"severity": "high",
"sources": [ { "name": "test", "remoteId": "CVE-2024-1234" } ],
},
],
},
} },
"require": { "vulnerable/pkg": "^1.0" },
});
let _tear_down = init_temp_composer(Some(&composer_json), None, None, true);
// Test 1: Without --no-security-blocking, the vulnerable version 1.1.0 should be filtered out
let mut app_tester = get_application_tester();
app_tester
.run(
input(vec![
("command", PhpMixed::from("update")),
("--dry-run", PhpMixed::from(true)),
("--no-audit", PhpMixed::from(true)),
("--no-install", PhpMixed::from(true)),
]),
RunOptions::default(),
)
.unwrap();
let display = app_tester.get_display();
assert!(
display.contains("Locking vulnerable/pkg (1.0.0)"),
"got: {display}"
);
assert!(
!display.contains("Locking vulnerable/pkg (1.1.0)"),
"got: {display}"
);
// Test 2: With --no-security-blocking, the vulnerable version 1.1.0 should be allowed
let mut app_tester = get_application_tester();
app_tester
.run(
input(vec![
("command", PhpMixed::from("update")),
("--dry-run", PhpMixed::from(true)),
("--no-audit", PhpMixed::from(true)),
("--no-install", PhpMixed::from(true)),
("--no-security-blocking", PhpMixed::from(true)),
]),
RunOptions::default(),
)
.unwrap();
let display = app_tester.get_display();
assert!(
display.contains("Locking vulnerable/pkg (1.1.0)"),
"got: {display}"
);
assert!(
!display.contains("Locking vulnerable/pkg (1.0.0)"),
"got: {display}"
);
}
#[test]
#[serial]
#[ignore = "solver error \"Fixed package __root__ 1.0.0+no-version-set was not added to solver pool.\" on the no-lockfile bump path (resolver pool construction)"]
fn test_bump_after_update_without_lockfile() {
let composer_json = serde_json::json!({
"repositories": { "packages": { "type": "package", "package": [
{ "name": "root/a", "version": "1.0.0" },
{ "name": "root/a", "version": "1.1.0" },
] } },
"require-dev": { "root/a": "^1.0.0" },
"config": { "lock": false },
});
let _tear_down = init_temp_composer(Some(&composer_json), None, None, true);
let mut app_tester = get_application_tester();
app_tester
.run(
input(vec![
("command", PhpMixed::from("update")),
("--dry-run", PhpMixed::from(true)),
("--no-audit", PhpMixed::from(true)),
("--bump-after-update", PhpMixed::from("dev")),
]),
RunOptions::default(),
)
.unwrap();
assert_string_matches_format(
"Loading composer repositories with package information
Updating dependencies
Package operations: 1 install, 0 updates, 0 removals
- Installing root/a (1.1.0)
Bumping dependencies
./composer.json would be updated with:
- require-dev.root/a: ^1.1.0",
app_tester.get_display().trim(),
);
}