bitwarden_organization_invite_link/
error.rs1use bitwarden_core::{ApiError, MissingFieldError};
2use bitwarden_crypto::CryptoError;
3use bitwarden_error::bitwarden_error;
4use bitwarden_organization_crypto::{
5 account_recovery::AccountRecoveryMakeError, invite::InviteKeyBundleError,
6 organization_shared_key::OrganizationSharedKeyMakeError,
7};
8use http::StatusCode;
9use thiserror::Error;
10
11use crate::validation_problem::{ValidationError, ValidationProblem};
12
13#[bitwarden_error(flat)]
16#[derive(Debug, Error)]
17pub enum InviteLinkError {
18 #[error(transparent)]
20 Invite(#[from] InviteKeyBundleError),
21 #[error(transparent)]
23 Api(#[from] ApiError),
24 #[error(transparent)]
27 Crypto(#[from] CryptoError),
28 #[error(transparent)]
30 MissingField(#[from] MissingFieldError),
31 #[error("Failed to parse `{0}`")]
33 ParseFailure(&'static str),
34 #[error("At least one allowed domain is required.")]
36 NoAllowedDomains,
37}
38
39#[bitwarden_error(flat)]
41#[derive(Debug, Error)]
42pub enum AcceptInviteLinkError {
43 #[error(transparent)]
45 Invite(#[from] InviteKeyBundleError),
46 #[error(transparent)]
48 AccountRecovery(#[from] AccountRecoveryMakeError),
49 #[error(transparent)]
51 OrganizationSharedKey(#[from] OrganizationSharedKeyMakeError),
52 #[error(transparent)]
54 Api(#[from] ApiError),
55 #[error(transparent)]
58 Crypto(#[from] CryptoError),
59 #[error(transparent)]
61 MissingField(#[from] MissingFieldError),
62 #[error("Failed to parse `{0}`")]
64 ParseFailure(&'static str),
65 #[error("Account recovery public key does not match the invite's bound organization key")]
68 RecoveryKeyMismatch,
69
70 #[error("The invite link was not found")]
74 LinkNotFound,
75 #[error("The organization's plan does not support invite links")]
77 InviteLinkNotAvailable,
78 #[error("The invite link does not support confirmation")]
80 InviteLinkConfirmationNotSupported,
81 #[error("The user's email address is not verified")]
83 EmailNotVerified,
84 #[error("The user's email domain is not allowed by the invite link")]
86 EmailDomainNotAllowed,
87 #[error("Provider users cannot join organizations via invite link")]
89 ProviderUsersCannotJoin,
90 #[error("The user's access to the organization has been revoked")]
92 OrganizationAccessRevoked,
93 #[error("The user is already a member of the organization")]
95 AlreadyOrganizationMember,
96 #[error("The organization has no available seats")]
98 OrganizationHasNoAvailableSeats,
99 #[error("Unable to add a seat for the user")]
101 SeatAddFailed,
102 #[error("Account recovery enrollment is required")]
104 ResetPasswordKeyRequired,
105 #[error("The user must leave all other organizations before joining")]
108 MemberOfAnotherOrganization,
109 #[error("A Single Organization policy prevents joining")]
111 SingleOrganizationPolicy,
112 #[error("Another organization's Automatic User Confirmation policy prevents joining")]
115 AutoConfirmPolicyViolationOtherOrg,
116 #[error(
119 "The organization's Automatic User Confirmation policy requires leaving all other organizations"
120 )]
121 AutoConfirmPolicyViolationTargetOrg,
122 #[error("Two-step login is required to join the organization")]
124 TwoFactorRequiredForMembership,
125 #[error("The user can only be an admin of one free organization")]
127 OnlyOneFreeOrganizationAdminAllowed,
128 #[error("{0}")]
132 Unknown(String),
133}
134
135const UNKNOWN_ERROR_FALLBACK_DETAIL: &str = "An unexpected error occurred.";
137
138impl AcceptInviteLinkError {
139 pub(crate) fn from_api_error(error: ApiError) -> Self {
149 if let ApiError::Response(content) = &error
150 && content.status == StatusCode::NOT_FOUND
151 {
152 return Self::LinkNotFound;
153 }
154
155 match ValidationProblem::from_api_error(&error)
156 .and_then(ValidationProblem::into_first_error)
157 {
158 Some(error) => Self::from_validation_error(error),
159 None => Self::Api(error),
160 }
161 }
162
163 fn from_validation_error(error: ValidationError) -> Self {
164 match error.code.as_str() {
165 "invite_link_not_available" => Self::InviteLinkNotAvailable,
166 "invite_link_confirmation_not_supported" => Self::InviteLinkConfirmationNotSupported,
167 "email_not_verified" => Self::EmailNotVerified,
168 "email_domain_not_allowed" => Self::EmailDomainNotAllowed,
169 "provider_users_cannot_join" => Self::ProviderUsersCannotJoin,
170 "organization_access_revoked" => Self::OrganizationAccessRevoked,
171 "already_organization_member" => Self::AlreadyOrganizationMember,
172 "organization_has_no_available_seats" => Self::OrganizationHasNoAvailableSeats,
173 "seat_add_failed" => Self::SeatAddFailed,
174 "reset_password_key_required" => Self::ResetPasswordKeyRequired,
175 "member_of_another_organization" => Self::MemberOfAnotherOrganization,
176 "single_organization_policy" => Self::SingleOrganizationPolicy,
177 "auto_confirm_policy_violation_other_org" => Self::AutoConfirmPolicyViolationOtherOrg,
178 "auto_confirm_policy_violation_target_org" => Self::AutoConfirmPolicyViolationTargetOrg,
179 "two_factor_required_for_membership" => Self::TwoFactorRequiredForMembership,
180 "only_one_free_organization_admin_allowed" => Self::OnlyOneFreeOrganizationAdminAllowed,
181 code => {
182 tracing::warn!(code, "Unrecognized invite link error code");
183 Self::Unknown(
184 error
185 .detail
186 .unwrap_or_else(|| UNKNOWN_ERROR_FALLBACK_DETAIL.to_owned()),
187 )
188 }
189 }
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::validation_problem::tests::{response_error, validation_problem};
197
198 fn map(status: u16, body: &str) -> AcceptInviteLinkError {
199 AcceptInviteLinkError::from_api_error(response_error(status, body))
200 }
201
202 #[test]
203 fn maps_every_known_code() {
204 let cases = [
205 ("code", "invite_link_not_available"),
206 ("code", "invite_link_confirmation_not_supported"),
207 ("organizationId", "email_not_verified"),
208 ("code", "email_domain_not_allowed"),
209 ("code", "provider_users_cannot_join"),
210 ("code", "organization_access_revoked"),
211 ("code", "already_organization_member"),
212 ("code", "organization_has_no_available_seats"),
213 ("code", "seat_add_failed"),
214 ("resetPasswordKey", "reset_password_key_required"),
215 ("organizationId", "member_of_another_organization"),
216 ("organizationId", "single_organization_policy"),
217 ("organizationId", "auto_confirm_policy_violation_other_org"),
218 ("organizationId", "auto_confirm_policy_violation_target_org"),
219 ("organizationId", "two_factor_required_for_membership"),
220 ("organizationId", "only_one_free_organization_admin_allowed"),
221 ];
222
223 for (property, code) in cases {
224 let error = map(400, &validation_problem(property, code));
225 assert!(
226 !matches!(
227 error,
228 AcceptInviteLinkError::Unknown(_) | AcceptInviteLinkError::Api(_)
229 ),
230 "`{code}` should map to a typed variant, got {error:?}"
231 );
232 }
233 }
234
235 #[test]
236 fn maps_already_organization_member() {
237 let error = map(
238 400,
239 &validation_problem("code", "already_organization_member"),
240 );
241 assert!(matches!(
242 error,
243 AcceptInviteLinkError::AlreadyOrganizationMember
244 ));
245 }
246
247 #[test]
248 fn maps_email_not_verified() {
249 let error = map(
250 400,
251 &validation_problem("organizationId", "email_not_verified"),
252 );
253 assert!(matches!(error, AcceptInviteLinkError::EmailNotVerified));
254 }
255
256 #[test]
257 fn maps_unmapped_code_to_unknown_with_detail() {
258 let error = map(400, &validation_problem("code", "some_future_code"));
259 assert!(
260 matches!(&error, AcceptInviteLinkError::Unknown(detail) if detail == "Some detail.")
261 );
262 assert_eq!(error.to_string(), "Some detail.");
263 }
264
265 #[test]
266 fn maps_unmapped_code_without_detail_to_unknown_with_fallback() {
267 let error = map(
268 400,
269 r#"{"type":"validation_error","status":400,"errors":{"code":[{"type":"some_future_code"}]}}"#,
270 );
271 assert!(
272 matches!(error, AcceptInviteLinkError::Unknown(detail) if detail == UNKNOWN_ERROR_FALLBACK_DETAIL)
273 );
274 }
275
276 #[test]
277 fn maps_not_found_with_legacy_body() {
278 let error = map(
279 404,
280 r#"{"message":"Invite link not found.","validationErrors":null,"exceptionMessage":null,"exceptionStackTrace":null,"innerExceptionMessage":null,"object":"error"}"#,
281 );
282 assert!(matches!(error, AcceptInviteLinkError::LinkNotFound));
283 }
284
285 #[test]
286 fn maps_not_found_with_empty_body() {
287 assert!(matches!(map(404, ""), AcceptInviteLinkError::LinkNotFound));
288 }
289
290 #[test]
291 fn keeps_legacy_bad_request_as_api_error() {
292 let error = map(
293 400,
294 r#"{"message":"You're already a member of Acme.","validationErrors":null,"object":"error"}"#,
295 );
296 assert!(matches!(
297 error,
298 AcceptInviteLinkError::Api(ApiError::Response(_))
299 ));
300 }
301
302 #[test]
303 fn keeps_model_binding_problem_as_api_error() {
304 let error = map(
306 400,
307 r#"{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","status":400,"errors":{"Code":["The Code field is required."]}}"#,
308 );
309 assert!(matches!(
310 error,
311 AcceptInviteLinkError::Api(ApiError::Response(_))
312 ));
313 }
314
315 #[test]
316 fn keeps_other_statuses_as_api_error() {
317 let error = map(
318 500,
319 &validation_problem("code", "already_organization_member"),
320 );
321 assert!(matches!(
322 error,
323 AcceptInviteLinkError::Api(ApiError::Response(_))
324 ));
325 }
326
327 #[test]
328 fn keeps_transport_errors_as_api_error() {
329 let error =
330 AcceptInviteLinkError::from_api_error(ApiError::from(std::io::Error::other("boom")));
331 assert!(matches!(error, AcceptInviteLinkError::Api(ApiError::Io(_))));
332 }
333}