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.
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1 | //! Renaming a workspace: changing its slug, the first segment of its URLs, |
| 2 | //! the way GitHub renames an organization. | |
| 3 | //! | |
| 4 | //! The workspace keeps its id, members, tokens and display name. Its old | |
| 5 | //! slug is recorded in `workspace_redirects`, pointing at the workspace's | |
| 6 | //! id, so that old addresses resolve to whatever the slug is now: renaming | |
| 7 | //! twice chains, because every old slug points at the same id. An old slug | |
| 8 | //! stays reserved for the workspace that had it for [`SLUG_HOLD_DAYS`], so | |
| 9 | //! nobody else can take it while links to it still redirect; the workspace | |
| 10 | //! itself can rename back to it. Renames are limited to one per | |
| 11 | //! [`RENAME_COOLDOWN_HOURS`] to stop churn. | |
| 12 | //! | |
| 13 | //! The rename publishes `workspace.renamed`; every other service moves the | |
| 14 | //! rows it keeps under the slug when it hears it. | |
| 15 | ||
| 16 | use g1t_contracts::events::{NewEvent, Publish, WorkspaceRenamed}; | |
| 17 | use g1t_contracts::identity::*; | |
| 18 | use g1t_contracts::time::rfc3339; | |
| A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so. | 19 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, is_action_owner_name, is_valid_namespace}; |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 20 | use g1t_kit::now_ms; |
| 21 | use serde::Deserialize; | |
| 22 | use worker::Result; | |
| 23 | ||
| 24 | use crate::Identity; | |
| 25 | ||
| 26 | const HOUR_MS: u64 = 60 * 60 * 1000; | |
| 27 | const DAY_MS: u64 = 24 * HOUR_MS; | |
| 28 | const SOURCE: &str = "identity"; | |
| 29 | const TAKEN: &str = "That workspace name is taken."; | |
| 30 | /// How many times publishing the event is tried before giving up. | |
| 31 | const PUBLISH_ATTEMPTS: u32 = 3; | |
| 32 | ||
| 33 | /// The earliest `created_at` of a redirect that still holds its slug. | |
| 34 | pub fn hold_cutoff(now_ms: u64) -> String { | |
| 35 | rfc3339(now_ms.saturating_sub(SLUG_HOLD_DAYS * DAY_MS)) | |
| 36 | } | |
| 37 | ||
| 38 | /// Everything about a wanted slug that decides whether a workspace may | |
| 39 | /// take it, as read from the database. | |
| 40 | #[derive(Debug, Default)] | |
| 41 | pub struct Facts<'a> { | |
| 42 | /// The workspace's id and its slug now. | |
| 43 | pub workspace_id: &'a str, | |
| 44 | pub current: &'a str, | |
| 45 | /// The slug asked for, lowercased and trimmed. | |
| 46 | pub wanted: &'a str, | |
| 47 | /// Another person's username is `wanted`. The actor's own is theirs | |
| 48 | /// to use, as when creating a workspace. | |
| 49 | pub someone_elses_username: bool, | |
| 50 | /// Another workspace's slug is `wanted`. | |
| 51 | pub another_workspace: bool, | |
| 52 | /// A redirect holds `wanted`: the workspace it points at, and when it | |
| 53 | /// was made. | |
| 54 | pub redirect: Option<(&'a str, &'a str)>, | |
| 55 | /// When the workspace was last renamed, if ever. | |
| 56 | pub last_renamed_at: Option<&'a str>, | |
| 57 | /// A deleted workspace had `wanted`; it is never given to another. | |
| 58 | pub deleted: bool, | |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 59 | /// Staff made `wanted` an alias (aliases.rs); it stays theirs. |
| 60 | pub aliased: bool, | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 61 | pub now_ms: u64, |
| 62 | } | |
| 63 | ||
| 64 | /// Whether the rename `facts` describe is allowed: `Ok`, or why not, in | |
| 65 | /// words for the owner. | |
| 66 | pub fn check(facts: &Facts) -> std::result::Result<(), (FailureCode, String)> { | |
| 67 | let refuse = |code, message: &str| Err((code, message.to_owned())); | |
| 68 | if !is_valid_namespace(facts.wanted) { | |
| 69 | return refuse( | |
| 70 | FailureCode::Invalid, | |
| 71 | "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.", | |
| 72 | ); | |
| 73 | } | |
| A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so. | 74 | if is_action_owner_name(facts.wanted) { |
| 75 | return refuse( | |
| 76 | FailureCode::Invalid, | |
| 77 | "That name is a GitHub account whose actions workflows use, so it is kept for that account. If it is yours, contact support to prove it.", | |
| 78 | ); | |
| 79 | } | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 80 | if facts.wanted == facts.current { |
| 81 | return refuse(FailureCode::Invalid, "That is already this workspace's name."); | |
| 82 | } | |
| 83 | if let Some(last) = facts.last_renamed_at { | |
| 84 | let cooldown_from = rfc3339(facts.now_ms.saturating_sub(RENAME_COOLDOWN_HOURS * HOUR_MS)); | |
| 85 | if last > cooldown_from.as_str() { | |
| 86 | return refuse( | |
| 87 | FailureCode::Conflict, | |
| 88 | "A workspace can be renamed once a day. Try again tomorrow.", | |
| 89 | ); | |
| 90 | } | |
| 91 | } | |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 92 | if facts.someone_elses_username || facts.another_workspace || facts.deleted || facts.aliased { |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 93 | return refuse(FailureCode::Conflict, TAKEN); |
| 94 | } | |
| 95 | if let Some((holder, created_at)) = facts.redirect | |
| 96 | && holder != facts.workspace_id | |
| 97 | && created_at >= hold_cutoff(facts.now_ms).as_str() | |
| 98 | { | |
| 99 | return refuse(FailureCode::Conflict, TAKEN); | |
| 100 | } | |
| 101 | Ok(()) | |
| 102 | } | |
| 103 | ||
| 104 | /// A redirect as read with the slug its workspace has now. | |
| 105 | #[derive(Debug, Deserialize)] | |
| 106 | pub struct RedirectRow { | |
| 107 | pub workspace_id: String, | |
| 108 | /// The workspace's slug now. | |
| 109 | pub slug: String, | |
| 110 | pub created_at: String, | |
| 111 | } | |
| 112 | ||
| 113 | /// Where an old slug leads: the workspace's current slug, while the | |
| 114 | /// redirect still holds. | |
| 115 | pub fn resolve(row: Option<RedirectRow>, now_ms: u64) -> Option<String> { | |
| 116 | row.filter(|row| row.created_at >= hold_cutoff(now_ms)) | |
| 117 | .map(|row| row.slug) | |
| 118 | } | |
| 119 | ||
| 120 | #[derive(Deserialize)] | |
| 121 | struct Target { | |
| 122 | id: String, | |
| 123 | } | |
| 124 | ||
| 125 | impl Identity { | |
| 126 | /// The redirect holding `slug`, if any, with its workspace's slug now. | |
| 127 | async fn redirect(&self, slug: &str) -> Result<Option<RedirectRow>> { | |
| 128 | self.db | |
| 129 | .prepare( | |
| 130 | "SELECT workspace_redirects.workspace_id, workspaces.slug, | |
| 131 | workspace_redirects.created_at | |
| 132 | FROM workspace_redirects | |
| 133 | JOIN workspaces ON workspaces.id = workspace_redirects.workspace_id | |
| 134 | WHERE workspace_redirects.old_slug = ?", | |
| 135 | ) | |
| 136 | .bind(&[slug.into()])? | |
| 137 | .first::<RedirectRow>(None) | |
| 138 | .await | |
| 139 | } | |
| 140 | ||
| 141 | /// Whether `slug` is an old slug still reserved for the workspace that | |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 142 | /// had it, or an alias staff set, so nobody else may register or create |
| 143 | /// it. | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 144 | pub async fn slug_held(&self, slug: &str) -> Result<bool> { |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 145 | Ok(resolve(self.redirect(slug).await?, now_ms()).is_some() || self.is_alias(slug).await?) |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 146 | } |
| 147 | ||
| Merge branch 'worktree-agent-a8385d293d42c913a' | 148 | /// `resolve_slug`: the current slug for an old one still redirecting, |
| 149 | /// or for an alias (aliases.rs). | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 150 | pub async fn resolve_slug(&self, a: SlugArgs) -> Result<Option<String>> { |
| 151 | let slug = a.slug.trim().to_lowercase(); | |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 152 | let in_use = self.get_workspace(SlugArgs { slug: slug.clone() }).await?.is_some(); |
| 153 | if in_use { | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 154 | return Ok(None); |
| 155 | } | |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 156 | let alias = self.resolve_alias(SlugArgs { slug: slug.clone() }).await?; |
| 157 | let redirect = match alias { | |
| 158 | Some(_) => None, | |
| 159 | None => self.redirect(&slug).await?, | |
| 160 | }; | |
| 161 | Ok(crate::aliases::resolve(in_use, alias, || resolve(redirect, now_ms()))) | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 162 | } |
| 163 | ||
| 164 | /// Checks a rename, returning the workspace's id when it is allowed. | |
| 165 | async fn rename_allowed(&self, a: &RenameWorkspaceArgs) -> Result<Outcome<(String, String)>> { | |
| 166 | let current = a.slug.trim().to_lowercase(); | |
| 167 | let wanted = a.new_slug.trim().to_lowercase(); | |
| 168 | if a.actor.kind != PrincipalKind::User || a.actor.role_in(¤t) != Some(Role::Owner) { | |
| 169 | return Ok(Outcome::fail( | |
| 170 | FailureCode::Forbidden, | |
| 171 | "Only an owner can rename a workspace.", | |
| 172 | )); | |
| 173 | } | |
| 174 | if !a.actor.verified { | |
| 175 | return Ok(Outcome::fail( | |
| 176 | FailureCode::Forbidden, | |
| 177 | "Confirm your email address before renaming a workspace.", | |
| 178 | )); | |
| 179 | } | |
| 180 | let Some(workspace) = self | |
| 181 | .db | |
| 182 | .prepare("SELECT id FROM workspaces WHERE slug = ?") | |
| 183 | .bind(&[current.as_str().into()])? | |
| 184 | .first::<Target>(None) | |
| 185 | .await? | |
| 186 | else { | |
| 187 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); | |
| 188 | }; | |
| 189 | let someone_elses_username = self | |
| 190 | .db | |
| 191 | .prepare("SELECT id FROM users WHERE username = ? AND id != ?") | |
| 192 | .bind(&[wanted.as_str().into(), a.actor.id.as_str().into()])? | |
| 193 | .first::<serde_json::Value>(None) | |
| 194 | .await? | |
| 195 | .is_some(); | |
| 196 | let another_workspace = self | |
| 197 | .db | |
| 198 | .prepare("SELECT id FROM workspaces WHERE slug = ? AND id != ?") | |
| 199 | .bind(&[wanted.as_str().into(), workspace.id.as_str().into()])? | |
| 200 | .first::<serde_json::Value>(None) | |
| 201 | .await? | |
| 202 | .is_some(); | |
| 203 | let redirect = self.redirect(&wanted).await?; | |
| 204 | let last_renamed_at = self | |
| 205 | .db | |
| 206 | .prepare("SELECT max(created_at) AS at FROM workspace_redirects WHERE workspace_id = ?") | |
| 207 | .bind(&[workspace.id.as_str().into()])? | |
| 208 | .first::<Option<String>>(Some("at")) | |
| 209 | .await? | |
| 210 | .flatten(); | |
| 211 | let facts = Facts { | |
| 212 | workspace_id: &workspace.id, | |
| 213 | current: ¤t, | |
| 214 | wanted: &wanted, | |
| 215 | someone_elses_username, | |
| 216 | another_workspace, | |
| 217 | redirect: redirect | |
| 218 | .as_ref() | |
| 219 | .map(|row| (row.workspace_id.as_str(), row.created_at.as_str())), | |
| 220 | last_renamed_at: last_renamed_at.as_deref(), | |
| 221 | deleted: self.slug_deleted(&wanted).await?, | |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 222 | aliased: self.is_alias(&wanted).await?, |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 223 | now_ms: now_ms(), |
| 224 | }; | |
| 225 | Ok(match check(&facts) { | |
| 226 | Ok(()) => Outcome::Ok((workspace.id, wanted)), | |
| 227 | Err((code, message)) => Outcome::fail(code, message), | |
| 228 | }) | |
| 229 | } | |
| 230 | ||
| 231 | pub async fn check_workspace_rename(&self, a: RenameWorkspaceArgs) -> Result<Outcome<bool>> { | |
| 232 | Ok(match self.rename_allowed(&a).await? { | |
| 233 | Outcome::Ok(_) => Outcome::Ok(true), | |
| 234 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 235 | }) | |
| 236 | } | |
| 237 | ||
| 238 | pub async fn rename_workspace(&self, a: RenameWorkspaceArgs) -> Result<Outcome<Workspace>> { | |
| 239 | let (workspace_id, wanted) = match self.rename_allowed(&a).await? { | |
| 240 | Outcome::Ok(allowed) => allowed, | |
| 241 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 242 | }; | |
| 243 | let from = a.slug.trim().to_lowercase(); | |
| 244 | let now = rfc3339(now_ms()); | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 245 | // A workspace protected by its slug stays protected under the new |
| 246 | // one: the protection goes on its row (deletion.rs). | |
| 247 | let protected = self.is_protected(&workspace_id, &from, false).await?; | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 248 | self.db |
| 249 | .batch(vec![ | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 250 | self.db |
| 251 | .prepare("UPDATE workspaces SET protected = 1 WHERE id = ? AND ? = 1") | |
| 252 | .bind(&[workspace_id.as_str().into(), u8::from(protected).into()])?, | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 253 | // A redirect the workspace is renaming back to, or one whose |
| 254 | // hold has ended, gives way to the slug in use. | |
| 255 | self.db | |
| 256 | .prepare("DELETE FROM workspace_redirects WHERE old_slug = ?") | |
| 257 | .bind(&[wanted.as_str().into()])?, | |
| 258 | self.db | |
| 259 | .prepare("UPDATE workspaces SET slug = ? WHERE id = ? AND slug = ?") | |
| 260 | .bind(&[ | |
| 261 | wanted.as_str().into(), | |
| 262 | workspace_id.as_str().into(), | |
| 263 | from.as_str().into(), | |
| 264 | ])?, | |
| 265 | self.db | |
| 266 | .prepare( | |
| 267 | "INSERT OR REPLACE INTO workspace_redirects (old_slug, workspace_id, created_at) | |
| 268 | VALUES (?, ?, ?)", | |
| 269 | ) | |
| 270 | .bind(&[ | |
| 271 | from.as_str().into(), | |
| 272 | workspace_id.as_str().into(), | |
| 273 | now.as_str().into(), | |
| 274 | ])?, | |
| 275 | // Agents at work keep their scope: it names the repository | |
| 276 | // by its path. | |
| 277 | self.db | |
| 278 | .prepare( | |
| 279 | "UPDATE access_tokens SET agent_scope = json_set(agent_scope, '$.repo.namespace', ?) | |
| 280 | WHERE agent_scope IS NOT NULL | |
| 281 | AND json_extract(agent_scope, '$.repo.namespace') = ?", | |
| 282 | ) | |
| 283 | .bind(&[wanted.as_str().into(), from.as_str().into()])?, | |
| 284 | ]) | |
| 285 | .await?; | |
| 286 | self.publish_renamed(WorkspaceRenamed { | |
| 287 | workspace_id, | |
| 288 | from, | |
| 289 | to: wanted.clone(), | |
| 290 | }, &a.actor.id) | |
| 291 | .await; | |
| 292 | Ok(match self.get_workspace(SlugArgs { slug: wanted }).await? { | |
| 293 | Some(workspace) => Outcome::Ok(workspace), | |
| 294 | None => Outcome::fail(FailureCode::NotFound, "Workspace not found."), | |
| 295 | }) | |
| 296 | } | |
| 297 | ||
| 298 | /// Tells every other service. The rename has happened by now, so a | |
| 299 | /// failure is logged rather than undoing it. | |
| 300 | async fn publish_renamed(&self, renamed: WorkspaceRenamed, actor: &str) { | |
| 301 | let events = match self.env.service("EVENTS") { | |
| 302 | Ok(events) => events, | |
| 303 | Err(error) => { | |
| 304 | worker::console_error!("workspace.renamed not published: {error}"); | |
| 305 | return; | |
| 306 | } | |
| 307 | }; | |
| 308 | let publish = Publish { | |
| 309 | events: vec![NewEvent { | |
| 310 | kind: "workspace.renamed", | |
| 311 | source: SOURCE, | |
| 312 | repo_id: None, | |
| 313 | actor: Some(actor.to_owned()), | |
| 314 | data: renamed, | |
| 315 | }], | |
| 316 | }; | |
| 317 | for attempt in 1..=PUBLISH_ATTEMPTS { | |
| 318 | match g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await { | |
| 319 | Ok(_) => return, | |
| 320 | Err(error) => worker::console_error!( | |
| 321 | "workspace.renamed publish attempt {attempt} failed: {error}" | |
| 322 | ), | |
| 323 | } | |
| 324 | } | |
| 325 | } | |
| 326 | } | |
| 327 | ||
| 328 | #[cfg(test)] | |
| 329 | mod tests { | |
| 330 | use super::*; | |
| 331 | use std::collections::HashMap; | |
| 332 | ||
| 333 | const NOW: u64 = 1_790_918_179_123; | |
| 334 | ||
| 335 | fn facts<'a>(current: &'a str, wanted: &'a str) -> Facts<'a> { | |
| 336 | Facts { | |
| 337 | workspace_id: "wsp_a", | |
| 338 | current, | |
| 339 | wanted, | |
| 340 | now_ms: NOW, | |
| 341 | ..Facts::default() | |
| 342 | } | |
| 343 | } | |
| 344 | ||
| 345 | fn refused(facts: &Facts) -> FailureCode { | |
| 346 | check(facts).unwrap_err().0 | |
| 347 | } | |
| 348 | ||
| 349 | #[test] | |
| 350 | fn validates_like_creation() { | |
| 351 | assert!(check(&facts("acme", "acme-inc")).is_ok()); | |
| 352 | for bad in ["", "-acme", "acme-", "ac--me", "Acme", "acme_inc", "api", "settings", "pulls"] { | |
| 353 | assert_eq!(refused(&facts("acme", bad)), FailureCode::Invalid, "{bad}"); | |
| 354 | } | |
| 355 | assert_eq!(refused(&facts("acme", &"a".repeat(40))), FailureCode::Invalid); | |
| 356 | assert_eq!(refused(&facts("acme", "acme")), FailureCode::Invalid); | |
| 357 | } | |
| 358 | ||
| 359 | #[test] | |
| 360 | fn no_workspace_is_renamed_to_g1ts_names() { | |
| 361 | for name in ["g1t", "g1t-agent", "G1T", "G1T-Agent"] { | |
| 362 | assert_eq!(refused(&facts("acme", name)), FailureCode::Invalid, "{name}"); | |
| 363 | } | |
| 364 | assert!(check(&facts("acme", "g1t-fans")).is_ok()); | |
| 365 | } | |
| 366 | ||
| 367 | #[test] | |
| A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so. | 368 | fn no_workspace_is_renamed_to_a_github_action_owner() { |
| 369 | for name in ["actions", "docker", "aws-actions"] { | |
| 370 | assert_eq!(refused(&facts("acme", name)), FailureCode::Invalid, "{name}"); | |
| 371 | } | |
| 372 | assert!(check(&facts("acme", "actions-team")).is_ok()); | |
| 373 | } | |
| 374 | ||
| 375 | #[test] | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 376 | fn refuses_names_in_use() { |
| 377 | let taken = Facts { | |
| 378 | someone_elses_username: true, | |
| 379 | ..facts("acme", "bob") | |
| 380 | }; | |
| 381 | assert_eq!(refused(&taken), FailureCode::Conflict); | |
| 382 | let taken = Facts { | |
| 383 | another_workspace: true, | |
| 384 | ..facts("acme", "globex") | |
| 385 | }; | |
| 386 | assert_eq!(refused(&taken), FailureCode::Conflict); | |
| 387 | let deleted = Facts { | |
| 388 | deleted: true, | |
| 389 | ..facts("acme", "initech") | |
| 390 | }; | |
| 391 | assert_eq!(refused(&deleted), FailureCode::Conflict); | |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 392 | let aliased = Facts { |
| 393 | aliased: true, | |
| 394 | ..facts("acme", "flagon") | |
| 395 | }; | |
| 396 | assert_eq!(refused(&aliased), FailureCode::Conflict); | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 397 | } |
| 398 | ||
| 399 | #[test] | |
| 400 | fn an_old_slug_is_held_for_its_workspace_until_the_hold_ends() { | |
| 401 | let recently = rfc3339(NOW - 10 * DAY_MS); | |
| 402 | let long_ago = rfc3339(NOW - (SLUG_HOLD_DAYS + 1) * DAY_MS); | |
| 403 | let held_by_other = Facts { | |
| 404 | redirect: Some(("wsp_b", recently.as_str())), | |
| 405 | ..facts("acme", "globex") | |
| 406 | }; | |
| 407 | assert_eq!(refused(&held_by_other), FailureCode::Conflict); | |
| 408 | let held_by_self = Facts { | |
| 409 | redirect: Some(("wsp_a", recently.as_str())), | |
| 410 | last_renamed_at: Some(recently.as_str()), | |
| 411 | ..facts("acme-inc", "acme") | |
| 412 | }; | |
| 413 | assert!(check(&held_by_self).is_ok(), "a workspace can rename back"); | |
| 414 | let expired = Facts { | |
| 415 | redirect: Some(("wsp_b", long_ago.as_str())), | |
| 416 | ..facts("acme", "globex") | |
| 417 | }; | |
| 418 | assert!(check(&expired).is_ok(), "after the hold anyone can take it"); | |
| 419 | } | |
| 420 | ||
| 421 | #[test] | |
| 422 | fn renames_are_limited_to_one_a_day() { | |
| 423 | let an_hour_ago = rfc3339(NOW - HOUR_MS); | |
| 424 | let two_days_ago = rfc3339(NOW - 2 * DAY_MS); | |
| 425 | let soon = Facts { | |
| 426 | last_renamed_at: Some(an_hour_ago.as_str()), | |
| 427 | ..facts("acme", "acme-inc") | |
| 428 | }; | |
| 429 | assert_eq!(refused(&soon), FailureCode::Conflict); | |
| 430 | let later = Facts { | |
| 431 | last_renamed_at: Some(two_days_ago.as_str()), | |
| 432 | ..facts("acme", "acme-inc") | |
| 433 | }; | |
| 434 | assert!(check(&later).is_ok()); | |
| 435 | } | |
| 436 | ||
| 437 | #[test] | |
| 438 | fn hold_ends_after_the_hold_period() { | |
| 439 | assert_eq!(hold_cutoff(NOW), rfc3339(NOW - SLUG_HOLD_DAYS * DAY_MS)); | |
| 440 | } | |
| 441 | ||
| 442 | /// The tables, as `rename_workspace` changes them: slug by workspace | |
| 443 | /// id, and old slug → (workspace id, when). | |
| 444 | #[derive(Default)] | |
| 445 | struct Tables { | |
| 446 | workspaces: HashMap<&'static str, String>, | |
| 447 | redirects: HashMap<String, (&'static str, String)>, | |
| 448 | } | |
| 449 | ||
| 450 | impl Tables { | |
| 451 | /// The same steps, in the same order, as the batch. | |
| 452 | fn rename(&mut self, id: &'static str, to: &str, at: u64) { | |
| 453 | self.redirects.remove(to); | |
| 454 | let from = self.workspaces.insert(id, to.to_owned()).unwrap(); | |
| 455 | self.redirects.insert(from, (id, rfc3339(at))); | |
| 456 | } | |
| 457 | ||
| 458 | /// As `redirect` + `resolve`. | |
| 459 | fn resolve(&self, slug: &str, now: u64) -> Option<String> { | |
| 460 | let row = self.redirects.get(slug).map(|(id, created_at)| RedirectRow { | |
| 461 | workspace_id: (*id).to_owned(), | |
| 462 | slug: self.workspaces[id].clone(), | |
| 463 | created_at: created_at.clone(), | |
| 464 | }); | |
| 465 | resolve(row, now) | |
| 466 | } | |
| 467 | } | |
| 468 | ||
| 469 | #[test] | |
| 470 | fn renames_chain_to_the_current_slug() { | |
| 471 | let mut tables = Tables::default(); | |
| 472 | tables.workspaces.insert("wsp_a", "acme".into()); | |
| 473 | tables.rename("wsp_a", "acme-inc", NOW); | |
| 474 | tables.rename("wsp_a", "acme-corp", NOW + 2 * DAY_MS); | |
| 475 | let later = NOW + 3 * DAY_MS; | |
| 476 | assert_eq!(tables.resolve("acme", later).as_deref(), Some("acme-corp")); | |
| 477 | assert_eq!(tables.resolve("acme-inc", later).as_deref(), Some("acme-corp")); | |
| 478 | assert_eq!(tables.resolve("unknown", later), None); | |
| 479 | // Past the hold, the first old slug stops redirecting. | |
| 480 | let much_later = NOW + (SLUG_HOLD_DAYS + 1) * DAY_MS; | |
| 481 | assert_eq!(tables.resolve("acme", much_later), None); | |
| 482 | assert_eq!(tables.resolve("acme-inc", much_later).as_deref(), Some("acme-corp")); | |
| 483 | } | |
| 484 | ||
| 485 | #[test] | |
| 486 | fn renaming_back_drops_the_redirect_for_the_slug_in_use() { | |
| 487 | let mut tables = Tables::default(); | |
| 488 | tables.workspaces.insert("wsp_a", "acme".into()); | |
| 489 | tables.rename("wsp_a", "acme-inc", NOW); | |
| 490 | tables.rename("wsp_a", "acme", NOW + 2 * DAY_MS); | |
| 491 | assert!(!tables.redirects.contains_key("acme")); | |
| 492 | let later = NOW + 3 * DAY_MS; | |
| 493 | assert_eq!(tables.resolve("acme-inc", later).as_deref(), Some("acme")); | |
| 494 | } | |
| 495 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.