g1t/services/security/src/security_updates.rs
| 1 | //! Security updates: for each vulnerable package with a fix, g1t itself |
| 2 | //! opens a pull request that raises its version. |
| 3 | //! |
| 4 | //! 1. A dependency scan asks the runner for a `bump` (most severe first): |
| 5 | //! a sandbox raises the package in each lockfile with the ecosystem's |
| 6 | //! own tool and pushes that to `g1t/security/<package>-<version>`. |
| 7 | //! 2. That push (`git.push`) opens the pull request, authored by g1t |
| 8 | //! (`User::system`). It lands through the branch's required checks like |
| 9 | //! any other. An older one for the same package is closed as superseded. |
| 10 | //! 3. When its checks fail because code has to change, or the sandbox |
| 11 | //! never pushed, the pull request is closed and an issue is opened for |
| 12 | //! g1t-agent, started by g1t. That is the only time an agent is used. |
| 13 | //! 4. A package no longer vulnerable closes its update as superseded. |
| 14 | //! |
| 15 | //! Each step is written to the alerts' activity log. |
| 16 | |
| 17 | use std::collections::BTreeMap; |
| 18 | |
| 19 | use g1t_contracts::repos::RepoPath; |
| 20 | use g1t_contracts::security::{BumpArgs, UpdateState, update_branch}; |
| 21 | use g1t_contracts::time::rfc3339; |
| 22 | use g1t_contracts::work::{OpenIssueArgs, OpenPullArgs, Pull, PullActionArgs, PullDetail, PullStatus, Runtime, ViewArgs}; |
| 23 | use g1t_contracts::{Outcome, User}; |
| 24 | use g1t_kit::now_ms; |
| 25 | use g1t_scan::osv::{self, Severity}; |
| 26 | use g1t_scan::version; |
| 27 | use serde_json::{Value, json}; |
| 28 | use worker::Result; |
| 29 | |
| 30 | use crate::Security; |
| 31 | use crate::deps::{advisory_table, issue_text}; |
| 32 | use crate::store::{Activity, RepoRow, UpdateRow, VulnRow}; |
| 33 | |
| 34 | /// Security updates asked for per scan, most severe first. |
| 35 | const MAX_NEW_UPDATES: usize = 8; |
| 36 | /// A sandbox that has not pushed its branch after this long is taken to |
| 37 | /// have failed. |
| 38 | const STALLED_MS: u64 = 45 * 60 * 1000; |
| 39 | /// A failed update is tried again after this long. |
| 40 | const RETRY_FAILED_MS: u64 = 24 * 60 * 60 * 1000; |
| 41 | |
| 42 | /// What to do about a package's existing update, given the version it |
| 43 | /// should now reach. |
| 44 | #[derive(Debug, PartialEq, Eq)] |
| 45 | pub enum Next { |
| 46 | /// Leave it: in flight, done, or someone closed it. |
| 47 | Wait, |
| 48 | /// Ask for a new one. |
| 49 | Request, |
| 50 | } |
| 51 | |
| 52 | /// Whether a package with an update in `state` to `current`, last changed |
| 53 | /// at `updated_at`, needs a new one to reach `target`. A higher target |
| 54 | /// always does; the same one only when the last was superseded (it is |
| 55 | /// vulnerable again) or failed long enough ago. |
| 56 | pub fn next_step(state: UpdateState, current: &str, target: &str, updated_at: &str, retry_after: &str) -> Next { |
| 57 | if version::compare(target, current) == std::cmp::Ordering::Greater { |
| 58 | return Next::Request; |
| 59 | } |
| 60 | match state { |
| 61 | UpdateState::Superseded => Next::Request, |
| 62 | UpdateState::Failed if updated_at < retry_after => Next::Request, |
| 63 | _ => Next::Wait, |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /// The pull request's title. |
| 68 | pub fn pull_title(package: &str, target: &str) -> String { |
| 69 | format!("Upgrade {package} to {target}").chars().take(200).collect() |
| 70 | } |
| 71 | |
| 72 | /// The pull request's body: what it fixes, where, and what happens if |
| 73 | /// raising the version is not enough. |
| 74 | pub fn pull_text(ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> String { |
| 75 | let mut from: Vec<&str> = vulns.iter().map(|vuln| vuln.version.as_str()).collect(); |
| 76 | from.sort(); |
| 77 | from.dedup(); |
| 78 | let mut lockfiles: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect(); |
| 79 | lockfiles.sort(); |
| 80 | lockfiles.dedup(); |
| 81 | let mut body = format!( |
| 82 | "Upgrades `{package}` ({ecosystem}) from {} to **{target}**, which fixes these known vulnerabilities:\n\n", |
| 83 | from.join(", ") |
| 84 | ); |
| 85 | body.push_str(&advisory_table(vulns)); |
| 86 | body.push_str(&format!( |
| 87 | "\nLockfiles changed: {}.\n\n\ |
| 88 | Only the version changes. This pull request lands through this branch's required checks like any other. \ |
| 89 | If they fail because code has to change, g1t closes it and puts g1t-agent on an issue to make the change.\n\n\ |
| 90 | ---\n_Opened by g1t's security updates. Turn them off for this project on its Security page._", |
| 91 | lockfiles.iter().map(|path| format!("`{path}`")).collect::<Vec<_>>().join(", ") |
| 92 | )); |
| 93 | body |
| 94 | } |
| 95 | |
| 96 | impl Security { |
| 97 | fn path_of(repo: &RepoRow) -> RepoPath { |
| 98 | RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() } |
| 99 | } |
| 100 | |
| 101 | async fn get_pull(&self, repo: &RepoRow, number: u32) -> Result<Option<Pull>> { |
| 102 | let found: Outcome<PullDetail> = g1t_kit::call( |
| 103 | &self.work, |
| 104 | "get_pull", |
| 105 | &ViewArgs { repo: Self::path_of(repo), number, viewer: Some(User::system(&repo.namespace)), after_seq: 0 }, |
| 106 | ) |
| 107 | .await?; |
| 108 | Ok(found.into_result().ok().map(|detail| detail.pull)) |
| 109 | } |
| 110 | |
| 111 | /// Closes one of g1t's pull requests, saying why first. |
| 112 | async fn close_with(&self, repo: &RepoRow, number: u32, why: String) -> Result<()> { |
| 113 | let system = User::system(&repo.namespace); |
| 114 | self.comment(&system, &Self::path_of(repo), number, why).await?; |
| 115 | let _: Outcome<Value> = g1t_kit::call( |
| 116 | &self.work, |
| 117 | "close_pull", |
| 118 | &PullActionArgs { |
| 119 | actor: system, |
| 120 | repo: Self::path_of(repo), |
| 121 | number, |
| 122 | summary: String::new(), |
| 123 | keep_issue_open: false, |
| 124 | ignore_checks: false, |
| 125 | }, |
| 126 | ) |
| 127 | .await?; |
| 128 | Ok(()) |
| 129 | } |
| 130 | |
| 131 | /// Records `action` on every open alert of an update's package. |
| 132 | async fn note(&self, row: &UpdateRow, action: &str, actor: Option<&str>, number: Option<u32>, comment: Option<&str>) -> Result<()> { |
| 133 | let ids = self.store.open_ids(&row.repo_id, &row.ecosystem, &row.package).await?; |
| 134 | let activity: Vec<Activity> = ids |
| 135 | .iter() |
| 136 | .map(|id| Activity { alert_id: id, action, actor, reason: None, comment, number }) |
| 137 | .collect(); |
| 138 | self.store.record(&row.repo_id, &activity).await |
| 139 | } |
| 140 | |
| 141 | /// After a dependency scan: asks for an update for each vulnerable |
| 142 | /// package with a fix (when `enabled`), and supersedes those whose |
| 143 | /// package is no longer vulnerable. |
| 144 | pub async fn security_updates(&self, repo: &RepoRow, enabled: bool) -> Result<()> { |
| 145 | let open = self.store.open_vulnerabilities(&repo.repo_id).await?; |
| 146 | let mut by_package: BTreeMap<(String, String), Vec<&VulnRow>> = BTreeMap::new(); |
| 147 | for vuln in &open { |
| 148 | by_package.entry((vuln.ecosystem.clone(), vuln.package.clone())).or_default().push(vuln); |
| 149 | } |
| 150 | // In flight for a package that is no longer vulnerable: no longer needed. |
| 151 | for row in self.store.updates(&repo.repo_id).await? { |
| 152 | if !row.state().in_progress() || by_package.contains_key(&(row.ecosystem.clone(), row.package.clone())) { |
| 153 | continue; |
| 154 | } |
| 155 | self.supersede(repo, &row, format!("`{}` is no longer vulnerable here, so this is no longer needed.", row.package)) |
| 156 | .await?; |
| 157 | } |
| 158 | if !enabled { |
| 159 | return Ok(()); |
| 160 | } |
| 161 | let mut groups: Vec<((String, String), Vec<&VulnRow>)> = by_package |
| 162 | .into_iter() |
| 163 | .filter(|(_, vulns)| vulns.iter().any(|vuln| vuln.fixed_version.is_some())) |
| 164 | .collect(); |
| 165 | groups.sort_by_key(|(_, vulns)| std::cmp::Reverse(vulns.iter().map(|v| Severity::parse(&v.severity)).max())); |
| 166 | let retry_after = rfc3339(now_ms().saturating_sub(RETRY_FAILED_MS)); |
| 167 | let mut asked = 0; |
| 168 | for ((ecosystem, package), vulns) in groups { |
| 169 | if asked >= MAX_NEW_UPDATES { |
| 170 | break; |
| 171 | } |
| 172 | let Some(target) = osv::upgrade_target(vulns.iter().filter_map(|v| v.fixed_version.as_deref())) else { |
| 173 | continue; |
| 174 | }; |
| 175 | match self.store.update(&repo.repo_id, &ecosystem, &package).await? { |
| 176 | Some(row) => { |
| 177 | if next_step(row.state(), &row.target, &target, &row.updated_at, &retry_after) == Next::Wait { |
| 178 | continue; |
| 179 | } |
| 180 | } |
| 181 | None => { |
| 182 | // An upgrade issue from before security updates, still |
| 183 | // open, is left to the agent on it. |
| 184 | if let Some(existing) = self.store.upgrade(&repo.repo_id, &ecosystem, &package).await? |
| 185 | && self |
| 186 | .issue(&User::system(&repo.namespace), &Self::path_of(repo), existing.number as u32) |
| 187 | .await? |
| 188 | .is_some_and(|issue| issue.state == g1t_contracts::work::State::Open) |
| 189 | { |
| 190 | continue; |
| 191 | } |
| 192 | } |
| 193 | } |
| 194 | asked += 1; |
| 195 | self.request(repo, &ecosystem, &package, &target, &vulns).await?; |
| 196 | } |
| 197 | Ok(()) |
| 198 | } |
| 199 | |
| 200 | /// Asks the runner to make the change on its branch. |
| 201 | async fn request(&self, repo: &RepoRow, ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> Result<()> { |
| 202 | let branch = update_branch(package, target); |
| 203 | let mut lockfiles: Vec<String> = vulns.iter().map(|vuln| vuln.manifest.clone()).collect(); |
| 204 | lockfiles.sort(); |
| 205 | lockfiles.dedup(); |
| 206 | let args = BumpArgs { |
| 207 | repo: Self::path_of(repo), |
| 208 | ecosystem: ecosystem.to_owned(), |
| 209 | package: package.to_owned(), |
| 210 | version: target.to_owned(), |
| 211 | lockfiles, |
| 212 | branch: branch.clone(), |
| 213 | message: format!("Upgrade {package} to {target}"), |
| 214 | }; |
| 215 | let started: std::result::Result<(), String> = match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", &args).await { |
| 216 | Ok(Outcome::Ok(_)) => Ok(()), |
| 217 | Ok(Outcome::Fail(refused)) => Err(refused.message), |
| 218 | Err(error) => Err(format!("the runner could not be reached: {error}")), |
| 219 | }; |
| 220 | self.store.request_update(&repo.repo_id, ecosystem, package, target, &branch).await?; |
| 221 | let Some(row) = self.store.update(&repo.repo_id, ecosystem, package).await? else { |
| 222 | return Ok(()); |
| 223 | }; |
| 224 | match started { |
| 225 | Ok(()) => self.note(&row, "update_requested", Some(g1t_contracts::system::USERNAME), None, None).await, |
| 226 | Err(reason) => { |
| 227 | let error = format!("g1t could not start the security update: {reason}"); |
| 228 | self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?; |
| 229 | self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await |
| 230 | } |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | /// A security update's branch was pushed: its pull request opens, and |
| 235 | /// an older one for the same package is closed as superseded. |
| 236 | pub async fn update_pushed(&self, repo_id: &str, branch: &str) -> Result<()> { |
| 237 | let Some(row) = self.store.update_by_branch(repo_id, branch).await? else { |
| 238 | return Ok(()); |
| 239 | }; |
| 240 | if row.state() != UpdateState::Requested { |
| 241 | return Ok(()); |
| 242 | } |
| 243 | let Some(repo) = self.store.repo(repo_id).await? else { |
| 244 | return Ok(()); |
| 245 | }; |
| 246 | let open = self.store.open_vulnerabilities(repo_id).await?; |
| 247 | let vulns: Vec<&VulnRow> = open |
| 248 | .iter() |
| 249 | .filter(|vuln| vuln.ecosystem == row.ecosystem && vuln.package == row.package) |
| 250 | .collect(); |
| 251 | let system = User::system(&repo.namespace); |
| 252 | let opened: Outcome<Pull> = g1t_kit::call( |
| 253 | &self.work, |
| 254 | "open_pull", |
| 255 | &OpenPullArgs { |
| 256 | actor: system, |
| 257 | repo: Self::path_of(&repo), |
| 258 | issue: None, |
| 259 | title: pull_title(&row.package, &row.target), |
| 260 | body: pull_text(&row.ecosystem, &row.package, &row.target, &vulns), |
| 261 | branch: Some(branch.to_owned()), |
| 262 | agent: String::new(), |
| 263 | runtime: Runtime::External, |
| 264 | }, |
| 265 | ) |
| 266 | .await?; |
| 267 | let pull = match opened { |
| 268 | Outcome::Ok(pull) => pull, |
| 269 | Outcome::Fail(refused) => { |
| 270 | let error = format!("The pull request could not be opened: {}", refused.message); |
| 271 | self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?; |
| 272 | return self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await; |
| 273 | } |
| 274 | }; |
| 275 | if let Some(older) = row.pull().filter(|older| *older != pull.number) { |
| 276 | self.close_with(&repo, older, format!("Superseded by #{}, which upgrades `{}` to {}.", pull.number, row.package, row.target)) |
| 277 | .await?; |
| 278 | } |
| 279 | self.store.set_update(&row, UpdateState::Open, Some(pull.number), None, None).await?; |
| 280 | self.note(&row, "update_opened", Some(g1t_contracts::system::USERNAME), Some(pull.number), None).await |
| 281 | } |
| 282 | |
| 283 | /// A pull request merged, closed or checked: if it is a security |
| 284 | /// update's, where the update stands now. |
| 285 | pub async fn update_pull_event(&self, kind: &str, repo_id: &str, number: u32, status: Option<&str>, actor: Option<&str>) -> Result<()> { |
| 286 | let Some(row) = self.store.update_by_pull(repo_id, number).await? else { |
| 287 | return Ok(()); |
| 288 | }; |
| 289 | if row.state() != UpdateState::Open { |
| 290 | return Ok(()); |
| 291 | } |
| 292 | match kind { |
| 293 | "pull.merged" => { |
| 294 | self.store.set_update(&row, UpdateState::Merged, Some(number), None, None).await?; |
| 295 | self.note(&row, "update_merged", actor, Some(number), None).await |
| 296 | } |
| 297 | "pull.closed" => { |
| 298 | self.store.set_update(&row, UpdateState::Closed, Some(number), None, None).await?; |
| 299 | self.note(&row, "update_closed", actor, Some(number), None).await |
| 300 | } |
| 301 | "checks.completed" if status == Some("failed") => { |
| 302 | let Some(repo) = self.store.repo(repo_id).await? else { |
| 303 | return Ok(()); |
| 304 | }; |
| 305 | self.needs_code(&repo, &row, "Raising the version alone fails this branch's required checks").await |
| 306 | } |
| 307 | _ => Ok(()), |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | /// Closes an update that is no longer needed: its pull request, if it |
| 312 | /// has one still open (one that merged meanwhile is recorded merged). |
| 313 | async fn supersede(&self, repo: &RepoRow, row: &UpdateRow, why: String) -> Result<()> { |
| 314 | if let Some(number) = row.pull() { |
| 315 | match self.get_pull(repo, number).await? { |
| 316 | Some(pull) if pull.status == PullStatus::Merged => { |
| 317 | return self.store.set_update(row, UpdateState::Merged, Some(number), row.issue(), None).await; |
| 318 | } |
| 319 | Some(pull) if matches!(pull.status, PullStatus::Open | PullStatus::Draft) => { |
| 320 | self.store.set_update(row, UpdateState::Superseded, Some(number), row.issue(), None).await?; |
| 321 | self.close_with(repo, number, why).await?; |
| 322 | return self.note(row, "update_superseded", Some(g1t_contracts::system::USERNAME), Some(number), None).await; |
| 323 | } |
| 324 | _ => {} |
| 325 | } |
| 326 | } |
| 327 | self.store.set_update(row, UpdateState::Superseded, row.pull(), row.issue(), None).await |
| 328 | } |
| 329 | |
| 330 | /// Updates whose sandbox never pushed: raising the version did not |
| 331 | /// work, so g1t-agent gets an issue for it. |
| 332 | pub async fn stalled_updates(&self) -> Result<()> { |
| 333 | let before = rfc3339(now_ms().saturating_sub(STALLED_MS)); |
| 334 | for row in self.store.stalled_updates(&before, 10).await? { |
| 335 | let Some(repo) = self.store.repo(&row.repo_id).await? else { continue }; |
| 336 | if repo.upkeep == 0 || !self.active(&repo.repo_id).await? { |
| 337 | self.store |
| 338 | .set_update(&row, UpdateState::Failed, row.pull(), None, Some("The version could not be raised in a sandbox.")) |
| 339 | .await?; |
| 340 | continue; |
| 341 | } |
| 342 | self.needs_code(&repo, &row, "The version could not be raised in a sandbox on its own").await?; |
| 343 | } |
| 344 | Ok(()) |
| 345 | } |
| 346 | |
| 347 | /// Raising the version is not enough: closes g1t's pull request, opens |
| 348 | /// an issue for the change, and puts g1t-agent on it, started by g1t. |
| 349 | async fn needs_code(&self, repo: &RepoRow, row: &UpdateRow, why: &str) -> Result<()> { |
| 350 | let path = Self::path_of(repo); |
| 351 | let system = User::system(&repo.namespace); |
| 352 | let open = self.store.open_vulnerabilities(&repo.repo_id).await?; |
| 353 | let vulns: Vec<&VulnRow> = open |
| 354 | .iter() |
| 355 | .filter(|vuln| vuln.ecosystem == row.ecosystem && vuln.package == row.package) |
| 356 | .collect(); |
| 357 | if vulns.is_empty() { |
| 358 | return self.supersede(repo, row, format!("`{}` is no longer vulnerable here.", row.package)).await; |
| 359 | } |
| 360 | let issue: Outcome<g1t_contracts::work::Issue> = g1t_kit::call( |
| 361 | &self.work, |
| 362 | "open_issue", |
| 363 | &OpenIssueArgs { |
| 364 | actor: system.clone(), |
| 365 | repo: path.clone(), |
| 366 | title: format!("Upgrade {} to {}: needs code changes", row.package, row.target).chars().take(200).collect(), |
| 367 | body: issue_text(&row.ecosystem, &row.package, &row.target, &vulns, &[]), |
| 368 | labels: vec!["dependencies".to_owned(), "security".to_owned()], |
| 369 | checks: Vec::new(), |
| 370 | }, |
| 371 | ) |
| 372 | .await?; |
| 373 | let issue = match issue { |
| 374 | Outcome::Ok(issue) => issue, |
| 375 | Outcome::Fail(refused) => { |
| 376 | let error = format!("{why}, and the issue for it could not be opened: {}", refused.message); |
| 377 | self.store.set_update(row, UpdateState::Failed, row.pull(), None, Some(&error)).await?; |
| 378 | return self.note(row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await; |
| 379 | } |
| 380 | }; |
| 381 | self.store |
| 382 | .set_update(row, UpdateState::NeedsCode, row.pull(), Some(issue.number), Some(why)) |
| 383 | .await?; |
| 384 | if let Some(number) = row.pull() { |
| 385 | self.close_with(repo, number, format!("{why}, so code has to change too. g1t-agent is making the change in #{}.", issue.number)) |
| 386 | .await?; |
| 387 | } |
| 388 | let started: Outcome<Value> = |
| 389 | g1t_kit::call(&self.runner, "run", &json!({ "actor": system, "repo": path, "issue": issue.number })).await?; |
| 390 | if let Outcome::Fail(refused) = started { |
| 391 | self.comment(&system, &path, issue.number, format!( |
| 392 | "g1t could not put an agent on this upgrade: {}\n\nAssign it to g1t-agent once agents can run here, or upgrade it by hand.", |
| 393 | refused.message |
| 394 | )) |
| 395 | .await?; |
| 396 | } |
| 397 | self.note(row, "update_needs_code", Some(g1t_contracts::system::USERNAME), Some(issue.number), Some(why)).await |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | #[cfg(test)] |
| 402 | mod tests { |
| 403 | use super::*; |
| 404 | |
| 405 | fn row(version: &str, fixed: &str) -> VulnRow { |
| 406 | VulnRow { |
| 407 | id: "vul_1".into(), |
| 408 | repo_id: "rep_1".into(), |
| 409 | ecosystem: "npm".into(), |
| 410 | package: "lodash".into(), |
| 411 | version: version.into(), |
| 412 | manifest: "web/package-lock.json".into(), |
| 413 | osv_id: "GHSA-35jh-r3h4-6jhm".into(), |
| 414 | advisory: "GHSA-35jh-r3h4-6jhm".into(), |
| 415 | summary: "Command Injection in lodash".into(), |
| 416 | severity: "high".into(), |
| 417 | fixed_version: Some(fixed.into()), |
| 418 | status: "open".into(), |
| 419 | found_at: "2026-10-04T00:00:00Z".into(), |
| 420 | fixed_at: None, |
| 421 | number: None, |
| 422 | dismiss_reason: None, |
| 423 | dismiss_comment: None, |
| 424 | dismissed_by: None, |
| 425 | dismissed_at: None, |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | #[test] |
| 430 | fn a_newer_fix_asks_again_and_the_same_one_waits() { |
| 431 | let retry = "2026-10-05T00:00:00Z"; |
| 432 | let at = "2026-10-06T00:00:00Z"; |
| 433 | assert_eq!(next_step(UpdateState::Open, "4.17.20", "4.17.21", at, retry), Next::Request); |
| 434 | assert_eq!(next_step(UpdateState::Open, "4.17.21", "4.17.21", at, retry), Next::Wait); |
| 435 | assert_eq!(next_step(UpdateState::Requested, "4.17.21", "4.17.21", at, retry), Next::Wait); |
| 436 | assert_eq!(next_step(UpdateState::Merged, "4.17.21", "4.17.21", at, retry), Next::Wait); |
| 437 | // A person closed it: left alone until a newer fix. |
| 438 | assert_eq!(next_step(UpdateState::Closed, "4.17.21", "4.17.21", at, retry), Next::Wait); |
| 439 | assert_eq!(next_step(UpdateState::Closed, "4.17.21", "4.17.22", at, retry), Next::Request); |
| 440 | // Vulnerable again after it was no longer needed. |
| 441 | assert_eq!(next_step(UpdateState::Superseded, "4.17.21", "4.17.21", at, retry), Next::Request); |
| 442 | // Failed: tried again a day later. |
| 443 | assert_eq!(next_step(UpdateState::Failed, "4.17.21", "4.17.21", at, retry), Next::Wait); |
| 444 | assert_eq!(next_step(UpdateState::Failed, "4.17.21", "4.17.21", "2026-10-04T00:00:00Z", retry), Next::Request); |
| 445 | assert_eq!(next_step(UpdateState::NeedsCode, "4.17.21", "4.17.21", at, retry), Next::Wait); |
| 446 | } |
| 447 | |
| 448 | #[test] |
| 449 | fn the_pull_request_says_what_it_fixes_and_where() { |
| 450 | let a = row("4.17.20", "4.17.21"); |
| 451 | let mut b = row("4.17.19", "4.17.21"); |
| 452 | b.manifest = "package-lock.json".into(); |
| 453 | let body = pull_text("npm", "lodash", "4.17.21", &[&a, &b]); |
| 454 | assert!(body.starts_with("Upgrades `lodash` (npm) from 4.17.19, 4.17.20 to **4.17.21**")); |
| 455 | assert!(body.contains("[GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm)")); |
| 456 | assert!(body.contains("Lockfiles changed: `package-lock.json`, `web/package-lock.json`.")); |
| 457 | assert!(body.contains("required checks")); |
| 458 | assert_eq!(pull_title("lodash", "4.17.21"), "Upgrade lodash to 4.17.21"); |
| 459 | assert_eq!(update_branch("@babel/core", "7.24.1"), "g1t/security/babel-core-7.24.1"); |
| 460 | assert_eq!(update_branch("golang.org/x/net", "v0.23.0"), "g1t/security/golang.org-x-net-v0.23.0"); |
| 461 | } |
| 462 | } |