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.
| Workflow files need workflow_files:write from a token; fine-grained permission table | 1 | //! Workflow files: a token adds, changes or deletes files under |
| 2 | //! `.g1t/workflows/` or `.github/workflows/` only with the | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 3 | //! `workflow_files:write` scope (a token's Workflow files: write |
| Workflow files need workflow_files:write from a token; fine-grained permission table | 4 | //! permission). A workflow job's token never may. Without the gate, a token |
| 5 | //! that can push code could write a workflow that runs with the | |
| 6 | //! repository's secrets and a stronger token than its own. | |
| 7 | //! | |
| 8 | //! A push is checked commit by commit: every commit it adds is compared | |
| 9 | //! with its first parent, looking only at the two workflow directories, so | |
| 10 | //! the check is complete however many other files a commit changes. A push | |
| 11 | //! too large to be read whole is refused for such a token, since what it | |
| 12 | //! holds cannot be checked. A signed-in person (no token) is never refused | |
| 13 | //! here, and neither is a token that has the scope: their roles and the | |
| 14 | //! repository's rules decide. | |
| 15 | ||
| 16 | use std::cell::Cell; | |
| 17 | ||
| 18 | use g1t_contracts::User; | |
| 19 | use g1t_contracts::scopes::{TokenAccess, WORKFLOW_DIRS, decide_workflow_files}; | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 20 | use g1t_scan::pack::{ObjectKind, Pack, TreeItem}; |
| 21 | use worker::Result; | |
| Workflow files need workflow_files:write from a token; fine-grained permission table | 22 | |
| 23 | use crate::rule_facts::{self, MAX_COMMITS}; | |
| 24 | use crate::secret_scan::Objects; | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 25 | use crate::store::GitRepo; |
| Workflow files need workflow_files:write from a token; fine-grained permission table | 26 | |
| 27 | /// The token behind a push or an edit, when it is one this gate checks: | |
| 28 | /// any token without `workflow_files:write`, and every job's token. | |
| 29 | pub(crate) fn gated(actor: Option<&User>) -> Option<&TokenAccess> { | |
| 30 | let token = actor?.token.as_deref()?; | |
| 31 | decide_workflow_files(Some(token), [WORKFLOW_DIRS[0]]).map(|_| token) | |
| 32 | } | |
| 33 | ||
| 34 | /// The id of the tree at `dir` (such as `.github/workflows/`) under the | |
| 35 | /// tree `root`, if there is one. | |
| 36 | async fn subtree<R: GitRepo>(objects: &Objects<'_, R>, root: &str, dir: &str) -> Result<Option<String>> { | |
| 37 | let mut id = root.to_owned(); | |
| 38 | for part in dir.trim_end_matches('/').split('/') { | |
| 39 | let items = objects.tree(&id).await?; | |
| 40 | match items.into_iter().find(|item| item.name == part && item.is_tree()) { | |
| 41 | Some(item) => id = item.id, | |
| 42 | None => return Ok(None), | |
| 43 | } | |
| 44 | } | |
| 45 | Ok(Some(id)) | |
| 46 | } | |
| 47 | ||
| 48 | /// The first entry that differs between two listings of one directory. | |
| 49 | fn first_difference(old: &[TreeItem], new: &[TreeItem]) -> Option<String> { | |
| 50 | new.iter() | |
| 51 | .find(|item| !old.iter().any(|before| before.name == item.name && before.id == item.id && before.mode == item.mode)) | |
| 52 | .or_else(|| old.iter().find(|item| !new.iter().any(|after| after.name == item.name))) | |
| 53 | .map(|item| item.name.clone()) | |
| 54 | } | |
| 55 | ||
| 56 | /// The first workflow file that differs between two root trees (`None` | |
| 57 | /// for a commit with no parent), as a path. | |
| 58 | pub(crate) async fn changed_between<R: GitRepo>(objects: &Objects<'_, R>, old_root: Option<&str>, new_root: &str) -> Result<Option<String>> { | |
| 59 | for dir in WORKFLOW_DIRS { | |
| 60 | let old = match old_root { | |
| 61 | Some(root) => subtree(objects, root, dir).await?, | |
| 62 | None => None, | |
| 63 | }; | |
| 64 | let new = subtree(objects, new_root, dir).await?; | |
| 65 | if old == new { | |
| 66 | continue; | |
| 67 | } | |
| 68 | let old_items = match &old { | |
| 69 | Some(id) => objects.tree(id).await?, | |
| 70 | None => Vec::new(), | |
| 71 | }; | |
| 72 | let new_items = match &new { | |
| 73 | Some(id) => objects.tree(id).await?, | |
| 74 | None => Vec::new(), | |
| 75 | }; | |
| 76 | let name = first_difference(&old_items, &new_items).unwrap_or_default(); | |
| 77 | return Ok(Some(format!("{dir}{name}"))); | |
| 78 | } | |
| 79 | Ok(None) | |
| 80 | } | |
| 81 | ||
| 82 | /// The first workflow file one of `ids` (commits the pack holds) changes | |
| 83 | /// against its first parent. | |
| 84 | pub(crate) async fn changed_in_commits<R: GitRepo>(pack: &Pack, repo: &R, ids: &[String]) -> Result<Option<String>> { | |
| 85 | let objects = Objects { pack, repo, reads: Cell::new(0) }; | |
| 86 | for id in ids { | |
| 87 | let Some((ObjectKind::Commit, data)) = pack.get(id) else { | |
| 88 | continue; | |
| 89 | }; | |
| 90 | let commit = rule_facts::read_commit(data); | |
| 91 | let old_root = match commit.parents.first() { | |
| 92 | Some(parent) => objects.commit_tree(parent).await?, | |
| 93 | None => None, | |
| 94 | }; | |
| 95 | if let Some(path) = changed_between(&objects, old_root.as_deref(), &commit.tree).await? { | |
| 96 | return Ok(Some(path)); | |
| 97 | } | |
| 98 | } | |
| 99 | Ok(None) | |
| 100 | } | |
| 101 | ||
| 102 | /// Why a push is refused, as the reason git shows beside each ref and the | |
| 103 | /// lines it prints, when `token` may not change workflow files and the | |
| 104 | /// push (`body`, read `whole` or not) does or cannot be checked. | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 105 | #[cfg(test)] |
| Workflow files need workflow_files:write from a token; fine-grained permission table | 106 | pub(crate) async fn judge_push<R: GitRepo>(token: &TokenAccess, body: &[u8], whole: bool, git: &R) -> Result<Option<(&'static str, Vec<String>)>> { |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 107 | let mut pack = whole.then(|| crate::push_checks::read_pack(body)); |
| 108 | if let Some(Ok(pack)) = &mut pack { | |
| 109 | crate::secret_scan::supply_bases(pack, git).await?; | |
| 110 | } | |
| 111 | judge_pack(token, body, pack.as_ref(), git).await | |
| 112 | } | |
| 113 | ||
| 114 | /// [`judge_push`] for a push whose pack was read already, with its bases | |
| 115 | /// supplied (push_checks.rs); `None` when it was not read whole. | |
| 116 | pub(crate) async fn judge_pack<R: GitRepo>( | |
| 117 | token: &TokenAccess, | |
| 118 | body: &[u8], | |
| 119 | pack: Option<&std::result::Result<Pack, String>>, | |
| 120 | git: &R, | |
| 121 | ) -> Result<Option<(&'static str, Vec<String>)>> { | |
| Workflow files need workflow_files:write from a token; fine-grained permission table | 122 | let updates = crate::git_http::ref_updates(body); |
| 123 | if updates.iter().all(|(_, _, new)| new.is_none()) { | |
| 124 | return Ok(None); | |
| 125 | } | |
| 126 | let too_large = |line: String| Ok(Some(("push too large to check for workflow files", vec![line, "Push in smaller parts, or with a token that has the workflow_files:write scope.".to_owned()]))); | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 127 | let Some(pack) = pack else { |
| Workflow files need workflow_files:write from a token; fine-grained permission table | 128 | return too_large("This push is too large for g1t to check whether it changes workflow files, and this token may not change them.".to_owned()); |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 129 | }; |
| 130 | // A push with no pack moves refs to commits the repository has: an | |
| 131 | // empty pack, which changes nothing. | |
| 132 | let pack = match pack { | |
| 133 | Ok(pack) => pack, | |
| 134 | Err(problem) => { | |
| Workflow files need workflow_files:write from a token; fine-grained permission table | 135 | worker::console_error!("a push's pack could not be read for workflow files: {problem}"); |
| 136 | return Ok(Some(("push could not be checked for workflow files", vec!["g1t could not read this push to check it for workflow files. Push again.".to_owned()]))); | |
| 137 | } | |
| 138 | }; | |
| 139 | for (_, _, new) in updates { | |
| 140 | let Some(new) = new else { continue }; | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 141 | let Some(ids) = rule_facts::added(pack, &new, MAX_COMMITS) else { |
| Workflow files need workflow_files:write from a token; fine-grained permission table | 142 | return too_large(format!("This push adds more than {MAX_COMMITS} commits to one ref, too many for g1t to check for workflow files, and this token may not change them.")); |
| 143 | }; | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 144 | if let Some(path) = changed_in_commits(pack, git, &ids).await? { |
| Workflow files need workflow_files:write from a token; fine-grained permission table | 145 | let reason = decide_workflow_files(Some(token), [path.as_str()]) |
| 146 | .and_then(|decision| decision.reason) | |
| 147 | .unwrap_or_else(|| format!("This access token cannot change the workflow file {path}.")); | |
| 148 | return Ok(Some(( | |
| 149 | "workflow files need the workflow_files:write scope", | |
| 150 | vec![reason, "Push with a token that has the workflow_files:write scope, or make the change signed in on g1t.sh.".to_owned()], | |
| 151 | ))); | |
| 152 | } | |
| 153 | } | |
| 154 | Ok(None) | |
| 155 | } | |
| 156 | ||
| 157 | #[cfg(test)] | |
| 158 | mod tests { | |
| 159 | use std::collections::HashMap; | |
| 160 | use std::future::Future; | |
| 161 | use std::pin::pin; | |
| 162 | use std::task::{Context, Poll, Waker}; | |
| 163 | ||
| 164 | use g1t_contracts::repos::{Branch, Commit, EntryKind, GitAccess, Signature, TreeEntry}; | |
| 165 | use g1t_contracts::scopes::{JobToken, Scope}; | |
| 166 | use g1t_scan::pack::{encode_tree, object_id, write_pack}; | |
| 167 | ||
| 168 | use super::*; | |
| 169 | use crate::store::Scope as StoreScope; | |
| 170 | ||
| 171 | fn run<F: Future>(future: F) -> F::Output { | |
| 172 | match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) { | |
| 173 | Poll::Ready(output) => output, | |
| 174 | Poll::Pending => panic!("the fake store never waits"), | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | /// The repository before the push: one commit, with its trees. | |
| 179 | #[derive(Default)] | |
| 180 | struct Fake { | |
| 181 | trees: HashMap<String, Vec<TreeEntry>>, | |
| 182 | commits: HashMap<String, Commit>, | |
| 183 | } | |
| 184 | ||
| 185 | impl GitRepo for Fake { | |
| 186 | async fn access(&self, _scope: StoreScope) -> Result<GitAccess> { | |
| 187 | unimplemented!() | |
| 188 | } | |
| 189 | async fn branches(&self) -> Result<Vec<Branch>> { | |
| 190 | Ok(Vec::new()) | |
| 191 | } | |
| 192 | async fn log(&self, git_ref: &str, _limit: u32) -> Result<Vec<Commit>> { | |
| 193 | Ok(self.commits.get(git_ref).cloned().into_iter().collect()) | |
| 194 | } | |
| 195 | async fn parents(&self, commit_hash: &str) -> Result<Option<Vec<String>>> { | |
| 196 | Ok(self.commits.get(commit_hash).map(|commit| commit.parents.clone())) | |
| 197 | } | |
| 198 | async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> { | |
| 199 | Ok(self.trees.get(tree_hash).cloned()) | |
| 200 | } | |
| 201 | async fn read_blob(&self, _blob_hash: &str) -> Result<Option<Vec<u8>>> { | |
| 202 | Ok(None) | |
| 203 | } | |
| 204 | async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> { | |
| 205 | Ok(None) | |
| 206 | } | |
| 207 | async fn fork(&self, _target_key: &str) -> Result<()> { | |
| 208 | Ok(()) | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | fn item(mode: &str, name: &str, id: &str) -> TreeItem { | |
| 213 | TreeItem { mode: mode.into(), name: name.into(), id: id.into() } | |
| 214 | } | |
| 215 | ||
| 216 | /// Objects for one tree layout: a root with `dir/workflows/<file>` | |
| 217 | /// holding `content`, and `README.md`. | |
| 218 | struct Layout { | |
| 219 | objects: Vec<(ObjectKind, Vec<u8>)>, | |
| 220 | root: String, | |
| 221 | } | |
| 222 | ||
| 223 | fn layout(dir: &str, file: &str, content: &str, readme: &str) -> Layout { | |
| 224 | let mut objects = Vec::new(); | |
| 225 | let mut add = |kind: ObjectKind, data: Vec<u8>| { | |
| 226 | let id = object_id(kind, &data); | |
| 227 | objects.push((kind, data)); | |
| 228 | id | |
| 229 | }; | |
| 230 | let workflow = add(ObjectKind::Blob, content.as_bytes().to_vec()); | |
| 231 | let readme = add(ObjectKind::Blob, readme.as_bytes().to_vec()); | |
| 232 | let workflows = add(ObjectKind::Tree, encode_tree(&[item("100644", file, &workflow)])); | |
| 233 | let parent = add(ObjectKind::Tree, encode_tree(&[item("40000", "workflows", &workflows)])); | |
| 234 | let root = add(ObjectKind::Tree, encode_tree(&[item("40000", dir, &parent), item("100644", "README.md", &readme)])); | |
| 235 | Layout { objects, root } | |
| 236 | } | |
| 237 | ||
| 238 | fn commit(tree: &str, parent: Option<&str>) -> (String, Vec<u8>) { | |
| 239 | let parent = parent.map(|parent| format!("parent {parent}\n")).unwrap_or_default(); | |
| 240 | let data = format!("tree {tree}\n{parent}author A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n\nchange\n").into_bytes(); | |
| 241 | (object_id(ObjectKind::Commit, &data), data) | |
| 242 | } | |
| 243 | ||
| 244 | /// The first workflow file a push of `after` on top of `before` changes. | |
| 245 | fn check(before: &Layout, after: &Layout) -> Option<String> { | |
| 246 | // The repository holds `before` and its commit; the pack, `after`. | |
| 247 | let mut repo = Fake::default(); | |
| 248 | let base = Pack::parse(&write_pack(&before.objects)).unwrap(); | |
| 249 | for (kind, data) in &before.objects { | |
| 250 | if *kind == ObjectKind::Tree { | |
| 251 | let id = object_id(*kind, data); | |
| 252 | let entries = base | |
| 253 | .tree(&id) | |
| 254 | .unwrap() | |
| 255 | .into_iter() | |
| 256 | .map(|item| TreeEntry { name: item.name.clone(), hash: item.id.clone(), kind: if item.is_tree() { EntryKind::Tree } else { EntryKind::Blob } }) | |
| 257 | .collect(); | |
| 258 | repo.trees.insert(id, entries); | |
| 259 | } | |
| 260 | } | |
| 261 | let (base_id, _) = commit(&before.root, None); | |
| 262 | repo.commits.insert( | |
| 263 | base_id.clone(), | |
| 264 | Commit { hash: base_id.clone(), tree_hash: before.root.clone(), message: String::new(), author: Signature { name: String::new(), email: String::new() }, parents: Vec::new(), authored_at: String::new() }, | |
| 265 | ); | |
| 266 | let (tip, data) = commit(&after.root, Some(&base_id)); | |
| 267 | let mut objects = after.objects.clone(); | |
| 268 | objects.push((ObjectKind::Commit, data)); | |
| 269 | let pack = Pack::parse(&write_pack(&objects)).unwrap(); | |
| 270 | run(changed_in_commits(&pack, &repo, &[tip])).unwrap() | |
| 271 | } | |
| 272 | ||
| 273 | #[test] | |
| 274 | fn a_push_that_changes_a_workflow_is_named_by_its_file() { | |
| 275 | let before = layout(".github", "ci.yml", "on: push", "hello"); | |
| 276 | let changed = layout(".github", "ci.yml", "on: [push, pull_request]", "hello"); | |
| 277 | assert_eq!(check(&before, &changed).as_deref(), Some(".github/workflows/ci.yml")); | |
| 278 | let added = layout(".github", "deploy.yml", "on: push", "hello"); | |
| 279 | assert!(check(&before, &added).unwrap().starts_with(".github/workflows/")); | |
| 280 | // The g1t directory too, added where there was none. | |
| 281 | let g1t = layout(".g1t", "ci.yml", "on: push", "hello"); | |
| 282 | assert_eq!(check(&before, &g1t).as_deref(), Some(".g1t/workflows/ci.yml")); | |
| 283 | } | |
| 284 | ||
| 285 | /// A receive-pack request moving `main` from `old` to `new`, with the pack. | |
| 286 | fn receive_pack(old: &str, new: &str, pack: &[u8]) -> Vec<u8> { | |
| 287 | let command = format!("{old} {new} refs/heads/main\0report-status side-band-64k\n"); | |
| 288 | let mut body = format!("{:04x}", command.len() + 4).into_bytes(); | |
| 289 | body.extend_from_slice(command.as_bytes()); | |
| 290 | body.extend_from_slice(b"0000"); | |
| 291 | body.extend_from_slice(pack); | |
| 292 | body | |
| 293 | } | |
| 294 | ||
| 295 | /// The repository holding `before` at its commit, and a push of `after` | |
| 296 | /// on top of it: the refusal, if any, for `pusher`'s token. | |
| 297 | fn push(before: &Layout, after: &Layout, pusher: &User, whole: bool) -> Option<(&'static str, Vec<String>)> { | |
| 298 | let mut repo = Fake::default(); | |
| 299 | let base = Pack::parse(&write_pack(&before.objects)).unwrap(); | |
| 300 | for (kind, data) in &before.objects { | |
| 301 | if *kind == ObjectKind::Tree { | |
| 302 | let id = object_id(*kind, data); | |
| 303 | let entries = base | |
| 304 | .tree(&id) | |
| 305 | .unwrap() | |
| 306 | .into_iter() | |
| 307 | .map(|item| TreeEntry { name: item.name.clone(), hash: item.id.clone(), kind: if item.is_tree() { EntryKind::Tree } else { EntryKind::Blob } }) | |
| 308 | .collect(); | |
| 309 | repo.trees.insert(id, entries); | |
| 310 | } | |
| 311 | } | |
| 312 | let (base_id, _) = commit(&before.root, None); | |
| 313 | repo.commits.insert( | |
| 314 | base_id.clone(), | |
| 315 | Commit { hash: base_id.clone(), tree_hash: before.root.clone(), message: String::new(), author: Signature { name: String::new(), email: String::new() }, parents: Vec::new(), authored_at: String::new() }, | |
| 316 | ); | |
| 317 | let (tip, data) = commit(&after.root, Some(&base_id)); | |
| 318 | let mut objects = after.objects.clone(); | |
| 319 | objects.push((ObjectKind::Commit, data)); | |
| 320 | let body = receive_pack(&base_id, &tip, &write_pack(&objects)); | |
| 321 | let token = gated(Some(pusher))?; | |
| 322 | run(judge_push(token, &body, whole, &repo)).unwrap() | |
| 323 | } | |
| 324 | ||
| 325 | #[test] | |
| 326 | fn a_git_push_of_a_workflow_change_is_declined_for_a_token_without_the_scope() { | |
| 327 | let before = layout(".github", "ci.yml", "on: push", "hello"); | |
| 328 | let changed = layout(".github", "ci.yml", "on: [push, pull_request]\njobs: {}", "hello"); | |
| 329 | let (reason, lines) = push(&before, &changed, &token(&[Scope::CodeWrite]), true).expect("declined"); | |
| 330 | assert_eq!(reason, "workflow files need the workflow_files:write scope"); | |
| 331 | assert!(lines[0].contains(".github/workflows/ci.yml"), "{lines:?}"); | |
| 332 | assert!(lines[0].contains("workflow_files:write"), "{lines:?}"); | |
| 333 | // With the scope, or full access, it goes through. | |
| 334 | assert!(push(&before, &changed, &token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]), true).is_none()); | |
| 335 | // A push that changes other files goes through without it. | |
| 336 | let readme = layout(".github", "ci.yml", "on: push", "hello, world"); | |
| 337 | assert!(push(&before, &readme, &token(&[Scope::CodeWrite]), true).is_none()); | |
| 338 | // One too large to read whole cannot be checked, so it is declined. | |
| 339 | let (reason, _) = push(&before, &readme, &token(&[Scope::CodeWrite]), false).expect("declined"); | |
| 340 | assert_eq!(reason, "push too large to check for workflow files"); | |
| 341 | } | |
| 342 | ||
| 343 | #[test] | |
| 344 | fn a_job_token_never_pushes_workflow_changes() { | |
| 345 | let before = layout(".g1t", "ci.yml", "on: push", "hello"); | |
| 346 | let changed = layout(".g1t", "ci.yml", "on: workflow_dispatch", "hello"); | |
| 347 | let mut job = token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]); | |
| 348 | job.token.as_mut().unwrap().job = Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }); | |
| 349 | let (_, lines) = push(&before, &changed, &job, true).expect("declined"); | |
| 350 | assert!(lines[0].contains("workflow job's token"), "{lines:?}"); | |
| 351 | } | |
| 352 | ||
| 353 | #[test] | |
| 354 | fn a_push_that_leaves_workflows_alone_passes() { | |
| 355 | let before = layout(".github", "ci.yml", "on: push", "hello"); | |
| 356 | let readme = layout(".github", "ci.yml", "on: push", "hello, world"); | |
| 357 | assert_eq!(check(&before, &readme), None); | |
| 358 | } | |
| 359 | ||
| 360 | fn token(scopes: &[Scope]) -> User { | |
| 361 | User { | |
| 362 | token: Some(Box::new(TokenAccess { | |
| 363 | token_id: "tok_1".into(), | |
| 364 | scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()), | |
| 365 | ..TokenAccess::default() | |
| 366 | })), | |
| 367 | ..User::default() | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | #[test] | |
| 372 | fn who_the_gate_checks() { | |
| 373 | assert!(gated(None).is_none(), "nobody"); | |
| 374 | assert!(gated(Some(&User::default())).is_none(), "a signed-in person"); | |
| 375 | assert!(gated(Some(&token(&[Scope::CodeWrite]))).is_some(), "a token that may push code only"); | |
| 376 | assert!(gated(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]))).is_none()); | |
| 377 | let full = User { token: Some(Box::new(TokenAccess::full())), ..User::default() }; | |
| 378 | assert!(gated(Some(&full)).is_none(), "full access includes workflow files"); | |
| 379 | let mut job = token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]); | |
| 380 | job.token.as_mut().unwrap().job = Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }); | |
| 381 | assert!(gated(Some(&job)).is_some(), "a job's token, whatever it holds"); | |
| 382 | } | |
| 383 | ||
| 384 | #[test] | |
| 385 | fn the_first_difference_names_added_changed_and_removed_entries() { | |
| 386 | let a = item("100644", "a.yml", "1"); | |
| 387 | let b = item("100644", "b.yml", "2"); | |
| 388 | assert_eq!(first_difference(&[a.clone()], &[a.clone(), b.clone()]).as_deref(), Some("b.yml")); | |
| 389 | assert_eq!(first_difference(&[a.clone(), b.clone()], &[a.clone()]).as_deref(), Some("b.yml")); | |
| 390 | assert_eq!(first_difference(&[a.clone()], &[item("100644", "a.yml", "3")]).as_deref(), Some("a.yml")); | |
| 391 | assert_eq!(first_difference(&[a.clone()], &[a]), None); | |
| 392 | } | |
| 393 | } |