Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1 | //! Settings, and the workspace's security overview: open alerts by type |
| 2 | //! and severity across its repositories, how they moved, which repository | |
| 3 | //! has which feature on, and those most in need. | |
| 4 | ||
| 5 | use std::collections::BTreeMap; | |
| 6 | ||
| 7 | use g1t_contracts::access::{self, Capability}; | |
| 8 | use g1t_contracts::security::SeverityCounts; | |
| 9 | use g1t_contracts::security_suite::{ | |
| 10 | AlertType, RepoCoverage, SecuritySettingsArgs, SecuritySettingsView, SetSecuritySettingsArgs, SetWorkspaceSecuritySettingsArgs, | |
| 11 | TrendPoint, TypeTotals, WorkspaceAlert, WorkspaceAlertsArgs, WorkspaceOverview, WorkspaceOverviewArgs, | |
| 12 | WorkspaceSecuritySettingsArgs, WorkspaceSecurityView, | |
| 13 | }; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 14 | use g1t_contracts::repos::{MAX_READABLE, ReadableArgs, Repo}; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 15 | use g1t_contracts::time::rfc3339; |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 16 | use g1t_contracts::{FailureCode, Outcome}; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 17 | use g1t_kit::now_ms; |
| 18 | use worker::Result; | |
| 19 | ||
| 20 | use crate::Security; | |
| 21 | use crate::store::RepoRow; | |
| 22 | ||
| 23 | const DAY_MS: u64 = 24 * 60 * 60 * 1000; | |
| 24 | /// Repositories snapshotted per sweep. | |
| 25 | const SNAPSHOTS_PER_SWEEP: u32 = 25; | |
| 26 | const GATES: [&str; 6] = ["none", "errors", "critical", "high", "medium", "any"]; | |
| 27 | const SEVERITY_NAMES: [&str; 5] = ["critical", "high", "medium", "low", "none"]; | |
| 28 | /// Licenses a repository may deny, at most. | |
| 29 | const MAX_DENIED: usize = 50; | |
| 30 | ||
| 31 | fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> { | |
| 32 | Outcome::fail(code, message) | |
| 33 | } | |
| 34 | ||
| 35 | fn add(total: &mut SeverityCounts, counts: &SeverityCounts) { | |
| 36 | total.critical += counts.critical; | |
| 37 | total.high += counts.high; | |
| 38 | total.medium += counts.medium; | |
| 39 | total.low += counts.low; | |
| 40 | total.unknown += counts.unknown; | |
| 41 | } | |
| 42 | ||
| 43 | fn sum(counts: &SeverityCounts) -> u32 { | |
| 44 | counts.critical + counts.high + counts.medium + counts.low + counts.unknown | |
| 45 | } | |
| 46 | ||
| 47 | /// The days of a trend, oldest first, ending today: `YYYY-MM-DD`. | |
| 48 | pub fn days(today_ms: u64, count: u32) -> Vec<String> { | |
| 49 | (0..count).rev().map(|ago| rfc3339(today_ms.saturating_sub(u64::from(ago) * DAY_MS))[..10].to_owned()).collect() | |
| 50 | } | |
| 51 | ||
| 52 | /// Repositories most in need first: open critical, then high, then the rest. | |
| 53 | pub fn rank(repos: &mut [RepoCoverage]) { | |
| 54 | let key = |repo: &RepoCoverage| { | |
| 55 | let mut total = SeverityCounts::default(); | |
| 56 | add(&mut total, &repo.secrets); | |
| 57 | add(&mut total, &repo.code); | |
| 58 | add(&mut total, &repo.vulnerabilities); | |
| 59 | (std::cmp::Reverse(total.critical), std::cmp::Reverse(total.high), std::cmp::Reverse(sum(&total))) | |
| 60 | }; | |
| 61 | repos.sort_by(|a, b| key(a).cmp(&key(b)).then_with(|| a.name.cmp(&b.name))); | |
| 62 | } | |
| 63 | ||
| 64 | /// A trend from daily snapshots: each day's open alerts by type, carrying | |
| 65 | /// a repository's last known counts over days it was not snapshotted. | |
| 66 | pub fn trend(days: &[String], rows: &[crate::suite_store::SnapshotRow]) -> Vec<TrendPoint> { | |
| 67 | let mut by_day: BTreeMap<&str, TrendPoint> = BTreeMap::new(); | |
| 68 | for row in rows { | |
| 69 | let point = by_day.entry(row.day.as_str()).or_insert_with(|| TrendPoint { day: row.day.clone(), ..TrendPoint::default() }); | |
| 70 | let n = (row.critical + row.high + row.medium + row.low + row.unknown).max(0) as u32; | |
| 71 | match row.alert_type.as_str() { | |
| 72 | "secret_scanning" => point.secret_scanning += n, | |
| 73 | "code_scanning" => point.code_scanning += n, | |
| 74 | _ => point.vulnerability += n, | |
| 75 | } | |
| 76 | } | |
| 77 | days.iter() | |
| 78 | .map(|day| by_day.get(day.as_str()).cloned().unwrap_or_else(|| TrendPoint { day: day.clone(), ..TrendPoint::default() })) | |
| 79 | .collect() | |
| 80 | } | |
| 81 | ||
| 82 | impl Security { | |
| 83 | pub(crate) async fn security_settings(&self, a: SecuritySettingsArgs) -> Result<Outcome<SecuritySettingsView>> { | |
| 84 | let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? { | |
| 85 | Outcome::Ok(repo) => repo, | |
| 86 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 87 | }; | |
| 88 | let (settings, private) = self.store.repo_settings(&repo.repo_id).await?; | |
| 89 | Ok(Outcome::Ok(SecuritySettingsView { | |
| 90 | settings, | |
| 91 | workspace: self.store.workspace_settings(&repo.namespace).await?, | |
| 92 | private, | |
| 93 | entitled: !private || self.activated(&repo.namespace).await, | |
| 94 | upkeep: repo.upkeep != 0, | |
| 95 | })) | |
| 96 | } | |
| 97 | ||
| 98 | pub(crate) async fn set_security_settings(&self, a: SetSecuritySettingsArgs) -> Result<Outcome<SecuritySettingsView>> { | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 99 | let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Capability::ManageSecurity).await? { |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 100 | Outcome::Ok(repo) => repo, |
| 101 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 102 | }; | |
| 103 | if !a.actor.verified { | |
| 104 | return Ok(fail(FailureCode::Forbidden, "Confirm your email address first.")); | |
| 105 | } | |
| 106 | let mut settings = a.settings; | |
| 107 | if !GATES.contains(&settings.code_scanning_gate.as_str()) { | |
| 108 | return Ok(fail(FailureCode::Invalid, "code_scanning_gate is none, errors, critical, high, medium or any.")); | |
| 109 | } | |
| 110 | if !SEVERITY_NAMES.contains(&settings.review_fail_on.as_str()) { | |
| 111 | return Ok(fail(FailureCode::Invalid, "review_fail_on is critical, high, medium, low or none.")); | |
| 112 | } | |
| 113 | settings.review_deny_licenses = settings | |
| 114 | .review_deny_licenses | |
| 115 | .iter() | |
| 116 | .map(|id| id.trim().to_owned()) | |
| 117 | .filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '+'))) | |
| 118 | .take(MAX_DENIED) | |
| 119 | .collect(); | |
| 120 | self.store.set_repo_settings(&repo.repo_id, &settings).await?; | |
| 121 | self.audit( | |
| 122 | &a.actor, | |
| 123 | "security_settings", | |
| 124 | Some(&repo), | |
| 125 | &repo.namespace, | |
| 126 | None, | |
| 127 | &format!( | |
| 128 | "Security settings: code scanning fails at {}, dependency review {} (fails at {})", | |
| 129 | settings.code_scanning_gate, | |
| 130 | if settings.dependency_review { "on" } else { "off" }, | |
| 131 | settings.review_fail_on | |
| 132 | ), | |
| 133 | ) | |
| 134 | .await; | |
| 135 | self.security_settings(SecuritySettingsArgs { viewer: Some(a.actor), repo: a.repo }).await | |
| 136 | } | |
| 137 | ||
| 138 | pub(crate) async fn workspace_security_settings(&self, a: WorkspaceSecuritySettingsArgs) -> Result<Outcome<WorkspaceSecurityView>> { | |
| 139 | let workspace = a.workspace.to_lowercase(); | |
| 140 | if !a.viewer.as_ref().is_some_and(|user| user.is_member(&workspace)) { | |
| 141 | return Ok(fail(FailureCode::NotFound, "Workspace not found.")); | |
| 142 | } | |
| 143 | Ok(Outcome::Ok(WorkspaceSecurityView { | |
| 144 | settings: self.store.workspace_settings(&workspace).await?, | |
| 145 | activated: self.activated(&workspace).await, | |
| 146 | })) | |
| 147 | } | |
| 148 | ||
| 149 | pub(crate) async fn set_workspace_security_settings(&self, a: SetWorkspaceSecuritySettingsArgs) -> Result<Outcome<WorkspaceSecurityView>> { | |
| 150 | let workspace = a.workspace.to_lowercase(); | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 151 | if !a.actor.manages_security(&workspace) { |
| 152 | return Ok(fail(FailureCode::Forbidden, "Only an owner or a security manager can change the workspace's security settings.")); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 153 | } |
| 154 | if !a.actor.verified { | |
| 155 | return Ok(fail(FailureCode::Forbidden, "Confirm your email address first.")); | |
| 156 | } | |
| 157 | self.store.set_workspace_settings(&workspace, &a.settings, &a.actor.username).await?; | |
| 158 | self.audit( | |
| 159 | &a.actor, | |
| 160 | "security_settings", | |
| 161 | None, | |
| 162 | &workspace, | |
| 163 | None, | |
| 164 | &format!( | |
| 165 | "Workspace security settings: delegated bypass {}, validity checks {}", | |
| 166 | if a.settings.delegated_bypass { "on" } else { "off" }, | |
| 167 | if a.settings.validity_checks { "on" } else { "off" } | |
| 168 | ), | |
| 169 | ) | |
| 170 | .await; | |
| 171 | self.workspace_security_settings(WorkspaceSecuritySettingsArgs { viewer: Some(a.actor), workspace }).await | |
| 172 | } | |
| 173 | ||
| 174 | /// The repositories of a workspace whose findings `viewer` may see, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 175 | /// with whether each is private: as the repositories service says now, |
| 176 | /// the record here corrected when it was wrong (a repository recorded | |
| 177 | /// from an event before its visibility was known), else as recorded. | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 178 | async fn visible_repos(&self, workspace: &str, viewer: &g1t_contracts::User) -> Result<Vec<(RepoRow, bool)>> { |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 179 | let rows: Vec<RepoRow> = self |
| 180 | .store | |
| 181 | .in_namespace(workspace) | |
| 182 | .await? | |
| 183 | .into_iter() | |
| 184 | .filter(|repo| { | |
| 185 | let target = access::RepoRef { id: &repo.repo_id, namespace: workspace, private: true }; | |
| 186 | access::can(Some(viewer), target, crate::SEE_FINDINGS) | |
| 187 | }) | |
| 188 | .collect(); | |
| 189 | let (recorded, live) = futures_util::future::join( | |
| 190 | futures_util::future::try_join_all(rows.iter().map(|repo| self.store.repo_settings(&repo.repo_id))), | |
| 191 | self.live_privacy(&rows, viewer), | |
| 192 | ) | |
| 193 | .await; | |
| 194 | let mut out = Vec::with_capacity(rows.len()); | |
| 195 | for (repo, (_, recorded)) in rows.into_iter().zip(recorded?) { | |
| 196 | let private = match live.get(&repo.repo_id) { | |
| 197 | Some(&now) => { | |
| 198 | if now != recorded { | |
| 199 | self.store.set_private(&repo.repo_id, now).await?; | |
| 200 | } | |
| 201 | now | |
| 202 | } | |
| 203 | None => recorded, | |
| 204 | }; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 205 | out.push((repo, private)); |
| 206 | } | |
| 207 | Ok(out) | |
| 208 | } | |
| 209 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 210 | /// Whether each repository is private, by id, as the repositories |
| 211 | /// service says now: one call for all of them. Empty when it cannot say. | |
| 212 | async fn live_privacy(&self, rows: &[RepoRow], viewer: &g1t_contracts::User) -> BTreeMap<String, bool> { | |
| 213 | if rows.is_empty() { | |
| 214 | return BTreeMap::new(); | |
| 215 | } | |
| 216 | let ids = rows.iter().take(MAX_READABLE).map(|repo| repo.repo_id.clone()).collect(); | |
| 217 | let found: Result<Vec<Repo>> = | |
| 218 | g1t_kit::call(&self.repos, "readable", &ReadableArgs { ids, viewer: Some(viewer.clone()) }).await; | |
| 219 | match found { | |
| 220 | Ok(found) => found.into_iter().map(|repo| (repo.id, repo.is_private)).collect(), | |
| 221 | Err(error) => { | |
| 222 | worker::console_error!("security: readable: {error}"); | |
| 223 | BTreeMap::new() | |
| 224 | } | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | /// One repository's row in the overview: its features and open counts, | |
| 229 | /// read at once. | |
| 230 | async fn coverage(&self, workspace: &str, validity_checks: bool, repo: RepoRow, private: bool) -> Result<RepoCoverage> { | |
| 231 | let ((repo_settings, custom_patterns, code_scanning_at), (secrets, code, vulnerabilities)) = futures_util::future::try_join( | |
| 232 | futures_util::future::try_join3( | |
| 233 | self.store.repo_settings(&repo.repo_id), | |
| 234 | self.store.pattern_count(workspace, &repo.repo_id), | |
| 235 | self.store.last_analysis_at(&repo.repo_id), | |
| 236 | ), | |
| 237 | futures_util::future::try_join3( | |
| 238 | self.store.secret_severity_counts(&repo.repo_id), | |
| 239 | self.store.code_counts(&repo.repo_id), | |
| 240 | self.store.vulnerability_counts(&repo.repo_id), | |
| 241 | ), | |
| 242 | ) | |
| 243 | .await?; | |
| 244 | Ok(RepoCoverage { | |
| 245 | custom_patterns, | |
| 246 | validity_checks, | |
| 247 | code_scanning_at, | |
| 248 | dependency_review: repo_settings.0.dependency_review, | |
| 249 | security_updates: repo.upkeep != 0, | |
| 250 | lockfiles: repo.scan_state().lockfiles.len() as u32, | |
| 251 | secrets, | |
| 252 | code, | |
| 253 | vulnerabilities, | |
| 254 | repo_id: repo.repo_id, | |
| 255 | name: repo.name, | |
| 256 | private, | |
| 257 | }) | |
| 258 | } | |
| 259 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 260 | pub(crate) async fn security_overview(&self, a: WorkspaceOverviewArgs) -> Result<Outcome<WorkspaceOverview>> { |
| 261 | let workspace = a.workspace.to_lowercase(); | |
| 262 | let Some(viewer) = a.viewer.as_ref().filter(|user| user.is_member(&workspace)) else { | |
| 263 | return Ok(fail(FailureCode::NotFound, "Workspace not found.")); | |
| 264 | }; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 265 | // Each repository's queries, and the repositories, at once: one |
| 266 | // after another they took seconds for a workspace of a dozen. | |
| 267 | let (activated, settings, visible) = futures_util::future::try_join3( | |
| 268 | async { Ok::<_, worker::Error>(self.activated(&workspace).await) }, | |
| 269 | self.store.workspace_settings(&workspace), | |
| 270 | self.visible_repos(&workspace, viewer), | |
| 271 | ) | |
| 272 | .await?; | |
| 273 | // Private repositories count with the activation only. | |
| 274 | let hidden = visible.iter().filter(|(_, private)| *private && !activated).count() as u32; | |
| 275 | let mut repos = futures_util::future::try_join_all( | |
| 276 | visible | |
| 277 | .into_iter() | |
| 278 | .filter(|(_, private)| !*private || activated) | |
| 279 | .map(|(repo, private)| self.coverage(&workspace, settings.validity_checks, repo, private)), | |
| 280 | ) | |
| 281 | .await?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 282 | let ids: Vec<String> = repos.iter().map(|repo| repo.repo_id.clone()).collect(); |
| 283 | let span = a.days.unwrap_or(30).clamp(7, 90); | |
| 284 | let since = rfc3339(now_ms().saturating_sub(u64::from(span) * DAY_MS)); | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 285 | let moved = futures_util::future::try_join_all( |
| 286 | AlertType::ALL.iter().map(|alert_type| self.store.opened_and_closed(alert_type.as_str(), &ids, &since)), | |
| 287 | ) | |
| 288 | .await?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 289 | let mut totals = Vec::new(); |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 290 | for (alert_type, (opened, closed)) in AlertType::ALL.into_iter().zip(moved) { |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 291 | let mut open = SeverityCounts::default(); |
| 292 | for repo in &repos { | |
| 293 | add(&mut open, match alert_type { | |
| 294 | AlertType::SecretScanning => &repo.secrets, | |
| 295 | AlertType::CodeScanning => &repo.code, | |
| 296 | AlertType::Vulnerability => &repo.vulnerabilities, | |
| 297 | }); | |
| 298 | } | |
| 299 | totals.push(TypeTotals { alert_type: alert_type.as_str().to_owned(), open, opened, closed }); | |
| 300 | } | |
| 301 | let days = days(now_ms(), span); | |
| 302 | let snapshots = self.store.snapshots(&workspace, &days[0], &ids).await?; | |
| 303 | let trend = trend(&days, &snapshots); | |
| 304 | rank(&mut repos); | |
| 305 | Ok(Outcome::Ok(WorkspaceOverview { activated, private_hidden: hidden, totals, trend, repos })) | |
| 306 | } | |
| 307 | ||
| 308 | pub(crate) async fn workspace_alerts(&self, a: WorkspaceAlertsArgs) -> Result<Outcome<Vec<WorkspaceAlert>>> { | |
| 309 | let workspace = a.workspace.to_lowercase(); | |
| 310 | let Some(viewer) = a.viewer.as_ref().filter(|user| user.is_member(&workspace)) else { | |
| 311 | return Ok(fail(FailureCode::NotFound, "Workspace not found.")); | |
| 312 | }; | |
| 313 | let activated = self.activated(&workspace).await; | |
| 314 | let mut alerts = Vec::new(); | |
| 315 | for (repo, private) in self.visible_repos(&workspace, viewer).await? { | |
| 316 | match a.alert_type { | |
| 317 | AlertType::SecretScanning => alerts.extend(self.store.secrets(&repo.repo_id).await?.into_iter().map(|secret| WorkspaceAlert { | |
| 318 | repo: repo.name.clone(), | |
| 319 | secret: Some(secret), | |
| 320 | code: None, | |
| 321 | vulnerability: None, | |
| 322 | })), | |
| 323 | AlertType::Vulnerability => { | |
| 324 | alerts.extend(self.store.vulnerabilities(&repo.repo_id).await?.into_iter().map(|vuln| WorkspaceAlert { | |
| 325 | repo: repo.name.clone(), | |
| 326 | secret: None, | |
| 327 | code: None, | |
| 328 | vulnerability: Some(vuln), | |
| 329 | })) | |
| 330 | } | |
| 331 | // Code scanning on a private repository is the activation's. | |
| 332 | AlertType::CodeScanning if private && !activated => {} | |
| 333 | AlertType::CodeScanning => alerts.extend(self.store.code_alerts(&repo.repo_id).await?.into_iter().map(|code| WorkspaceAlert { | |
| 334 | repo: repo.name.clone(), | |
| 335 | secret: None, | |
| 336 | code: Some(code), | |
| 337 | vulnerability: None, | |
| 338 | })), | |
| 339 | } | |
| 340 | if alerts.len() >= 5_000 { | |
| 341 | break; | |
| 342 | } | |
| 343 | } | |
| 344 | Ok(Outcome::Ok(alerts)) | |
| 345 | } | |
| 346 | ||
| 347 | /// The sweep's part: today's open counts for repositories that have | |
| 348 | /// none yet, and validity checks where the workspace turned them on. | |
| 349 | pub(crate) async fn sweep_suite(&self) -> Result<()> { | |
| 350 | let today = rfc3339(now_ms())[..10].to_owned(); | |
| 351 | for repo in self.store.unsnapshotted(&today, SNAPSHOTS_PER_SWEEP).await? { | |
| 352 | let secrets = self.store.secret_severity_counts(&repo.repo_id).await?; | |
| 353 | let code = self.store.code_counts(&repo.repo_id).await?; | |
| 354 | let vulnerabilities = self.store.vulnerability_counts(&repo.repo_id).await?; | |
| 355 | for (kind, counts) in [("secret_scanning", &secrets), ("code_scanning", &code), ("vulnerability", &vulnerabilities)] { | |
| 356 | self.store.snapshot(&repo.repo_id, &repo.namespace, &today, kind, counts).await?; | |
| 357 | } | |
| 358 | if let Err(error) = self.sweep_validity(&repo).await { | |
| 359 | worker::console_error!("security: validity checks for {}: {error}", repo.repo_id); | |
| 360 | } | |
| 361 | } | |
| 362 | Ok(()) | |
| 363 | } | |
| 364 | } | |
| 365 | ||
| 366 | #[cfg(test)] | |
| 367 | mod tests { | |
| 368 | use super::*; | |
| 369 | use crate::suite_store::SnapshotRow; | |
| 370 | ||
| 371 | fn counts(critical: u32, high: u32) -> SeverityCounts { | |
| 372 | SeverityCounts { critical, high, ..SeverityCounts::default() } | |
| 373 | } | |
| 374 | ||
| 375 | #[test] | |
| 376 | fn the_repositories_most_in_need_come_first() { | |
| 377 | let repo = |name: &str, code: SeverityCounts, vulnerabilities: SeverityCounts| RepoCoverage { | |
| 378 | name: name.into(), | |
| 379 | code, | |
| 380 | vulnerabilities, | |
| 381 | ..RepoCoverage::default() | |
| 382 | }; | |
| 383 | let mut repos = vec![repo("a", counts(0, 1), counts(0, 0)), repo("b", counts(1, 0), counts(0, 0)), repo("c", counts(0, 3), counts(0, 1))]; | |
| 384 | rank(&mut repos); | |
| 385 | let order: Vec<&str> = repos.iter().map(|repo| repo.name.as_str()).collect(); | |
| 386 | assert_eq!(order, ["b", "c", "a"]); | |
| 387 | } | |
| 388 | ||
| 389 | #[test] | |
| 390 | fn a_trend_has_every_day_and_sums_each_type() { | |
| 391 | let today = 1_791_374_400_000; // 2026-10-07T12:00:00Z | |
| 392 | let span = days(today, 3); | |
| 393 | assert_eq!(span, ["2026-10-05", "2026-10-06", "2026-10-07"]); | |
| 394 | let row = |day: &str, kind: &str, critical| SnapshotRow { | |
| 395 | day: day.into(), | |
| 396 | alert_type: kind.into(), | |
| 397 | critical, | |
| 398 | high: 1, | |
| 399 | medium: 0, | |
| 400 | low: 0, | |
| 401 | unknown: 0, | |
| 402 | }; | |
| 403 | let points = trend(&span, &[row("2026-10-06", "code_scanning", 2), row("2026-10-06", "vulnerability", 0), row("2026-10-07", "code_scanning", 1)]); | |
| 404 | assert_eq!(points.len(), 3); | |
| 405 | assert_eq!((points[0].code_scanning, points[1].code_scanning, points[1].vulnerability, points[2].code_scanning), (0, 3, 1, 2)); | |
| 406 | } | |
| 407 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.