| 1 | //! Who owns and belongs to a workspace, and what its members may do. See |
| 2 | //! `g1t_contracts::members` for the rules. |
| 3 | //! |
| 4 | //! - **Owners.** `update_member` makes a member an owner or an owner a |
| 5 | //! member, and gives or takes away billing manager and security manager; |
| 6 | //! `transfer_ownership` hands the workspace over in one step; |
| 7 | //! `leave_workspace` is anyone leaving. None of them leaves a workspace |
| 8 | //! without an owner. |
| 9 | //! - **Member privileges.** `set_member_privileges` stores them as JSON in |
| 10 | //! `workspaces.member_privileges`; every membership identity resolves |
| 11 | //! carries them (workspaces.rs), and the repos service enforces them. |
| 12 | //! - **Two-factor requirement.** `set_two_factor_requirement` sets |
| 13 | //! `workspaces.require_two_factor`, which security.rs enforces. |
| 14 | //! - **Creators.** `grant_creator` gives whoever creates a repository the |
| 15 | //! Admin role on it, and [`Identity::backfill_creator_grants`] did the |
| 16 | //! same, once, for repositories made before. |
| 17 | //! |
| 18 | //! Every change is recorded in the workspace's audit log. |
| 19 | |
| 20 | use g1t_contracts::audit::Surface; |
| 21 | use g1t_contracts::identity::Member; |
| 22 | use g1t_contracts::members::*; |
| 23 | use g1t_contracts::repos::{AllIdsArgs, CreatorPage}; |
| 24 | use g1t_contracts::time::rfc3339; |
| 25 | use g1t_contracts::{FailureCode, Outcome, Role, User}; |
| 26 | use g1t_kit::now_ms; |
| 27 | use serde::Deserialize; |
| 28 | use worker::Result; |
| 29 | use worker::wasm_bindgen::JsValue; |
| 30 | |
| 31 | use crate::Identity; |
| 32 | use crate::security::is_person; |
| 33 | |
| 34 | const OWNERS_ONLY: &str = "Only an owner can change a workspace's members."; |
| 35 | const CONFIRM_FIRST: &str = "Confirm your email address before changing the workspace's members."; |
| 36 | const NOT_A_MEMBER: &str = "That person is not a member of this workspace."; |
| 37 | |
| 38 | /// A member's row, as the rules here need it. |
| 39 | #[derive(Deserialize)] |
| 40 | pub(crate) struct MemberRow { |
| 41 | pub user_id: String, |
| 42 | pub username: String, |
| 43 | pub role: Role, |
| 44 | #[serde(default)] |
| 45 | pub name: Option<String>, |
| 46 | #[serde(default)] |
| 47 | pub avatar: Option<String>, |
| 48 | #[serde(default)] |
| 49 | pub billing_manager: u8, |
| 50 | #[serde(default)] |
| 51 | pub security_manager: u8, |
| 52 | } |
| 53 | |
| 54 | impl MemberRow { |
| 55 | pub fn org_roles(&self) -> Vec<OrgRole> { |
| 56 | let mut roles = Vec::new(); |
| 57 | if self.billing_manager != 0 { |
| 58 | roles.push(OrgRole::BillingManager); |
| 59 | } |
| 60 | if self.security_manager != 0 { |
| 61 | roles.push(OrgRole::SecurityManager); |
| 62 | } |
| 63 | roles |
| 64 | } |
| 65 | |
| 66 | pub fn member(&self, two_factor: Option<bool>) -> Member { |
| 67 | Member { |
| 68 | username: self.username.clone(), |
| 69 | role: self.role, |
| 70 | org_roles: self.org_roles(), |
| 71 | two_factor, |
| 72 | name: self.name.clone(), |
| 73 | avatar: self.avatar.clone(), |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /// How a role change reads in the audit log. |
| 79 | fn role_words(role: Role) -> &'static str { |
| 80 | match role { |
| 81 | Role::Owner => "owner", |
| 82 | Role::Member => "member", |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | impl Identity { |
| 87 | /// The workspace's id, if `actor` is a verified person who owns it; the |
| 88 | /// refusal otherwise. |
| 89 | async fn owners_workspace(&self, actor: &User, slug: &str) -> Result<Outcome<String>> { |
| 90 | if !is_person(actor) || actor.role_in(slug) != Some(Role::Owner) { |
| 91 | return Ok(Outcome::fail(FailureCode::Forbidden, OWNERS_ONLY)); |
| 92 | } |
| 93 | if !actor.verified { |
| 94 | return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST)); |
| 95 | } |
| 96 | Ok(match self.workspace_id_of(slug).await? { |
| 97 | Some(id) => Outcome::Ok(id), |
| 98 | None => Outcome::fail(FailureCode::NotFound, "Workspace not found."), |
| 99 | }) |
| 100 | } |
| 101 | |
| 102 | /// One member of a workspace, by username. |
| 103 | pub(crate) async fn member_row(&self, workspace_id: &str, username: &str) -> Result<Option<MemberRow>> { |
| 104 | self.db |
| 105 | .prepare( |
| 106 | "SELECT m.user_id, u.username, m.role, u.display_name AS name, u.avatar, |
| 107 | m.billing_manager, m.security_manager |
| 108 | FROM workspace_members m JOIN users u ON u.id = m.user_id |
| 109 | WHERE m.workspace_id = ? AND u.username = ?", |
| 110 | ) |
| 111 | .bind(&[workspace_id.into(), username.trim().trim_start_matches('@').to_lowercase().into()])? |
| 112 | .first::<MemberRow>(None) |
| 113 | .await |
| 114 | } |
| 115 | |
| 116 | /// How many owners a workspace has. |
| 117 | pub(crate) async fn owner_count(&self, workspace_id: &str) -> Result<usize> { |
| 118 | #[derive(Deserialize)] |
| 119 | struct Count { |
| 120 | owners: u32, |
| 121 | } |
| 122 | Ok(self |
| 123 | .db |
| 124 | .prepare("SELECT count(*) AS owners FROM workspace_members WHERE workspace_id = ? AND role = 'owner'") |
| 125 | .bind(&[workspace_id.into()])? |
| 126 | .first::<Count>(None) |
| 127 | .await? |
| 128 | .map_or(0, |count| count.owners as usize)) |
| 129 | } |
| 130 | |
| 131 | /// `update_member`: see [`UpdateMemberArgs`]. |
| 132 | pub async fn update_member(&self, a: UpdateMemberArgs) -> Result<Outcome<Member>> { |
| 133 | let slug = a.slug.trim().to_lowercase(); |
| 134 | let workspace_id = match self.owners_workspace(&a.actor, &slug).await? { |
| 135 | Outcome::Ok(id) => id, |
| 136 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 137 | }; |
| 138 | let Some(row) = self.member_row(&workspace_id, &a.username).await? else { |
| 139 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_A_MEMBER)); |
| 140 | }; |
| 141 | if a.role.is_none() && a.org_roles.is_none() { |
| 142 | return Ok(Outcome::fail(FailureCode::Invalid, "Give role or org_roles to change.")); |
| 143 | } |
| 144 | let surface = a.surface.unwrap_or(Surface::Web); |
| 145 | let role = a.role.unwrap_or(row.role); |
| 146 | if row.role == Role::Owner |
| 147 | && role == Role::Member |
| 148 | && let Some(why) = last_owner_refusal(self.owner_count(&workspace_id).await?, true) |
| 149 | { |
| 150 | return Ok(Outcome::fail(FailureCode::Conflict, why)); |
| 151 | } |
| 152 | let mut org_roles = a.org_roles.clone().unwrap_or_else(|| row.org_roles()); |
| 153 | org_roles.sort(); |
| 154 | org_roles.dedup(); |
| 155 | let billing = org_roles.contains(&OrgRole::BillingManager); |
| 156 | let security = org_roles.contains(&OrgRole::SecurityManager); |
| 157 | self.db |
| 158 | .prepare( |
| 159 | "UPDATE workspace_members SET role = ?, billing_manager = ?, security_manager = ? |
| 160 | WHERE workspace_id = ? AND user_id = ?", |
| 161 | ) |
| 162 | .bind(&[ |
| 163 | role_words(role).into(), |
| 164 | (billing as u8).into(), |
| 165 | (security as u8).into(), |
| 166 | workspace_id.as_str().into(), |
| 167 | row.user_id.as_str().into(), |
| 168 | ])? |
| 169 | .run() |
| 170 | .await?; |
| 171 | if role != row.role { |
| 172 | self.audit_workspace( |
| 173 | &a.actor, |
| 174 | "member.role_changed", |
| 175 | &slug, |
| 176 | surface, |
| 177 | format!("Changed {}'s role from {} to {}", row.username, role_words(row.role), role_words(role)), |
| 178 | ) |
| 179 | .await; |
| 180 | } |
| 181 | let before = row.org_roles(); |
| 182 | for changed in OrgRole::ALL { |
| 183 | let (had, has) = (before.contains(&changed), org_roles.contains(&changed)); |
| 184 | if had != has { |
| 185 | self.audit_workspace( |
| 186 | &a.actor, |
| 187 | if has { "member.org_role_added" } else { "member.org_role_removed" }, |
| 188 | &slug, |
| 189 | surface, |
| 190 | if has { |
| 191 | format!("Made {} a {}", row.username, changed.label().to_lowercase()) |
| 192 | } else { |
| 193 | format!("Took {} off as {}", row.username, changed.label().to_lowercase()) |
| 194 | }, |
| 195 | ) |
| 196 | .await; |
| 197 | } |
| 198 | } |
| 199 | let updated = MemberRow { role, billing_manager: billing as u8, security_manager: security as u8, ..row }; |
| 200 | Ok(Outcome::Ok(updated.member(None))) |
| 201 | } |
| 202 | |
| 203 | /// `transfer_ownership`: see [`TransferOwnershipArgs`]. |
| 204 | pub async fn transfer_ownership(&self, a: TransferOwnershipArgs) -> Result<Outcome<bool>> { |
| 205 | let slug = a.slug.trim().to_lowercase(); |
| 206 | let workspace_id = match self.owners_workspace(&a.actor, &slug).await? { |
| 207 | Outcome::Ok(id) => id, |
| 208 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 209 | }; |
| 210 | let Some(row) = self.member_row(&workspace_id, &a.username).await? else { |
| 211 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_A_MEMBER)); |
| 212 | }; |
| 213 | if row.user_id == a.actor.id { |
| 214 | return Ok(Outcome::fail(FailureCode::Invalid, "Choose another member to hand the workspace to.")); |
| 215 | } |
| 216 | // The new owner must be able to use the workspace: its policy holds |
| 217 | // for owners too. |
| 218 | if let Some(why) = self.policy_refusal(&row.user_id, &slug).await? { |
| 219 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{} cannot own {slug} yet: {why}", row.username))); |
| 220 | } |
| 221 | self.db |
| 222 | .batch(vec![ |
| 223 | self.db |
| 224 | .prepare("UPDATE workspace_members SET role = 'owner' WHERE workspace_id = ? AND user_id = ?") |
| 225 | .bind(&[workspace_id.as_str().into(), row.user_id.as_str().into()])?, |
| 226 | self.db |
| 227 | .prepare("UPDATE workspace_members SET role = 'member' WHERE workspace_id = ? AND user_id = ?") |
| 228 | .bind(&[workspace_id.as_str().into(), a.actor.id.as_str().into()])?, |
| 229 | ]) |
| 230 | .await?; |
| 231 | self.audit_workspace( |
| 232 | &a.actor, |
| 233 | "workspace.ownership_transferred", |
| 234 | &slug, |
| 235 | a.surface.unwrap_or(Surface::Web), |
| 236 | format!("Handed {slug} to {}: they are an owner, and {} a member", row.username, a.actor.username), |
| 237 | ) |
| 238 | .await; |
| 239 | Ok(Outcome::Ok(true)) |
| 240 | } |
| 241 | |
| 242 | /// `leave_workspace`: see [`LeaveWorkspaceArgs`]. |
| 243 | pub async fn leave_workspace(&self, a: LeaveWorkspaceArgs) -> Result<Outcome<bool>> { |
| 244 | let slug = a.slug.trim().to_lowercase(); |
| 245 | if !is_person(&a.user) { |
| 246 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can leave a workspace, signed in as themselves.")); |
| 247 | } |
| 248 | let Some(workspace_id) = self.workspace_id_of(&slug).await? else { |
| 249 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); |
| 250 | }; |
| 251 | // Read from the table, not the resolved user: someone held out by |
| 252 | // the workspace's policy can still leave it. |
| 253 | let Some(row) = self.member_row(&workspace_id, &a.user.username).await? else { |
| 254 | return Ok(Outcome::fail(FailureCode::NotFound, "You are not a member of this workspace.")); |
| 255 | }; |
| 256 | if row.role == Role::Owner |
| 257 | && let Some(why) = last_owner_refusal(self.owner_count(&workspace_id).await?, true) |
| 258 | { |
| 259 | return Ok(Outcome::fail(FailureCode::Conflict, why)); |
| 260 | } |
| 261 | self.drop_member(&workspace_id, &row.user_id).await?; |
| 262 | self.audit_workspace(&a.user, "member.left", &slug, a.surface.unwrap_or(Surface::Web), format!("{} left {slug}", row.username)) |
| 263 | .await; |
| 264 | Ok(Outcome::Ok(true)) |
| 265 | } |
| 266 | |
| 267 | /// Takes a person out of a workspace and every way into it: their roles |
| 268 | /// on its repositories (access.rs) and their place in its teams |
| 269 | /// (teams.rs). To keep them on a repository, add them to it again as an |
| 270 | /// outside collaborator. |
| 271 | pub(crate) async fn drop_member(&self, workspace_id: &str, user_id: &str) -> Result<()> { |
| 272 | self.db |
| 273 | .batch(vec![ |
| 274 | self.db |
| 275 | .prepare("DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?") |
| 276 | .bind(&[workspace_id.into(), user_id.into()])?, |
| 277 | self.db |
| 278 | .prepare( |
| 279 | "DELETE FROM repo_grants |
| 280 | WHERE workspace_id = ? AND principal_kind = 'user' AND principal_id = ?", |
| 281 | ) |
| 282 | .bind(&[workspace_id.into(), user_id.into()])?, |
| 283 | self.db |
| 284 | .prepare( |
| 285 | "DELETE FROM team_members |
| 286 | WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?) AND user_id = ?", |
| 287 | ) |
| 288 | .bind(&[workspace_id.into(), user_id.into()])?, |
| 289 | ]) |
| 290 | .await?; |
| 291 | Ok(()) |
| 292 | } |
| 293 | |
| 294 | /// A workspace's member privileges, by its id. |
| 295 | pub(crate) async fn privileges_of(&self, workspace_id: &str) -> Result<MemberPrivileges> { |
| 296 | #[derive(Deserialize)] |
| 297 | struct Row { |
| 298 | member_privileges: Option<String>, |
| 299 | } |
| 300 | Ok(self |
| 301 | .db |
| 302 | .prepare("SELECT member_privileges FROM workspaces WHERE id = ?") |
| 303 | .bind(&[workspace_id.into()])? |
| 304 | .first::<Row>(None) |
| 305 | .await? |
| 306 | .map(|row| MemberPrivileges::from_stored(row.member_privileges.as_deref())) |
| 307 | .unwrap_or_default()) |
| 308 | } |
| 309 | |
| 310 | /// `set_member_privileges`: see [`SetMemberPrivilegesArgs`]. |
| 311 | pub async fn set_member_privileges(&self, a: SetMemberPrivilegesArgs) -> Result<Outcome<MemberPrivileges>> { |
| 312 | let slug = a.slug.trim().to_lowercase(); |
| 313 | let workspace_id = match self.owners_workspace(&a.actor, &slug).await? { |
| 314 | Outcome::Ok(id) => id, |
| 315 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 316 | }; |
| 317 | let before = self.privileges_of(&workspace_id).await?; |
| 318 | let after = a.privileges.apply(before); |
| 319 | if after == before { |
| 320 | return Ok(Outcome::Ok(before)); |
| 321 | } |
| 322 | let stored = serde_json::to_string(&after).unwrap_or_default(); |
| 323 | self.db |
| 324 | .prepare("UPDATE workspaces SET member_privileges = ? WHERE id = ?") |
| 325 | .bind(&[stored.into(), workspace_id.as_str().into()])? |
| 326 | .run() |
| 327 | .await?; |
| 328 | for ((name, was), (_, now)) in before.listed().into_iter().zip(after.listed()) { |
| 329 | if was != now { |
| 330 | self.audit_workspace( |
| 331 | &a.actor, |
| 332 | "workspace.member_privileges_changed", |
| 333 | &slug, |
| 334 | a.surface.unwrap_or(Surface::Web), |
| 335 | format!("Turned {} {}", if now { "on" } else { "off" }, MemberPrivileges::describe(name)), |
| 336 | ) |
| 337 | .await; |
| 338 | } |
| 339 | } |
| 340 | self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await; |
| 341 | Ok(Outcome::Ok(after)) |
| 342 | } |
| 343 | |
| 344 | /// `set_two_factor_requirement`: see [`SetTwoFactorRequirementArgs`]. |
| 345 | pub async fn set_two_factor_requirement(&self, a: SetTwoFactorRequirementArgs) -> Result<Outcome<bool>> { |
| 346 | let slug = a.slug.trim().to_lowercase(); |
| 347 | let workspace_id = match self.owners_workspace(&a.actor, &slug).await? { |
| 348 | Outcome::Ok(id) => id, |
| 349 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 350 | }; |
| 351 | if a.required && !self.two_factor_enabled(&a.actor.id).await? { |
| 352 | return Ok(Outcome::fail( |
| 353 | FailureCode::Conflict, |
| 354 | "Turn on two-factor authentication for your own account before requiring it of everyone.", |
| 355 | )); |
| 356 | } |
| 357 | let before = self.workspace_policy(&slug).await?.require_two_factor; |
| 358 | if before == a.required { |
| 359 | return Ok(Outcome::Ok(before)); |
| 360 | } |
| 361 | self.db |
| 362 | .prepare("UPDATE workspaces SET require_two_factor = ? WHERE id = ?") |
| 363 | .bind(&[(a.required as u8).into(), workspace_id.as_str().into()])? |
| 364 | .run() |
| 365 | .await?; |
| 366 | self.audit_workspace( |
| 367 | &a.actor, |
| 368 | if a.required { "workspace.two_factor_required" } else { "workspace.two_factor_not_required" }, |
| 369 | &slug, |
| 370 | a.surface.unwrap_or(Surface::Web), |
| 371 | if a.required { |
| 372 | "Required two-factor authentication of members and outside collaborators".to_owned() |
| 373 | } else { |
| 374 | "Stopped requiring two-factor authentication".to_owned() |
| 375 | }, |
| 376 | ) |
| 377 | .await; |
| 378 | self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await; |
| 379 | Ok(Outcome::Ok(a.required)) |
| 380 | } |
| 381 | |
| 382 | /// `grant_creator`: see [`GrantCreatorArgs`]. |
| 383 | pub async fn grant_creator(&self, a: GrantCreatorArgs) -> Result<bool> { |
| 384 | let Some(workspace_id) = self.workspace_id_of(&a.namespace.to_lowercase()).await? else { |
| 385 | return Ok(false); |
| 386 | }; |
| 387 | if !self.is_member_of(&workspace_id, &a.user_id).await? { |
| 388 | return Ok(false); |
| 389 | } |
| 390 | self.insert_creator_grant(&a.repo_id, &workspace_id, &a.name, &a.user_id).await?; |
| 391 | Ok(true) |
| 392 | } |
| 393 | |
| 394 | async fn insert_creator_grant(&self, repo_id: &str, workspace_id: &str, name: &str, user_id: &str) -> Result<()> { |
| 395 | let now = rfc3339(now_ms()); |
| 396 | // Never lowers a role someone already gave them. |
| 397 | self.db |
| 398 | .prepare( |
| 399 | "INSERT INTO repo_grants |
| 400 | (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at) |
| 401 | VALUES (?1, 'user', ?2, ?3, ?4, 'admin', NULL, ?5, ?5) |
| 402 | ON CONFLICT (repo_id, principal_kind, principal_id) |
| 403 | DO UPDATE SET role = 'admin', updated_at = excluded.updated_at", |
| 404 | ) |
| 405 | .bind(&[repo_id.into(), user_id.into(), workspace_id.into(), name.into(), now.as_str().into()])? |
| 406 | .run() |
| 407 | .await?; |
| 408 | Ok(()) |
| 409 | } |
| 410 | |
| 411 | /// Once, a page per run of the scheduled handler: the person who |
| 412 | /// created each repository made before creators were given Admin gets |
| 413 | /// it, if they are still a member of its workspace and lack it. |
| 414 | pub async fn backfill_creator_grants(&self) -> Result<()> { |
| 415 | #[derive(Deserialize)] |
| 416 | struct Job { |
| 417 | cursor: Option<String>, |
| 418 | done_at: Option<String>, |
| 419 | } |
| 420 | let Some(job) = self |
| 421 | .db |
| 422 | .prepare("SELECT cursor, done_at FROM identity_jobs WHERE name = 'creator_grants'") |
| 423 | .first::<Job>(None) |
| 424 | .await? |
| 425 | else { |
| 426 | return Ok(()); |
| 427 | }; |
| 428 | if job.done_at.is_some() { |
| 429 | return Ok(()); |
| 430 | } |
| 431 | let page: CreatorPage = g1t_kit::call( |
| 432 | &self.env.service("REPOS")?, |
| 433 | "repo_creators", |
| 434 | &AllIdsArgs { after: job.cursor.clone(), limit: 200 }, |
| 435 | ) |
| 436 | .await?; |
| 437 | #[derive(Deserialize)] |
| 438 | struct Standing { |
| 439 | workspace_id: String, |
| 440 | role: String, |
| 441 | base_permission: String, |
| 442 | direct: Option<String>, |
| 443 | } |
| 444 | for repo in &page.repos { |
| 445 | let standing = self |
| 446 | .db |
| 447 | .prepare( |
| 448 | "SELECT w.id AS workspace_id, m.role, w.base_permission, |
| 449 | (SELECT g.role FROM repo_grants g WHERE g.repo_id = ?1 AND g.principal_kind = 'user' AND g.principal_id = ?2) AS direct |
| 450 | FROM workspaces w JOIN workspace_members m ON m.workspace_id = w.id AND m.user_id = ?2 |
| 451 | WHERE w.slug = ?3 AND w.deleted_at IS NULL", |
| 452 | ) |
| 453 | .bind(&[repo.id.as_str().into(), repo.owner_id.as_str().into(), repo.namespace.to_lowercase().into()])? |
| 454 | .first::<Standing>(None) |
| 455 | .await?; |
| 456 | let Some(standing) = standing else { continue }; |
| 457 | let has_admin = standing.role == "owner" || standing.base_permission == "admin" || standing.direct.as_deref() == Some("admin"); |
| 458 | if !has_admin { |
| 459 | self.insert_creator_grant(&repo.id, &standing.workspace_id, &repo.name, &repo.owner_id).await?; |
| 460 | } |
| 461 | } |
| 462 | let (cursor, done): (JsValue, JsValue) = match &page.next { |
| 463 | Some(next) => (next.as_str().into(), JsValue::NULL), |
| 464 | None => (job.cursor.as_deref().map_or(JsValue::NULL, Into::into), rfc3339(now_ms()).into()), |
| 465 | }; |
| 466 | self.db |
| 467 | .prepare("UPDATE identity_jobs SET cursor = ?, done_at = ? WHERE name = 'creator_grants'") |
| 468 | .bind(&[cursor, done])? |
| 469 | .run() |
| 470 | .await?; |
| 471 | Ok(()) |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | #[cfg(test)] |
| 476 | mod tests { |
| 477 | use super::*; |
| 478 | |
| 479 | fn row(billing: u8, security: u8) -> MemberRow { |
| 480 | MemberRow { |
| 481 | user_id: "usr_1".into(), |
| 482 | username: "ada".into(), |
| 483 | role: Role::Member, |
| 484 | name: None, |
| 485 | avatar: None, |
| 486 | billing_manager: billing, |
| 487 | security_manager: security, |
| 488 | } |
| 489 | } |
| 490 | |
| 491 | #[test] |
| 492 | fn a_rows_flags_are_its_org_roles() { |
| 493 | assert!(row(0, 0).org_roles().is_empty()); |
| 494 | assert_eq!(row(1, 0).org_roles(), vec![OrgRole::BillingManager]); |
| 495 | assert_eq!(row(1, 1).org_roles(), vec![OrgRole::BillingManager, OrgRole::SecurityManager]); |
| 496 | let member = row(0, 1).member(Some(true)); |
| 497 | assert_eq!(member.org_roles, vec![OrgRole::SecurityManager]); |
| 498 | assert_eq!(member.two_factor, Some(true)); |
| 499 | } |
| 500 | } |