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