pr_01m47d24b0e6n91zwymwxg0vpx/services/actions/src/settings.rs
| 1 | //! Secrets and variables, a repository's or its workspace's: one list for |
| 2 | //! every reader, shaped like Vercel's environment variables. Each row is a |
| 3 | //! key, its type (a secret, or a variable shown as Config), the |
| 4 | //! environments it applies to and who reads it: workflows, deployments, or |
| 5 | //! both. A key may have one row per environment, so production and |
| 6 | //! previews can hold different values; a key's rows never overlap. |
| 7 | //! |
| 8 | //! A reader asking for an environment gets the row naming it, else the |
| 9 | //! key's row for every environment. A repository's row overrides its |
| 10 | //! workspace's of the same key. Names are upper-cased, as GitHub treats |
| 11 | //! them without regard to case. Agents never read any. |
| 12 | |
| 13 | use g1t_contracts::actions::{ |
| 14 | CONSUMERS, DeleteSettingArgs, ResolveSettingsArgs, ResolvedSettings, SetSettingArgs, Setting, SettingsArgs, |
| 15 | SettingsOwner, |
| 16 | }; |
| 17 | use g1t_contracts::time::rfc3339; |
| 18 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id}; |
| 19 | use g1t_kit::now_ms; |
| 20 | use serde::Deserialize; |
| 21 | use serde_json::{Map, Value}; |
| 22 | use worker::Result; |
| 23 | use worker::wasm_bindgen::JsValue; |
| 24 | |
| 25 | use crate::{Actions, check, fail}; |
| 26 | |
| 27 | /// The largest value, as on GitHub. |
| 28 | const MAX_VALUE_BYTES: usize = 48 * 1024; |
| 29 | const MAX_PER_OWNER: u32 = 200; |
| 30 | const MAX_NOTE: usize = 500; |
| 31 | |
| 32 | #[derive(Deserialize)] |
| 33 | struct SettingRow { |
| 34 | id: String, |
| 35 | scope: String, |
| 36 | kind: String, |
| 37 | name: String, |
| 38 | value: String, |
| 39 | updated_at: String, |
| 40 | available_to: String, |
| 41 | environments: String, |
| 42 | repositories: Option<String>, |
| 43 | note: Option<String>, |
| 44 | updated_by: Option<String>, |
| 45 | } |
| 46 | |
| 47 | /// A name GitHub would accept: letters, digits and `_`, not starting with |
| 48 | /// a digit, `GITHUB_` or `G1T_`, which are g1t's own (`G1T_TOKEN` and its |
| 49 | /// alias `GITHUB_TOKEN`). |
| 50 | fn valid_name(name: &str) -> Result<String, String> { |
| 51 | let upper = name.trim().to_ascii_uppercase(); |
| 52 | if upper.is_empty() || upper.len() > 100 { |
| 53 | return Err("A name is 1 to 100 characters.".to_owned()); |
| 54 | } |
| 55 | if !upper.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { |
| 56 | return Err("A name has only letters, digits and underscores.".to_owned()); |
| 57 | } |
| 58 | if upper.starts_with(|c: char| c.is_ascii_digit()) { |
| 59 | return Err("A name cannot start with a digit.".to_owned()); |
| 60 | } |
| 61 | if upper.starts_with("GITHUB_") || upper.starts_with("G1T_") { |
| 62 | return Err("Names starting with G1T_ or GITHUB_ are kept for g1t's own, such as G1T_TOKEN.".to_owned()); |
| 63 | } |
| 64 | Ok(upper) |
| 65 | } |
| 66 | |
| 67 | /// Environments' names: lowercase letters, digits, `-` and `_`, each once. |
| 68 | fn valid_environments(list: &[String]) -> Result<Vec<String>, String> { |
| 69 | let mut out: Vec<String> = Vec::new(); |
| 70 | for name in list { |
| 71 | let lower = name.trim().to_ascii_lowercase(); |
| 72 | if lower.is_empty() { |
| 73 | continue; |
| 74 | } |
| 75 | if lower.len() > 40 || !lower.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { |
| 76 | return Err(format!("`{name}` is not an environment's name: up to 40 letters, digits, - and _.")); |
| 77 | } |
| 78 | if !out.contains(&lower) { |
| 79 | out.push(lower); |
| 80 | } |
| 81 | } |
| 82 | out.sort(); |
| 83 | Ok(out) |
| 84 | } |
| 85 | |
| 86 | fn consumers(list: &[String]) -> Result<Vec<String>, String> { |
| 87 | let mut out: Vec<String> = Vec::new(); |
| 88 | for item in list { |
| 89 | let item = item.trim().to_ascii_lowercase(); |
| 90 | if !CONSUMERS.contains(&item.as_str()) { |
| 91 | return Err(format!("`{item}` is not a reader: use workflows or deployments.")); |
| 92 | } |
| 93 | if !out.contains(&item) { |
| 94 | out.push(item); |
| 95 | } |
| 96 | } |
| 97 | if out.is_empty() { |
| 98 | return Err("Choose who reads it: workflows, deployments, or both.".to_owned()); |
| 99 | } |
| 100 | Ok(out) |
| 101 | } |
| 102 | |
| 103 | fn split(list: &str) -> Vec<String> { |
| 104 | list.split(',').filter(|s| !s.is_empty()).map(str::to_owned).collect() |
| 105 | } |
| 106 | |
| 107 | impl SettingRow { |
| 108 | fn environments(&self) -> Vec<String> { |
| 109 | split(&self.environments) |
| 110 | } |
| 111 | |
| 112 | fn repositories(&self) -> Vec<String> { |
| 113 | self.repositories.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default() |
| 114 | } |
| 115 | |
| 116 | fn reaches(&self, repo: &str) -> bool { |
| 117 | let list = self.repositories(); |
| 118 | list.is_empty() || list.iter().any(|r| r.eq_ignore_ascii_case(repo)) |
| 119 | } |
| 120 | |
| 121 | /// Whether it and rows for `environments` would both apply somewhere. |
| 122 | fn overlaps(&self, environments: &[String]) -> bool { |
| 123 | let mine = self.environments(); |
| 124 | mine.is_empty() == environments.is_empty() && (mine.is_empty() || mine.iter().any(|e| environments.contains(e))) |
| 125 | } |
| 126 | |
| 127 | fn describe(self) -> Setting { |
| 128 | Setting { |
| 129 | available_to: split(&self.available_to), |
| 130 | environments: self.environments(), |
| 131 | repositories: self.repositories(), |
| 132 | value: (self.kind == "variable").then_some(self.value), |
| 133 | id: self.id, |
| 134 | name: self.name, |
| 135 | kind: self.kind, |
| 136 | scope: self.scope, |
| 137 | updated_at: self.updated_at, |
| 138 | note: self.note, |
| 139 | updated_by: self.updated_by, |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | /// Where settings live: `(scope, owner)` with the owner a repository id or |
| 145 | /// a workspace slug. |
| 146 | struct Place { |
| 147 | scope: &'static str, |
| 148 | owner: String, |
| 149 | namespace: String, |
| 150 | } |
| 151 | |
| 152 | impl Actions { |
| 153 | async fn place(&self, actor: &User, owner: &SettingsOwner, changing: bool) -> Result<Outcome<Place>> { |
| 154 | if actor.kind == PrincipalKind::Agent { |
| 155 | return Ok(fail(FailureCode::Forbidden, "Agents cannot read or change secrets and variables.")); |
| 156 | } |
| 157 | // A workspace's tokens, G1T_TOKEN among them, read the names but |
| 158 | // never change them: a workflow must not rewrite what it runs with. |
| 159 | if changing && actor.kind == PrincipalKind::Workspace { |
| 160 | return Ok(fail( |
| 161 | FailureCode::Forbidden, |
| 162 | "A workspace's tokens, G1T_TOKEN included, cannot change secrets and variables. Use a person's token or the site.", |
| 163 | )); |
| 164 | } |
| 165 | match (&owner.repo, &owner.workspace) { |
| 166 | (Some(path), _) => { |
| 167 | if !actor.is_member(&path.namespace.to_lowercase()) { |
| 168 | return Ok(fail(FailureCode::Forbidden, format!("Only members of {} can see its secrets and variables.", path.namespace))); |
| 169 | } |
| 170 | let Some(repo) = self.visible_repo(path, &Some(actor.clone())).await? else { |
| 171 | return Ok(fail(FailureCode::NotFound, "There is no such repository.")); |
| 172 | }; |
| 173 | Ok(Outcome::Ok(Place { scope: "repository", owner: repo.id, namespace: repo.namespace })) |
| 174 | } |
| 175 | (None, Some(slug)) => { |
| 176 | let slug = slug.to_lowercase(); |
| 177 | let role = actor.workspaces.iter().find(|m| m.slug.eq_ignore_ascii_case(&slug)).map(|m| m.role); |
| 178 | match role { |
| 179 | None => Ok(fail(FailureCode::Forbidden, format!("Only members of {slug} can see its secrets and variables."))), |
| 180 | Some(Role::Member) if changing => Ok(fail(FailureCode::Forbidden, format!("Only owners of {slug} can change its secrets and variables."))), |
| 181 | Some(_) => Ok(Outcome::Ok(Place { scope: "workspace", owner: slug.clone(), namespace: slug })), |
| 182 | } |
| 183 | } |
| 184 | (None, None) => Ok(fail(FailureCode::Invalid, "Give `repo` or `workspace`.")), |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | /// `secret`, `variable`, or `None` for both. |
| 189 | fn kind(kind: &str) -> Outcome<Option<&'static str>> { |
| 190 | match kind { |
| 191 | "secret" | "secrets" => Outcome::Ok(Some("secret")), |
| 192 | "variable" | "variables" | "config" => Outcome::Ok(Some("variable")), |
| 193 | "" | "all" => Outcome::Ok(None), |
| 194 | _ => fail(FailureCode::Invalid, "`kind` is `secret` or `variable`."), |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | async fn rows(&self, owner: &str) -> Result<Vec<SettingRow>> { |
| 199 | self.db |
| 200 | .prepare("SELECT * FROM settings WHERE owner = ? ORDER BY name, environments") |
| 201 | .bind(&[owner.into()])? |
| 202 | .all() |
| 203 | .await? |
| 204 | .results::<SettingRow>() |
| 205 | } |
| 206 | |
| 207 | pub async fn settings(&self, a: SettingsArgs) -> Result<Outcome<Vec<Setting>>> { |
| 208 | let kind = check!(Self::kind(&a.kind)); |
| 209 | let place = check!(self.place(&a.actor, &a.owner, false).await?); |
| 210 | let repo_name = a.owner.repo.as_ref().map(|r| r.name.clone()); |
| 211 | let mut out: Vec<Setting> = Vec::new(); |
| 212 | // A repository's list shows the workspace's rows that reach it, but |
| 213 | // for keys it sets itself. |
| 214 | if place.scope == "repository" { |
| 215 | let own: Vec<String> = self.rows(&place.owner).await?.into_iter().map(|row| row.name).collect(); |
| 216 | for row in self.rows(&place.namespace.to_lowercase()).await? { |
| 217 | if repo_name.as_deref().is_some_and(|name| row.reaches(name)) && !own.contains(&row.name) { |
| 218 | out.push(row.describe()); |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | out.extend(self.rows(&place.owner).await?.into_iter().map(SettingRow::describe)); |
| 223 | out.retain(|setting| kind.is_none_or(|kind| setting.kind == kind)); |
| 224 | out.sort_by(|a, b| a.name.cmp(&b.name).then(a.environments.cmp(&b.environments))); |
| 225 | Ok(Outcome::Ok(out)) |
| 226 | } |
| 227 | |
| 228 | fn seal(&self, value: &str, id: &str) -> Outcome<String> { |
| 229 | match &self.sealer { |
| 230 | Some(sealer) => Outcome::Ok(sealer.seal(value, id)), |
| 231 | None => fail(FailureCode::Conflict, "Secrets cannot be saved yet: g1t's key for them is not set."), |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | pub async fn set_setting(&self, a: SetSettingArgs) -> Result<Outcome<Setting>> { |
| 236 | let Some(kind) = check!(Self::kind(&a.kind)) else { |
| 237 | return Ok(fail(FailureCode::Invalid, "`kind` is `secret` or `variable`.")); |
| 238 | }; |
| 239 | let name = match valid_name(&a.name) { |
| 240 | Ok(name) => name, |
| 241 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), |
| 242 | }; |
| 243 | if a.value.as_ref().is_some_and(|v| v.len() > MAX_VALUE_BYTES) { |
| 244 | return Ok(fail(FailureCode::Invalid, "A value is at most 48 KB.")); |
| 245 | } |
| 246 | if a.note.as_ref().is_some_and(|n| n.len() > MAX_NOTE) { |
| 247 | return Ok(fail(FailureCode::Invalid, "A note is at most 500 characters.")); |
| 248 | } |
| 249 | let readers = match a.available_to.as_deref().map(consumers).transpose() { |
| 250 | Ok(readers) => readers, |
| 251 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), |
| 252 | }; |
| 253 | let environments = match a.environments.as_deref().map(valid_environments).transpose() { |
| 254 | Ok(environments) => environments, |
| 255 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), |
| 256 | }; |
| 257 | let place = check!(self.place(&a.actor, &a.owner, true).await?); |
| 258 | if a.repositories.as_ref().is_some_and(|r| !r.is_empty()) && place.scope != "workspace" { |
| 259 | return Ok(fail(FailureCode::Invalid, "Only a workspace's rows choose repositories.")); |
| 260 | } |
| 261 | let rows = self.rows(&place.owner).await?; |
| 262 | // A secret and a variable may share a key, as on GitHub, where |
| 263 | // workflows read them apart (`secrets.X`, `vars.X`). |
| 264 | let same_key: Vec<&SettingRow> = rows.iter().filter(|row| row.name == name).collect(); |
| 265 | // The row being changed: by id, else the key's row for every |
| 266 | // environment (GitHub's API names a secret by its key alone). |
| 267 | let existing = match &a.id { |
| 268 | Some(id) => match rows.iter().find(|row| &row.id == id) { |
| 269 | Some(row) => Some(row), |
| 270 | None => return Ok(fail(FailureCode::NotFound, "There is no such row.")), |
| 271 | }, |
| 272 | None if a.environments.is_none() => same_key.iter().copied().find(|row| row.environments.is_empty() && row.kind == kind), |
| 273 | None => None, |
| 274 | }; |
| 275 | if existing.is_some_and(|row| row.kind == "secret" && kind == "variable") { |
| 276 | return Ok(fail(FailureCode::Invalid, "A secret cannot become config: its value is sealed. Add a config row and remove the secret.")); |
| 277 | } |
| 278 | let environments = environments.unwrap_or_else(|| existing.map(SettingRow::environments).unwrap_or_default()); |
| 279 | // A key's rows never apply to the same environment twice. |
| 280 | if let Some(clash) = same_key |
| 281 | .iter() |
| 282 | .find(|row| row.kind == kind && existing.is_none_or(|e| e.id != row.id) && row.overlaps(&environments)) |
| 283 | { |
| 284 | let at = if environments.is_empty() { "every environment".to_owned() } else { environments.join(", ") }; |
| 285 | return Ok(fail( |
| 286 | FailureCode::Conflict, |
| 287 | format!("{name} already has a row for {at} ({}). Edit that row, or choose other environments.", if clash.environments.is_empty() { "every environment" } else { &clash.environments }), |
| 288 | )); |
| 289 | } |
| 290 | if existing.is_none() && rows.len() as u32 >= MAX_PER_OWNER { |
| 291 | return Ok(fail(FailureCode::Invalid, format!("There can be at most {MAX_PER_OWNER} secrets and variables here."))); |
| 292 | } |
| 293 | let id = existing.map(|row| row.id.clone()).unwrap_or_else(|| new_id("set", now_ms())); |
| 294 | let value = match (&a.value, existing) { |
| 295 | (Some(value), _) if kind == "secret" => check!(self.seal(value, &id)), |
| 296 | (Some(value), _) => value.clone(), |
| 297 | // Config becoming a secret: its value is sealed now. |
| 298 | (None, Some(row)) if row.kind == "variable" && kind == "secret" => check!(self.seal(&row.value, &id)), |
| 299 | (None, Some(row)) => row.value.clone(), |
| 300 | (None, None) => return Ok(fail(FailureCode::Invalid, "A new row needs a `value`.")), |
| 301 | }; |
| 302 | let available_to = readers |
| 303 | .map(|r| r.join(",")) |
| 304 | .or_else(|| existing.map(|row| row.available_to.clone())) |
| 305 | .unwrap_or_else(|| CONSUMERS.join(",")); |
| 306 | let repositories: Option<String> = match &a.repositories { |
| 307 | Some(list) if list.is_empty() => None, |
| 308 | Some(list) => Some(serde_json::to_string(list).unwrap_or_default()), |
| 309 | None => existing.and_then(|row| row.repositories.clone()), |
| 310 | }; |
| 311 | let note = match &a.note { |
| 312 | Some(note) if note.trim().is_empty() => None, |
| 313 | Some(note) => Some(note.trim().to_owned()), |
| 314 | None => existing.and_then(|row| row.note.clone()), |
| 315 | }; |
| 316 | let at = rfc3339(now_ms()); |
| 317 | let optional = |v: Option<&str>| v.map_or(JsValue::NULL, JsValue::from); |
| 318 | self.db |
| 319 | .prepare( |
| 320 | "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at, available_to, environments, repositories, note, updated_by) |
| 321 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| 322 | ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, value = excluded.value, updated_at = excluded.updated_at, |
| 323 | available_to = excluded.available_to, environments = excluded.environments, |
| 324 | repositories = excluded.repositories, note = excluded.note, updated_by = excluded.updated_by", |
| 325 | ) |
| 326 | .bind(&[ |
| 327 | id.as_str().into(), |
| 328 | place.scope.into(), |
| 329 | place.owner.as_str().into(), |
| 330 | kind.into(), |
| 331 | name.as_str().into(), |
| 332 | value.into(), |
| 333 | at.as_str().into(), |
| 334 | available_to.as_str().into(), |
| 335 | environments.join(",").into(), |
| 336 | optional(repositories.as_deref()), |
| 337 | optional(note.as_deref()), |
| 338 | a.actor.username.as_str().into(), |
| 339 | ])? |
| 340 | .run() |
| 341 | .await?; |
| 342 | let row = self |
| 343 | .db |
| 344 | .prepare("SELECT * FROM settings WHERE id = ?") |
| 345 | .bind(&[id.as_str().into()])? |
| 346 | .first::<SettingRow>(None) |
| 347 | .await? |
| 348 | .expect("just written"); |
| 349 | Ok(Outcome::Ok(row.describe())) |
| 350 | } |
| 351 | |
| 352 | pub async fn delete_setting(&self, a: DeleteSettingArgs) -> Result<Outcome<bool>> { |
| 353 | let kind = check!(Self::kind(&a.kind)); |
| 354 | let place = check!(self.place(&a.actor, &a.owner, true).await?); |
| 355 | let name = a.name.trim().to_ascii_uppercase(); |
| 356 | let removed = match &a.id { |
| 357 | Some(id) => self |
| 358 | .db |
| 359 | .prepare("DELETE FROM settings WHERE owner = ? AND id = ? RETURNING id") |
| 360 | .bind(&[place.owner.as_str().into(), id.as_str().into()])? |
| 361 | .all() |
| 362 | .await?, |
| 363 | None => self |
| 364 | .db |
| 365 | .prepare("DELETE FROM settings WHERE owner = ? AND name = ? AND (?3 IS NULL OR kind = ?3) RETURNING id") |
| 366 | .bind(&[place.owner.as_str().into(), name.as_str().into(), kind.map_or(JsValue::NULL, JsValue::from)])? |
| 367 | .all() |
| 368 | .await?, |
| 369 | }; |
| 370 | Ok(if removed.results::<Value>()?.is_empty() { |
| 371 | fail(FailureCode::NotFound, format!("There is nothing called {} here.", a.name)) |
| 372 | } else { |
| 373 | Outcome::Ok(true) |
| 374 | }) |
| 375 | } |
| 376 | |
| 377 | /// What one reader of a repository gets: per key, the row for |
| 378 | /// `environment`, else the row for every environment; the repository's |
| 379 | /// over its workspace's. No secrets unless `trusted`. |
| 380 | #[allow(clippy::too_many_arguments)] |
| 381 | async fn resolved( |
| 382 | &self, |
| 383 | repo_id: &str, |
| 384 | repo_name: &str, |
| 385 | namespace: &str, |
| 386 | kind: &str, |
| 387 | consumer: &str, |
| 388 | environment: Option<&str>, |
| 389 | trusted: bool, |
| 390 | ) -> Result<Map<String, Value>> { |
| 391 | if kind == "secret" && !trusted { |
| 392 | return Ok(Map::new()); |
| 393 | } |
| 394 | let environment = environment.map(str::to_ascii_lowercase); |
| 395 | let mut out = Map::new(); |
| 396 | for owner in [namespace.to_lowercase(), repo_id.to_owned()] { |
| 397 | let rows: Vec<SettingRow> = self |
| 398 | .rows(&owner) |
| 399 | .await? |
| 400 | .into_iter() |
| 401 | .filter(|row| row.kind == kind) |
| 402 | .filter(|row| split(&row.available_to).iter().any(|r| r == consumer)) |
| 403 | .filter(|row| row.scope != "workspace" || row.reaches(repo_name)) |
| 404 | .collect(); |
| 405 | let mut names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect(); |
| 406 | names.dedup(); |
| 407 | for name in names { |
| 408 | let of_key: Vec<&SettingRow> = rows.iter().filter(|row| row.name == name).collect(); |
| 409 | let chosen = environment |
| 410 | .as_deref() |
| 411 | .and_then(|env| of_key.iter().find(|row| row.environments().iter().any(|e| e == env))) |
| 412 | .or_else(|| of_key.iter().find(|row| row.environments.is_empty())); |
| 413 | let Some(row) = chosen else { |
| 414 | // Rows only for other environments: this reader gets |
| 415 | // none, nor the workspace's. |
| 416 | out.remove(name); |
| 417 | continue; |
| 418 | }; |
| 419 | let value = if kind == "secret" { |
| 420 | match self.sealer.as_ref().and_then(|sealer| sealer.open(&row.value, &row.id)) { |
| 421 | Some(value) => value, |
| 422 | None => continue, |
| 423 | } |
| 424 | } else { |
| 425 | row.value.clone() |
| 426 | }; |
| 427 | out.insert(name.to_owned(), Value::String(value)); |
| 428 | } |
| 429 | } |
| 430 | Ok(out) |
| 431 | } |
| 432 | |
| 433 | /// The `vars` context of a repository's runs. `environment` is the job's |
| 434 | /// `environment:`, when it has one. |
| 435 | pub async fn variables_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Map<String, Value>> { |
| 436 | let (namespace, name) = repo.split_once('/').unwrap_or((repo, "")); |
| 437 | self.resolved(repo_id, name, namespace, "variable", "workflows", environment, trusted).await |
| 438 | } |
| 439 | |
| 440 | /// The `secrets` context of a repository's runs, opened. |
| 441 | pub async fn secrets_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Map<String, Value>> { |
| 442 | let (namespace, name) = repo.split_once('/').unwrap_or((repo, "")); |
| 443 | self.resolved(repo_id, name, namespace, "secret", "workflows", environment, trusted).await |
| 444 | } |
| 445 | |
| 446 | /// `resolve_settings`, for the deployments service: what a deploy build |
| 447 | /// and its running app get. |
| 448 | pub async fn resolve_settings(&self, a: ResolveSettingsArgs) -> Result<ResolvedSettings> { |
| 449 | let environment = a.environment.as_deref(); |
| 450 | Ok(ResolvedSettings { |
| 451 | secrets: self |
| 452 | .resolved(&a.repo_id, &a.repo.name, &a.repo.namespace, "secret", &a.consumer, environment, a.trusted) |
| 453 | .await?, |
| 454 | variables: self |
| 455 | .resolved(&a.repo_id, &a.repo.name, &a.repo.namespace, "variable", &a.consumer, environment, a.trusted) |
| 456 | .await?, |
| 457 | }) |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | #[cfg(test)] |
| 462 | mod tests { |
| 463 | use super::{SettingRow, consumers, valid_environments, valid_name}; |
| 464 | |
| 465 | fn row(environments: &str) -> SettingRow { |
| 466 | SettingRow { |
| 467 | id: "set_1".into(), |
| 468 | scope: "repository".into(), |
| 469 | kind: "secret".into(), |
| 470 | name: "STRIPE_KEY".into(), |
| 471 | value: String::new(), |
| 472 | updated_at: String::new(), |
| 473 | available_to: "workflows,deployments".into(), |
| 474 | environments: environments.into(), |
| 475 | repositories: None, |
| 476 | note: None, |
| 477 | updated_by: None, |
| 478 | } |
| 479 | } |
| 480 | |
| 481 | #[test] |
| 482 | fn names_follow_githubs_rules_and_keep_g1ts_own() { |
| 483 | assert_eq!(valid_name("npm_token").unwrap(), "NPM_TOKEN"); |
| 484 | assert!(valid_name("GITHUB_TOKEN").is_err()); |
| 485 | assert!(valid_name("G1T_TOKEN").is_err()); |
| 486 | assert!(valid_name("1PASSWORD").is_err()); |
| 487 | assert!(valid_name("MY-TOKEN").is_err()); |
| 488 | assert!(valid_name("").is_err()); |
| 489 | } |
| 490 | |
| 491 | #[test] |
| 492 | fn environments_and_readers_are_checked() { |
| 493 | assert_eq!(valid_environments(&["Production".into(), "preview".into(), "production".into()]).unwrap(), vec!["preview", "production"]); |
| 494 | assert!(valid_environments(&["staging env".into()]).is_err()); |
| 495 | assert_eq!(consumers(&["Deployments".into(), "deployments".into()]).unwrap(), vec!["deployments"]); |
| 496 | assert!(consumers(&["agents".into()]).is_err()); |
| 497 | assert!(consumers(&[]).is_err()); |
| 498 | } |
| 499 | |
| 500 | #[test] |
| 501 | fn a_keys_rows_cannot_share_an_environment() { |
| 502 | assert!(row("production").overlaps(&["production".into(), "preview".into()])); |
| 503 | assert!(!row("production").overlaps(&["preview".into()])); |
| 504 | // One row for every environment, and others for some, live together. |
| 505 | assert!(!row("").overlaps(&["preview".into()])); |
| 506 | assert!(row("").overlaps(&[])); |
| 507 | } |
| 508 | } |