From cb04c7779cc2b8069e642e2a0fbaafa5bb0a2a87 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 8 Nov 2026 20:15:33 +0000 Subject: [PATCH] fix(invitations): keep every invited role through acceptance A pending invitation stored one builtin role name, so an invite with several roles kept only the first, and a custom role could not be invited at all. pending_invitations gains role_ids. Both invite endpoints accept role_ids, each checked like assigning it: same team, Business gate for custom roles, and the inviter must cover it. Acceptance assigns every id that still exists and falls back to the builtin role name only when none does. Clients that send only `role` behave as before. The pending list returns role_ids, and `role` holds the most senior invited role for display. assign_member_role and invite share require_may_assign_role. Refs VoltiusApp/voltius#779 --- .../066_pending_invitation_role_ids.sql | 1 + src/routes/invitations.rs | 23 +- src/routes/member_names.rs | 12 +- src/routes/team_grants.rs | 2 +- src/routes/teams.rs | 308 ++++++++++++++++-- 5 files changed, 305 insertions(+), 41 deletions(-) create mode 100644 migrations/066_pending_invitation_role_ids.sql diff --git a/migrations/066_pending_invitation_role_ids.sql b/migrations/066_pending_invitation_role_ids.sql new file mode 100644 index 0000000..7e56166 --- /dev/null +++ b/migrations/066_pending_invitation_role_ids.sql @@ -0,0 +1 @@ +ALTER TABLE pending_invitations ADD COLUMN role_ids UUID[] NOT NULL DEFAULT '{}'; diff --git a/src/routes/invitations.rs b/src/routes/invitations.rs index 15d5315..1df06f8 100644 --- a/src/routes/invitations.rs +++ b/src/routes/invitations.rs @@ -52,7 +52,7 @@ pub async fn get_invitation( // ─── Admitting a member ─────────────────────────────────────────────────────── -/// The membership row plus its builtin role, written the same way by all three +/// The membership row plus its roles, written the same way by all three /// acceptance paths: the link token, the in-app pending invite, and the /// auto-accept at registration. /// @@ -66,6 +66,7 @@ pub(crate) async fn admit_member( user_id: Uuid, invited_by: Option, role: &str, + role_ids: &[Uuid], member_name: Option<&str>, ) -> Result<(), StatusCode> { sqlx::query( @@ -86,12 +87,16 @@ pub(crate) async fn admit_member( sqlx::query( r#"INSERT INTO team_member_roles (team_id, user_id, role_id) SELECT $1, $2, tr.id FROM team_roles tr - WHERE tr.team_id = $1 AND tr.name = $3 AND tr.is_builtin = TRUE + WHERE tr.team_id = $1 + AND (tr.id = ANY($4) + OR (tr.name = $3 AND tr.is_builtin + AND NOT EXISTS (SELECT 1 FROM team_roles kept WHERE kept.team_id = $1 AND kept.id = ANY($4)))) ON CONFLICT DO NOTHING"#, ) .bind(team_id) .bind(user_id) .bind(role) + .bind(role_ids) .execute(&mut *conn) .await .map_err(|e| { @@ -112,11 +117,12 @@ pub(crate) struct PendingInvitation { team_id: Uuid, email: String, role: String, + role_ids: Vec, invited_by: Option, member_name: Option, } -pub(crate) const PENDING_INVITATION_COLUMNS: &str = "id, team_id, email, role, invited_by, member_name"; +pub(crate) const PENDING_INVITATION_COLUMNS: &str = "id, team_id, email, role, role_ids, invited_by, member_name"; /// Every acceptance path ends here, so none can skip the audit row or the team fan-out. pub(crate) async fn accept_pending_invitation( @@ -138,6 +144,7 @@ pub(crate) async fn accept_pending_invitation( user_id, invitation.invited_by, &invitation.role, + &invitation.role_ids, invitation.member_name.as_deref(), ) .await?; @@ -163,7 +170,7 @@ pub(crate) async fn accept_pending_invitation( invitation.team_id, user_id, user_email, - json!({ "role": invitation.role, "via": via }), + json!({ "role": invitation.role, "role_ids": invitation.role_ids, "via": via }), ) .await; Ok(()) @@ -682,7 +689,7 @@ mod admit_tests { let invitee = seed_user(&pool).await; let mut conn = pool.acquire().await.unwrap(); - admit_member(&mut conn, team, invitee, Some(owner), "member", None) + admit_member(&mut conn, team, invitee, Some(owner), "member", &[], None) .await .unwrap(); drop(conn); @@ -714,18 +721,18 @@ mod admit_tests { let mut conn = pool.acquire().await.unwrap(); // A link-only invite carries no inviter; a later accept fills it in. - admit_member(&mut conn, team, invitee, None, "member", None) + admit_member(&mut conn, team, invitee, None, "member", &[], None) .await .unwrap(); assert_eq!(inviter_of(&pool, team, invitee).await, None); - admit_member(&mut conn, team, invitee, Some(owner), "member", None) + admit_member(&mut conn, team, invitee, Some(owner), "member", &[], None) .await .unwrap(); assert_eq!(inviter_of(&pool, team, invitee).await, Some(owner)); // …and a second invitation cannot claim credit for a member already in. - admit_member(&mut conn, team, invitee, Some(other), "member", None) + admit_member(&mut conn, team, invitee, Some(other), "member", &[], None) .await .unwrap(); assert_eq!(inviter_of(&pool, team, invitee).await, Some(owner)); diff --git a/src/routes/member_names.rs b/src/routes/member_names.rs index 8f49395..048a8ed 100644 --- a/src/routes/member_names.rs +++ b/src/routes/member_names.rs @@ -341,7 +341,7 @@ mod db_tests { Extension(AuthUser(actor)), Extension(SyncNotifier::new()), Path(team), - Json(InviteMemberRequest { email: email.into(), role: None, name: name.map(str::to_string) }), + Json(InviteMemberRequest { email: email.into(), role: None, name: name.map(str::to_string), role_ids: None }), ) .await .map(|_| ()) @@ -403,7 +403,7 @@ mod db_tests { Extension(AuthUser(admin)), Extension(SyncNotifier::new()), Path(team), - Json(AddMemberRequest { email: None, user_id: Some(invitee), role: None, name: Some("Eva".into()) }), + Json(AddMemberRequest { email: None, user_id: Some(invitee), role: None, name: Some("Eva".into()), role_ids: None }), ) .await .expect("invite"); @@ -433,15 +433,15 @@ mod db_tests { let user = seed_user(&pool).await; let mut conn = pool.acquire().await.unwrap(); - crate::routes::invitations::admit_member(&mut conn, team, user, Some(owner), "member", Some("Jan")).await.unwrap(); + crate::routes::invitations::admit_member(&mut conn, team, user, Some(owner), "member", &[], Some("Jan")).await.unwrap(); sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2") .bind(team).bind(user).execute(&pool).await.unwrap(); assert_eq!(stored(&pool, team, user).await.as_deref(), Some("Jan"), "name survives departure"); - crate::routes::invitations::admit_member(&mut conn, team, user, Some(owner), "member", None).await.unwrap(); + crate::routes::invitations::admit_member(&mut conn, team, user, Some(owner), "member", &[], None).await.unwrap(); assert_eq!(stored(&pool, team, user).await.as_deref(), Some("Jan")); - crate::routes::invitations::admit_member(&mut conn, team, user, Some(owner), "member", Some("Jan Nováková")).await.unwrap(); + crate::routes::invitations::admit_member(&mut conn, team, user, Some(owner), "member", &[], Some("Jan Nováková")).await.unwrap(); assert_eq!(stored(&pool, team, user).await.as_deref(), Some("Jan Nováková")); } @@ -594,7 +594,7 @@ mod db_tests { Extension(AuthUser(admin)), Extension(SyncNotifier::new()), Path(team), - Json(AddMemberRequest { email: None, user_id: Some(by_id), role: None, name: Some("ById".into()) }), + Json(AddMemberRequest { email: None, user_id: Some(by_id), role: None, name: Some("ById".into()), role_ids: None }), ) .await .expect("invite"); diff --git a/src/routes/team_grants.rs b/src/routes/team_grants.rs index 77205c7..c21e0da 100644 --- a/src/routes/team_grants.rs +++ b/src/routes/team_grants.rs @@ -309,7 +309,7 @@ pub async fn redeem_grant( // `invited_by` is the grant's creator, so the roster attributes the joiner // to whoever minted the link. - admit_member(&mut tx, locked.team_id, auth.0, Some(locked.created_by), &locked.role, None).await?; + admit_member(&mut tx, locked.team_id, auth.0, Some(locked.created_by), &locked.role, &[], None).await?; // Fill a missing key only. Overwriting a key a user already published // would orphan every vault key already wrapped to it, in this team and diff --git a/src/routes/teams.rs b/src/routes/teams.rs index fd09c5a..52a2d84 100644 --- a/src/routes/teams.rs +++ b/src/routes/teams.rs @@ -421,6 +421,8 @@ pub struct AddMemberRequest { pub role: Option, #[serde(default)] pub name: Option, + #[serde(default)] + pub role_ids: Option>, } pub async fn add_member( @@ -467,8 +469,7 @@ pub async fn add_member( let owner_id = team_owner(&pool, team_id).await?; ensure_seat_available(&pool, owner_id, Some(invitee_id)).await?; - let role_name = body.role.as_deref().unwrap_or("member").to_string(); - require_may_confer_builtin(&pool, team_id, auth.0, &role_name).await?; + let roles = invited_roles(&pool, team_id, auth.0, body.role.as_deref(), body.role_ids.as_deref()).await?; // Already a member — no-op let already_member = sqlx::query_scalar::<_, bool>( @@ -496,13 +497,13 @@ pub async fn add_member( team_id, user_id: Some(invitee_id), email: &invitee_email, - role: &role_name, + roles: &roles, invited_by: auth.0, member_name: member_name.as_deref(), }) .await?; - info!(team_id = %team_id, invitee_id = %invitee_id, role = %role_name, "Pending invitation created for existing user"); + info!(team_id = %team_id, invitee_id = %invitee_id, role = %roles.name, "Pending invitation created for existing user"); tokio::spawn(write_audit_event( pool.clone(), team_id, @@ -511,7 +512,7 @@ pub async fn add_member( Some("user"), Some(invitee_id.to_string()), Some(invitee_display_name), - Some(json!({ "role": role_name, "status": "pending", "name": member_name })), + Some(roles.audit(member_name.as_deref())), )); // Notify the invitee so their client refreshes pending invitations notifier.notify_pending_invitations_changed(invitee_id); @@ -1217,9 +1218,7 @@ pub async fn assign_member_role( return Err(StatusCode::NOT_FOUND); } - let role = role_authority(&pool, team_id, body.role_id).await?; - crate::team_plan::require_granular(&pool, team_id, role.is_builtin).await?; - require_covers(&pool, team_id, auth.0, role.authority).await?; + require_may_assign_role(&pool, team_id, auth.0, body.role_id).await?; let target_display_name = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE id = $1") .bind(target_user_id) @@ -1537,19 +1536,76 @@ pub(crate) struct PendingInvite<'a> { pub team_id: Uuid, pub user_id: Option, pub email: &'a str, - pub role: &'a str, + pub roles: &'a InvitedRoles, pub invited_by: Uuid, pub member_name: Option<&'a str>, } +const MAX_INVITED_ROLES: usize = 32; + +async fn require_may_assign_role(pool: &PgPool, team_id: Uuid, actor: Uuid, role_id: Uuid) -> Result<(), StatusCode> { + let role = role_authority(pool, team_id, role_id).await?; + crate::team_plan::require_granular(pool, team_id, role.is_builtin).await?; + require_covers(pool, team_id, actor, role.authority).await +} + +/// `ids` empty means an older client named one builtin role; `name` is what invitees see either way. +pub(crate) struct InvitedRoles { + pub name: String, + pub ids: Vec, +} + +impl InvitedRoles { + fn audit(&self, member_name: Option<&str>) -> serde_json::Value { + json!({ "role": self.name, "role_ids": self.ids, "status": "pending", "name": member_name }) + } +} + +async fn invited_roles( + pool: &PgPool, + team_id: Uuid, + actor: Uuid, + role: Option<&str>, + role_ids: Option<&[Uuid]>, +) -> Result { + let mut ids = role_ids.unwrap_or_default().to_vec(); + ids.sort(); + ids.dedup(); + if ids.is_empty() { + let name = role.unwrap_or("member").to_string(); + require_may_confer_builtin(pool, team_id, actor, &name).await?; + return Ok(InvitedRoles { name, ids }); + } + if ids.len() > MAX_INVITED_ROLES { + return Err(StatusCode::BAD_REQUEST); + } + for &id in &ids { + require_may_assign_role(pool, team_id, actor, id).await.map_err(|status| match status { + StatusCode::NOT_FOUND => StatusCode::BAD_REQUEST, + other => other, + })?; + } + let name = sqlx::query_scalar::<_, String>("SELECT name FROM team_roles WHERE team_id = $1 AND id = ANY($2) ORDER BY position, name LIMIT 1") + .bind(team_id) + .bind(&ids) + .fetch_one(pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to name invited roles"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(InvitedRoles { name, ids }) +} + // A name survives a re-send only while the invite is still pending; an accepted row must not revive it. pub(crate) async fn upsert_pending_invitation(pool: &PgPool, invite: PendingInvite<'_>) -> Result { sqlx::query_scalar( - "INSERT INTO pending_invitations (team_id, user_id, email, role, invited_by, member_name) - VALUES ($1, $2, $3, $4, $5, $6) + "INSERT INTO pending_invitations (team_id, user_id, email, role, invited_by, member_name, role_ids) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (team_id, email) DO UPDATE SET user_id = COALESCE(EXCLUDED.user_id, pending_invitations.user_id), role = EXCLUDED.role, + role_ids = EXCLUDED.role_ids, invited_by = EXCLUDED.invited_by, member_name = CASE WHEN pending_invitations.accepted_at IS NULL THEN COALESCE(EXCLUDED.member_name, pending_invitations.member_name) @@ -1561,9 +1617,10 @@ pub(crate) async fn upsert_pending_invitation(pool: &PgPool, invite: PendingInvi .bind(invite.team_id) .bind(invite.user_id) .bind(invite.email) - .bind(invite.role) + .bind(&invite.roles.name) .bind(invite.invited_by) .bind(invite.member_name) + .bind(&invite.roles.ids) .fetch_one(pool) .await .map_err(|e| { @@ -1580,6 +1637,8 @@ pub struct InviteMemberRequest { pub role: Option, #[serde(default)] pub name: Option, + #[serde(default)] + pub role_ids: Option>, } #[derive(Serialize)] @@ -1619,8 +1678,7 @@ pub async fn invite_member( return Err(StatusCode::BAD_REQUEST); } - let role = body.role.as_deref().unwrap_or("member").to_string(); - require_may_confer_builtin(&pool, team_id, auth.0, &role).await?; + let roles = invited_roles(&pool, team_id, auth.0, body.role.as_deref(), body.role_ids.as_deref()).await?; let owner_id = team_owner(&pool, team_id).await?; @@ -1654,13 +1712,13 @@ pub async fn invite_member( team_id, user_id: Some(user_id), email: &email, - role: &role, + roles: &roles, invited_by: auth.0, member_name: member_name.as_deref(), }) .await?; - info!(team_id = %team_id, user_id = %user_id, role = %role, "Pending invitation created for existing user via invite endpoint"); + info!(team_id = %team_id, user_id = %user_id, role = %roles.name, "Pending invitation created for existing user via invite endpoint"); let invite_display_name = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE id = $1") .bind(user_id) .fetch_optional(&pool) @@ -1674,7 +1732,7 @@ pub async fn invite_member( Some("user"), Some(user_id.to_string()), invite_display_name, - Some(json!({ "role": role, "status": "pending", "name": member_name })), + Some(roles.audit(member_name.as_deref())), )); notifier.notify_pending_invitations_changed(user_id); notify_team_members_changed(&pool, ¬ifier, team_id).await; @@ -1698,7 +1756,7 @@ pub async fn invite_member( team_id, user_id: None, email: &email, - role: &role, + roles: &roles, invited_by: auth.0, member_name: member_name.as_deref(), }) @@ -1723,7 +1781,7 @@ pub async fn invite_member( Some("user"), None, Some(invite_display_name), - Some(json!({ "role": role, "status": "pending", "name": member_name })), + Some(roles.audit(member_name.as_deref())), )); notify_team_members_changed(&pool, ¬ifier, team_id).await; Ok(Json(InviteMemberResponse { status: "invited".to_string(), email_sent: Some(email_sent) })) @@ -1739,6 +1797,8 @@ pub struct PendingInvitation { /// no handle, so the server has nothing else to send here. pub display_name: String, pub role: String, + /// Empty for an invite from an older client, which carries only `role`. + pub role_ids: Vec, pub invited_by_display_name: Option, pub created_at: chrono::DateTime, pub expires_at: chrono::DateTime, @@ -1766,8 +1826,8 @@ pub async fn list_pending_invitations( return Err(StatusCode::FORBIDDEN); } - let rows = sqlx::query_as::<_, (Uuid, String, String, Option, chrono::DateTime, chrono::DateTime, Option)>( - r#"SELECT pi.id, COALESCE(invitee.handle, pi.email), pi.role, inv.handle, pi.created_at, pi.expires_at, pi.member_name + let rows = sqlx::query_as::<_, (Uuid, String, String, Option, chrono::DateTime, chrono::DateTime, Option, Vec)>( + r#"SELECT pi.id, COALESCE(invitee.handle, pi.email), pi.role, inv.handle, pi.created_at, pi.expires_at, pi.member_name, pi.role_ids FROM pending_invitations pi LEFT JOIN users inv ON inv.id = pi.invited_by LEFT JOIN users invitee ON invitee.id = pi.user_id @@ -1786,8 +1846,8 @@ pub async fn list_pending_invitations( let now = chrono::Utc::now(); Ok(Json( rows.into_iter() - .map(|(id, display_name, role, invited_by_display_name, created_at, expires_at, member_name)| PendingInvitation { - id, display_name, role, invited_by_display_name, created_at, expires_at, member_name, + .map(|(id, display_name, role, invited_by_display_name, created_at, expires_at, member_name, role_ids)| PendingInvitation { + id, display_name, role, invited_by_display_name, created_at, expires_at, member_name, role_ids, status: if expires_at > now { "pending" } else { "expired" }.to_string(), }) .collect(), @@ -2087,6 +2147,7 @@ mod authz_tests { email: None, role: None, name: None, + role_ids: None, }), ) .await; @@ -2117,6 +2178,7 @@ mod authz_tests { email: None, role: None, name: None, + role_ids: None, }), ) .await; @@ -2922,6 +2984,7 @@ mod authz_tests { email: None, role: None, name: None, + role_ids: None, }), ) .await; @@ -2953,6 +3016,7 @@ mod authz_tests { email: None, role: None, name: None, + role_ids: None, }), ) .await; @@ -2994,6 +3058,7 @@ mod authz_tests { email: None, role: None, name: None, + role_ids: None, }), ) .await; @@ -3037,6 +3102,7 @@ mod authz_tests { email: None, role: None, name: None, + role_ids: None, }), ) .await; @@ -3066,6 +3132,7 @@ mod authz_tests { email: "newcomer@test.local".to_string(), role: None, name: None, + role_ids: None, }), ) .await; @@ -3086,7 +3153,7 @@ mod authz_tests { Extension(AuthUser(caller)), Extension(SyncNotifier::new()), Path(team), - Json(AddMemberRequest { user_id: Some(invitee), email: None, role: None, name: None }), + Json(AddMemberRequest { user_id: Some(invitee), email: None, role: None, name: None, role_ids: None }), ) .await?; Ok((code, body.status)) @@ -3098,7 +3165,7 @@ mod authz_tests { Extension(AuthUser(caller)), Extension(SyncNotifier::new()), Path(team), - Json(InviteMemberRequest { email, role: None, name: None }), + Json(InviteMemberRequest { email, role: None, name: None, role_ids: None }), ) .await .map(|Json(body)| body) @@ -4012,7 +4079,7 @@ mod ownership_tests { Extension(AuthUser(actor)), Extension(SyncNotifier::new()), Path(self.id), - Json(AddMemberRequest { email: None, user_id: Some(invitee), role: Some(role.into()), name: None }), + Json(AddMemberRequest { email: None, user_id: Some(invitee), role: Some(role.into()), name: None, role_ids: None }), ) .await .map(|(status, _)| status) @@ -4127,4 +4194,193 @@ mod ownership_tests { assert_eq!(t.remove(t.creator, t.creator).await, Ok(StatusCode::NO_CONTENT)); assert_eq!(team_owner(&t.pool, t.id).await.unwrap(), bob); } + +} + +#[cfg(test)] +mod invite_role_tests { + use super::*; + use crate::auth::AuthUser; + use crate::permissions::{PERM_INVITE_MEMBERS, PERM_VIEW_SECRETS}; + use crate::sync_notifier::SyncNotifier; + use crate::test_pool_or_skip; + use crate::test_support::{inviter_with, seed_role, seed_team_with_roles, seed_user, set_user_seats, set_user_tier, BillingMode}; + use axum::extract::{Path, State}; + use axum::{Extension, Json}; + + async fn builtin_role_id(pool: &PgPool, team: Uuid, name: &str) -> Uuid { + sqlx::query_scalar("SELECT id FROM team_roles WHERE team_id = $1 AND name = $2 AND is_builtin") + .bind(team).bind(name).fetch_one(pool).await.unwrap() + } + + async fn roles_held(pool: &PgPool, team: Uuid, user: Uuid) -> Vec { + let mut ids: Vec = sqlx::query_scalar("SELECT role_id FROM team_member_roles WHERE team_id = $1 AND user_id = $2") + .bind(team).bind(user).fetch_all(pool).await.unwrap(); + ids.sort(); + ids + } + + fn sorted(mut ids: Vec) -> Vec { + ids.sort(); + ids + } + + async fn invite_with_roles(pool: &PgPool, team: Uuid, actor: Uuid, invitee: Uuid, role: Option<&str>, role_ids: Option>) -> Result<(), StatusCode> { + add_member( + State(pool.clone()), + Extension(AuthUser(actor)), + Extension(SyncNotifier::new()), + Path(team), + Json(AddMemberRequest { user_id: Some(invitee), email: None, role: role.map(str::to_string), name: None, role_ids }), + ) + .await + .map(|_| ()) + } + + async fn accept_pending(pool: &PgPool, team: Uuid, invitee: Uuid) { + let id: Uuid = sqlx::query_scalar("SELECT id FROM pending_invitations WHERE team_id = $1 AND user_id = $2") + .bind(team).bind(invitee).fetch_one(pool).await.unwrap(); + let res = crate::routes::invitations::accept_my_pending_invitation( + State(pool.clone()), Extension(AuthUser(invitee)), Extension(SyncNotifier::new()), Path(id), + ) + .await; + assert_eq!(res, Ok(StatusCode::NO_CONTENT)); + } + + async fn pending_count(pool: &PgPool, team: Uuid, invitee: Uuid) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM pending_invitations WHERE team_id = $1 AND user_id = $2") + .bind(team).bind(invitee).fetch_one(pool).await.unwrap() + } + + #[tokio::test] + async fn an_invite_keeps_every_chosen_role_through_acceptance() { + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + let team = seed_team_with_roles(&pool, owner).await; + let member = builtin_role_id(&pool, team, "member").await; + let custom = seed_role(&pool, team, "dba", PERM_VIEW_SECRETS).await; + let invitee = seed_user(&pool).await; + + invite_with_roles(&pool, team, owner, invitee, None, Some(vec![member, custom])).await.unwrap(); + accept_pending(&pool, team, invitee).await; + + assert_eq!(roles_held(&pool, team, invitee).await, sorted(vec![member, custom])); + } + + #[tokio::test] + async fn an_email_invite_keeps_every_chosen_role_too() { + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + let team = seed_team_with_roles(&pool, owner).await; + let editor = builtin_role_id(&pool, team, "editor").await; + let custom = seed_role(&pool, team, "dba", PERM_VIEW_SECRETS).await; + let invitee = seed_user(&pool).await; + + invite_member( + State(pool.clone()), + Extension(AuthUser(owner)), + Extension(SyncNotifier::new()), + Path(team), + Json(InviteMemberRequest { email: crate::test_support::test_user_email(invitee), role: None, name: None, role_ids: Some(vec![custom, editor]) }), + ) + .await + .map(|_| ()) + .unwrap(); + accept_pending(&pool, team, invitee).await; + + assert_eq!(roles_held(&pool, team, invitee).await, sorted(vec![editor, custom])); + } + + #[tokio::test] + async fn an_invite_names_its_most_senior_role_for_display() { + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + let team = seed_team_with_roles(&pool, owner).await; + let member = builtin_role_id(&pool, team, "member").await; + let editor = builtin_role_id(&pool, team, "editor").await; + let invitee = seed_user(&pool).await; + + invite_with_roles(&pool, team, owner, invitee, Some("connect-only"), Some(vec![member, editor])).await.unwrap(); + + let Json(pending) = list_pending_invitations(State(pool.clone()), Extension(AuthUser(owner)), Path(team)).await.unwrap(); + let row = pending.into_iter().next().unwrap(); + assert_eq!(row.role, "editor"); + assert_eq!(sorted(row.role_ids), sorted(vec![member, editor])); + } + + #[tokio::test] + async fn an_invited_role_from_another_team_is_refused() { + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + let team = seed_team_with_roles(&pool, owner).await; + let other_team = seed_team_with_roles(&pool, owner).await; + let foreign = seed_role(&pool, other_team, "dba", PERM_VIEW_SECRETS).await; + let invitee = seed_user(&pool).await; + + assert_eq!(invite_with_roles(&pool, team, owner, invitee, None, Some(vec![foreign])).await, Err(StatusCode::BAD_REQUEST)); + assert_eq!(pending_count(&pool, team, invitee).await, 0); + } + + #[tokio::test] + async fn an_inviter_cannot_invite_into_a_role_above_their_own() { + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + let team = seed_team_with_roles(&pool, owner).await; + let inviter = inviter_with(&pool, team, PERM_INVITE_MEMBERS).await; + let member = builtin_role_id(&pool, team, "member").await; + let manager = builtin_role_id(&pool, team, "manager").await; + let invitee = seed_user(&pool).await; + + assert_eq!(invite_with_roles(&pool, team, inviter, invitee, None, Some(vec![member, manager])).await, Err(StatusCode::FORBIDDEN)); + assert_eq!(pending_count(&pool, team, invitee).await, 0); + } + + #[tokio::test] + async fn a_teams_team_may_invite_into_builtin_roles_but_not_custom_ones() { + let _env = BillingMode::hosted(); + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + set_user_tier(&pool, owner, "teams").await; + set_user_seats(&pool, owner, 10).await; + let team = seed_team_with_roles(&pool, owner).await; + let member = builtin_role_id(&pool, team, "member").await; + let custom = seed_role(&pool, team, "dba", PERM_VIEW_SECRETS).await; + let (a, b) = (seed_user(&pool).await, seed_user(&pool).await); + + assert_eq!(invite_with_roles(&pool, team, owner, a, None, Some(vec![member])).await, Ok(())); + assert_eq!(invite_with_roles(&pool, team, owner, b, None, Some(vec![member, custom])).await, Err(StatusCode::PAYMENT_REQUIRED)); + } + + #[tokio::test] + async fn a_resent_invite_replaces_its_roles() { + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + let team = seed_team_with_roles(&pool, owner).await; + let member = builtin_role_id(&pool, team, "member").await; + let connect_only = builtin_role_id(&pool, team, "connect-only").await; + let custom = seed_role(&pool, team, "dba", PERM_VIEW_SECRETS).await; + let invitee = seed_user(&pool).await; + + invite_with_roles(&pool, team, owner, invitee, None, Some(vec![member, custom])).await.unwrap(); + invite_with_roles(&pool, team, owner, invitee, Some("connect-only"), None).await.unwrap(); + accept_pending(&pool, team, invitee).await; + + assert_eq!(roles_held(&pool, team, invitee).await, vec![connect_only]); + } + + #[tokio::test] + async fn a_role_deleted_before_acceptance_is_skipped() { + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + let team = seed_team_with_roles(&pool, owner).await; + let member = builtin_role_id(&pool, team, "member").await; + let custom = seed_role(&pool, team, "dba", PERM_VIEW_SECRETS).await; + let invitee = seed_user(&pool).await; + + invite_with_roles(&pool, team, owner, invitee, None, Some(vec![member, custom])).await.unwrap(); + sqlx::query("DELETE FROM team_roles WHERE id = $1").bind(custom).execute(&pool).await.unwrap(); + accept_pending(&pool, team, invitee).await; + + assert_eq!(roles_held(&pool, team, invitee).await, vec![member]); + } }