mirror of
https://github.com/signalapp/libsignal.git
synced 2026-04-25 17:25:18 +02:00
Add support for a retry later duration in rate limit challenge responses
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
v0.90.0
|
||||
|
||||
- Add support for a retry later duration in rate limit responses
|
||||
|
||||
- keytrans: Simplify the top-level API
|
||||
|
||||
- Use CDSI enclave 3a1ac5e5 in staging.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.signal.libsignal.net
|
||||
|
||||
import org.signal.libsignal.internal.CalledFromNative
|
||||
import java.time.Duration
|
||||
import java.util.EnumSet
|
||||
|
||||
/**
|
||||
@@ -17,10 +18,30 @@ import java.util.EnumSet
|
||||
public class RateLimitChallengeException : ChatServiceException {
|
||||
public val token: String
|
||||
public val options: Set<ChallengeOption>
|
||||
public val retryLater: Duration?
|
||||
|
||||
@CalledFromNative
|
||||
public constructor(message: String, token: String, options: Array<ChallengeOption>) : super(message) {
|
||||
public constructor(
|
||||
message: String,
|
||||
token: String,
|
||||
options: Array<ChallengeOption>,
|
||||
retryLater: Duration?,
|
||||
) : super(message) {
|
||||
this.token = token
|
||||
this.options = EnumSet.copyOf(options.asList())
|
||||
this.retryLater = retryLater
|
||||
}
|
||||
|
||||
@CalledFromNative
|
||||
internal constructor(
|
||||
message: String,
|
||||
token: String,
|
||||
options: Array<ChallengeOption>,
|
||||
retryLater: Long,
|
||||
) : this(
|
||||
message,
|
||||
token,
|
||||
options,
|
||||
if (retryLater < 0) null else Duration.ofSeconds(retryLater),
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,11 +266,18 @@ public class RegistrationServiceTest {
|
||||
}
|
||||
|
||||
private static void assertIsPushChallengeError(ThrowingConsumer<String> throwError) {
|
||||
RateLimitChallengeException e =
|
||||
final RateLimitChallengeException e =
|
||||
assertRegistrationSessionErrorIs(
|
||||
"PushChallenge", RateLimitChallengeException.class, throwError);
|
||||
assertEquals(e.getToken(), "token");
|
||||
assertEquals(e.getOptions(), EnumSet.of(ChallengeOption.PUSH_CHALLENGE));
|
||||
assertEquals(e.getRetryLater(), null);
|
||||
final RateLimitChallengeException e2 =
|
||||
assertRegistrationSessionErrorIs(
|
||||
"PushChallengeRetryAfter42Seconds", RateLimitChallengeException.class, throwError);
|
||||
assertEquals(e2.getToken(), "token42");
|
||||
assertEquals(e2.getOptions(), EnumSet.of(ChallengeOption.PUSH_CHALLENGE));
|
||||
assertEquals(e2.getRetryLater(), Duration.ofSeconds(42));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -307,6 +307,7 @@ export type RateLimitChallengeError = LibSignalErrorBase & {
|
||||
code: ErrorCode.RateLimitChallengeError;
|
||||
readonly token: string;
|
||||
readonly options: Set<'pushChallenge' | 'captcha'>;
|
||||
readonly retryAfterSecs: number | null;
|
||||
};
|
||||
|
||||
export type ChatServiceInactive = LibSignalErrorBase & {
|
||||
|
||||
@@ -133,6 +133,16 @@ describe('Registration types', () => {
|
||||
code: ErrorCode.RateLimitChallengeError,
|
||||
token: 'token',
|
||||
options: new Set(['pushChallenge']),
|
||||
retryAfterSecs: null,
|
||||
},
|
||||
];
|
||||
const rateLimitRetryChallengeCase: [string, object] = [
|
||||
'PushChallengeRetryAfter42Seconds',
|
||||
{
|
||||
code: ErrorCode.RateLimitChallengeError,
|
||||
token: 'token42',
|
||||
options: new Set(['pushChallenge']),
|
||||
retryAfterSecs: 42,
|
||||
},
|
||||
];
|
||||
const cases: Array<{
|
||||
@@ -150,6 +160,7 @@ describe('Registration types', () => {
|
||||
timeoutCase,
|
||||
serverSideErrorCase,
|
||||
rateLimitChallengeCase,
|
||||
rateLimitRetryChallengeCase,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -162,6 +173,7 @@ describe('Registration types', () => {
|
||||
timeoutCase,
|
||||
serverSideErrorCase,
|
||||
rateLimitChallengeCase,
|
||||
rateLimitRetryChallengeCase,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ use libsignal_bridge::ffi::{
|
||||
use libsignal_bridge::{IllegalArgumentError, ffi_arg_type, ffi_result_type};
|
||||
use libsignal_bridge_macros::bridge_fn;
|
||||
use libsignal_core::ProtocolAddress;
|
||||
use libsignal_net::infra::errors::RetryLater;
|
||||
use libsignal_net_chat::api::ChallengeOption;
|
||||
use libsignal_net_chat::api::messages::MismatchedDeviceError;
|
||||
use uuid::Uuid;
|
||||
@@ -146,17 +147,28 @@ fn Error_GetTriesRemaining(err: &SignalFfiError) -> Result<u32, IllegalArgumentE
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[bridge_fn(jni = false, node = false)]
|
||||
fn Error_GetRateLimitChallenge(
|
||||
err: &SignalFfiError,
|
||||
) -> Result<(String, Box<[ChallengeOption]>), IllegalArgumentError> {
|
||||
let libsignal_net_chat::api::RateLimitChallenge { token, options } =
|
||||
err.provide_rate_limit_challenge().map_err(|_| {
|
||||
IllegalArgumentError::new(format!(
|
||||
"cannot get rate limit challenge error from error ({err})"
|
||||
))
|
||||
})?;
|
||||
Ok((token.clone(), options[..].into()))
|
||||
) -> Result<((String, Box<[ChallengeOption]>), i64), IllegalArgumentError> {
|
||||
let libsignal_net_chat::api::RateLimitChallenge {
|
||||
token,
|
||||
options,
|
||||
retry_later,
|
||||
} = err.provide_rate_limit_challenge().map_err(|_| {
|
||||
IllegalArgumentError::new(format!(
|
||||
"cannot get rate limit challenge error from error ({err})"
|
||||
))
|
||||
})?;
|
||||
let retry_later = retry_later
|
||||
.map(
|
||||
|RetryLater {
|
||||
retry_after_seconds,
|
||||
}| i64::from(retry_after_seconds),
|
||||
)
|
||||
.unwrap_or(-1);
|
||||
Ok(((token.clone(), options[..].into()), retry_later))
|
||||
}
|
||||
|
||||
#[bridge_fn(jni = false, node = false)]
|
||||
|
||||
@@ -189,6 +189,12 @@ impl<TestE: for<'a> TryFrom<&'a str, Error = strum::ParseError>> TryFrom<String>
|
||||
"PushChallenge" => RequestError::Challenge(RateLimitChallenge {
|
||||
token: "token".to_owned(),
|
||||
options: vec![ChallengeOption::PushChallenge],
|
||||
retry_later: None,
|
||||
}),
|
||||
"PushChallengeRetryAfter42Seconds" => RequestError::Challenge(RateLimitChallenge {
|
||||
token: "token42".to_owned(),
|
||||
options: vec![ChallengeOption::PushChallenge],
|
||||
retry_later: Some(RETRY_AFTER_42_SECONDS),
|
||||
}),
|
||||
"ServerSideError" => RequestError::ServerSideError,
|
||||
_ => TestE::try_from(&value)
|
||||
|
||||
@@ -1381,6 +1381,7 @@ trivial!(u8);
|
||||
trivial!(u16);
|
||||
trivial!(u32);
|
||||
trivial!(u64);
|
||||
trivial!(i64);
|
||||
trivial!(usize);
|
||||
trivial!(bool);
|
||||
|
||||
@@ -1493,6 +1494,7 @@ macro_rules! ffi_result_type {
|
||||
(u32) => (u32);
|
||||
(Option<u32>) => (u32);
|
||||
(u64) => (u64);
|
||||
(i64) => (i64);
|
||||
(Option<u64>) => (u64);
|
||||
(bool) => (bool);
|
||||
(&str) => (*const std::ffi::c_char);
|
||||
|
||||
@@ -1031,18 +1031,32 @@ impl JniError for RateLimitChallenge {
|
||||
&self,
|
||||
env: &mut JNIEnv<'a>,
|
||||
) -> Result<JThrowable<'a>, BridgeLayerError> {
|
||||
let Self { token, options } = self;
|
||||
let Self {
|
||||
token,
|
||||
options,
|
||||
retry_later,
|
||||
} = self;
|
||||
let (message, token) =
|
||||
try_scoped(|| Ok((env.new_string(self.to_string())?, env.new_string(token)?)))
|
||||
.check_exceptions(env, "RateLimitChallenge")?;
|
||||
let options = options.as_slice().convert_into(env)?;
|
||||
let retry_later = retry_later
|
||||
.as_ref()
|
||||
.map(
|
||||
|RetryLater {
|
||||
retry_after_seconds,
|
||||
}| i64::from(*retry_after_seconds),
|
||||
)
|
||||
.unwrap_or(-1);
|
||||
new_instance(
|
||||
env,
|
||||
ClassName("org.signal.libsignal.net.RateLimitChallengeException"),
|
||||
jni_args!((
|
||||
message => java.lang.String,
|
||||
token => java.lang.String,
|
||||
options => [org.signal.libsignal.net.ChallengeOption]) -> void),
|
||||
options => [org.signal.libsignal.net.ChallengeOption],
|
||||
retry_later => long,
|
||||
) -> void),
|
||||
)
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
|
||||
use libsignal_net::infra::errors::TransportConnectError;
|
||||
use libsignal_net::infra::errors::{RetryLater, TransportConnectError};
|
||||
use libsignal_net::infra::ws::WebSocketConnectError;
|
||||
use libsignal_net_chat::api::keys::GetPreKeysFailure;
|
||||
use neon::thread::LocalKey;
|
||||
@@ -521,15 +521,28 @@ impl SignalNodeError for libsignal_net_chat::api::RateLimitChallenge {
|
||||
operation_name: &str,
|
||||
) -> Handle<'a, JsError> {
|
||||
let message = self.to_string();
|
||||
let Self { token, options } = self;
|
||||
let Self {
|
||||
token,
|
||||
options,
|
||||
retry_later,
|
||||
} = self;
|
||||
let properties = move |cx: &mut C| {
|
||||
let token = cx.string(token);
|
||||
let options = options.into_boxed_slice().convert_into(cx)?.upcast();
|
||||
let set_constructor: Handle<'_, JsFunction> = cx.global("Set")?;
|
||||
let options = set_constructor.construct(cx, [options])?;
|
||||
let props = cx.empty_object();
|
||||
let retry_later = retry_later
|
||||
.map(
|
||||
|RetryLater {
|
||||
retry_after_seconds,
|
||||
}| cx.number(retry_after_seconds),
|
||||
)
|
||||
.map(|x| x.as_value(cx))
|
||||
.unwrap_or_else(|| cx.null().as_value(cx));
|
||||
props.set(cx, "token", token)?;
|
||||
props.set(cx, "options", options)?;
|
||||
props.set(cx, "retryAfterSecs", retry_later)?;
|
||||
Ok(props.upcast())
|
||||
};
|
||||
new_js_error(
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
//! chat-server".
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::fmt::Formatter;
|
||||
|
||||
use libsignal_net::infra::errors::LogSafeDisplay;
|
||||
use libsignal_net::infra::errors::{LogSafeDisplay, RetryLater};
|
||||
use ref_cast::RefCast as _;
|
||||
|
||||
pub mod backups;
|
||||
@@ -142,12 +143,25 @@ impl<E> From<DisconnectedError> for RequestError<E> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, displaydoc::Display)]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[cfg_attr(test, derive(Clone))]
|
||||
/// retry after completing a rate limit challenge {options:?}
|
||||
pub struct RateLimitChallenge {
|
||||
pub token: String,
|
||||
pub options: Vec<ChallengeOption>,
|
||||
pub retry_later: Option<RetryLater>,
|
||||
}
|
||||
impl std::fmt::Display for RateLimitChallenge {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"retry after completing a rate limit challenge {:?}",
|
||||
self.options
|
||||
)?;
|
||||
if let Some(retry_later) = &self.retry_later {
|
||||
write!(f, " (or {retry_later})")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl LogSafeDisplay for RateLimitChallenge {}
|
||||
|
||||
|
||||
@@ -293,7 +293,11 @@ impl ResponseError {
|
||||
if let Ok(ChallengeBody { token, options }) =
|
||||
parse_json_from_body(&response)
|
||||
{
|
||||
return RequestError::Challenge(RateLimitChallenge { token, options });
|
||||
return RequestError::Challenge(RateLimitChallenge {
|
||||
token,
|
||||
options,
|
||||
retry_later: extract_retry_later(headers),
|
||||
});
|
||||
}
|
||||
}
|
||||
if status.as_u16() == 422 {
|
||||
@@ -452,6 +456,18 @@ mod testutil {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_headers(
|
||||
headers: &[(http::HeaderName, &'static str)],
|
||||
mut response: chat::Response,
|
||||
) -> chat::Response {
|
||||
response.headers.extend(
|
||||
headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), http::HeaderValue::from_static(v))),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
pub(crate) struct RequestValidator {
|
||||
pub expected: chat::Request,
|
||||
pub response: chat::Response,
|
||||
@@ -556,7 +572,12 @@ mod test {
|
||||
#[test_case(json(428, "{}") => matches Err(RequestError::Unexpected { log_safe: m }) if m.contains("428"))]
|
||||
#[test_case(json(
|
||||
428, r#"{"token": "zzz", "options": ["captcha"]}"#
|
||||
) => matches Err(RequestError::Challenge(RateLimitChallenge { token, options })) if token == "zzz" && options == vec![ChallengeOption::Captcha])]
|
||||
) => matches Err(RequestError::Challenge(RateLimitChallenge { token, options, retry_later: None })) if token == "zzz" && options == vec![ChallengeOption::Captcha])]
|
||||
#[test_case(with_headers(&[(http::header::RETRY_AFTER, "42")], json(
|
||||
428, r#"{"token": "zzz", "options": ["captcha"]}"#
|
||||
)) => matches Err(RequestError::Challenge(RateLimitChallenge { token, options, retry_later: Some(
|
||||
RetryLater { retry_after_seconds: 42 }
|
||||
) })) if token == "zzz" && options == vec![ChallengeOption::Captcha])]
|
||||
#[test_case(empty(422) => matches Err(RequestError::Unexpected { log_safe: m }) if m.contains("server validation"))]
|
||||
#[test_case(empty(419) => matches Err(RequestError::Unexpected { log_safe: m }) if m.contains("419"))]
|
||||
fn try_parse_empty(
|
||||
|
||||
@@ -550,6 +550,7 @@ mod test {
|
||||
|
||||
use futures_util::FutureExt;
|
||||
use libsignal_core::{Aci, Pni};
|
||||
use libsignal_net::infra::errors::RetryLater;
|
||||
use libsignal_protocol::{CiphertextMessage, PlaintextContent, Timestamp};
|
||||
use serde_json::json;
|
||||
use test_case::test_case;
|
||||
@@ -562,7 +563,7 @@ mod test {
|
||||
};
|
||||
use crate::api::{ChallengeOption, RateLimitChallenge, UserBasedAuthorization};
|
||||
use crate::ws::ACCESS_KEY_HEADER_NAME;
|
||||
use crate::ws::testutil::{JsonRequestValidator, RequestValidator, empty, json};
|
||||
use crate::ws::testutil::{JsonRequestValidator, RequestValidator, empty, json, with_headers};
|
||||
|
||||
const ACI_UUID: &str = "9d0652a3-dcc3-4d11-975f-74d61598733f";
|
||||
const PNI_UUID: &str = "796abedb-ca4e-4f18-8803-1fde5b921f9f";
|
||||
@@ -858,7 +859,12 @@ mod test {
|
||||
#[test_case(json(428, "{}") => matches Err(RequestError::Unexpected { log_safe: m }) if m.contains("428"))]
|
||||
#[test_case(json(
|
||||
428, r#"{"token": "zzz", "options": ["captcha"]}"#
|
||||
) => matches Err(RequestError::Challenge(RateLimitChallenge { token, options })) if token == "zzz" && options == vec![ChallengeOption::Captcha])]
|
||||
) => matches Err(RequestError::Challenge(RateLimitChallenge { token, options, retry_later: None })) if token == "zzz" && options == vec![ChallengeOption::Captcha])]
|
||||
#[test_case(with_headers(&[(http::header::RETRY_AFTER, "42")], json(
|
||||
428, r#"{"token": "zzz", "options": ["captcha"]}"#
|
||||
)) => matches Err(RequestError::Challenge(RateLimitChallenge { token, options, retry_later: Some(
|
||||
RetryLater { retry_after_seconds: 42 }
|
||||
) })) if token == "zzz" && options == vec![ChallengeOption::Captcha])]
|
||||
fn test_unsealed_send(response: Response) -> Result<(), RequestError<UnsealedSendFailure>> {
|
||||
let validator = JsonRequestValidator {
|
||||
expected: Request {
|
||||
@@ -953,7 +959,7 @@ mod test {
|
||||
)]
|
||||
#[test_case(json(
|
||||
428, r#"{"token": "zzz", "options": ["captcha"]}"#
|
||||
) => matches Err(RequestError::Challenge(RateLimitChallenge { token, options })) if token == "zzz" && options == vec![ChallengeOption::Captcha])]
|
||||
) => matches Err(RequestError::Challenge(RateLimitChallenge { token, options, retry_later: None })) if token == "zzz" && options == vec![ChallengeOption::Captcha])]
|
||||
fn test_sync_send(response: Response) -> Result<(), RequestError<MismatchedDeviceError>> {
|
||||
let validator = JsonRequestValidator {
|
||||
expected: Request {
|
||||
|
||||
@@ -58,7 +58,12 @@ public enum SignalError: Error {
|
||||
case networkProtocolError(String)
|
||||
case cdsiInvalidToken(String)
|
||||
case rateLimitedError(retryAfter: TimeInterval, message: String)
|
||||
case rateLimitChallengeError(token: String, options: Set<ChallengeOption>, message: String)
|
||||
case rateLimitChallengeError(
|
||||
token: String,
|
||||
options: Set<ChallengeOption>,
|
||||
retryAfter: TimeInterval?,
|
||||
message: String
|
||||
)
|
||||
case svrDataMissing(String)
|
||||
case svrRestoreFailed(triesRemaining: UInt32, message: String)
|
||||
case svrRotationMachineTooManySteps(String)
|
||||
@@ -230,9 +235,11 @@ internal func checkError(_ error: SignalFfiErrorRef?) throws {
|
||||
}
|
||||
throw SignalError.rateLimitedError(retryAfter: TimeInterval(retryAfterSeconds), message: errStr)
|
||||
case SignalErrorCodeRateLimitChallenge:
|
||||
let pair = try invokeFnReturningValueByPointer(.init()) {
|
||||
let outer_pair = try invokeFnReturningValueByPointer(.init()) {
|
||||
signal_error_get_rate_limit_challenge($0, error)
|
||||
}
|
||||
let pair = outer_pair.first
|
||||
let retryAfterRaw = outer_pair.second
|
||||
defer {
|
||||
signal_free_string(pair.first)
|
||||
signal_free_buffer(pair.second.base, pair.second.length)
|
||||
@@ -242,7 +249,8 @@ internal func checkError(_ error: SignalFfiErrorRef?) throws {
|
||||
throw SignalError.rateLimitChallengeError(
|
||||
token: token,
|
||||
options: Set(try options.lazy.map { try ChallengeOption(fromNative: $0) }),
|
||||
message: errStr
|
||||
retryAfter: (retryAfterRaw < 0) ? nil : TimeInterval(retryAfterRaw),
|
||||
message: errStr,
|
||||
)
|
||||
case SignalErrorCodeSvrDataMissing:
|
||||
throw SignalError.svrDataMissing(errStr)
|
||||
|
||||
@@ -976,6 +976,11 @@ typedef struct {
|
||||
SignalOwnedBuffer second;
|
||||
} SignalPairOfc_charOwnedBufferOfc_uchar;
|
||||
|
||||
typedef struct {
|
||||
SignalPairOfc_charOwnedBufferOfc_uchar first;
|
||||
int64_t second;
|
||||
} SignalPairOfPairOfc_charOwnedBufferOfc_uchari64;
|
||||
|
||||
typedef struct {
|
||||
const char *first;
|
||||
bool second;
|
||||
@@ -1938,7 +1943,7 @@ SignalFfiError *signal_error_get_mismatched_device_errors(SignalOwnedBufferOfFfi
|
||||
|
||||
SignalFfiError *signal_error_get_our_fingerprint_version(uint32_t *out, SignalUnwindSafeArgSignalFfiError err);
|
||||
|
||||
SignalFfiError *signal_error_get_rate_limit_challenge(SignalPairOfc_charOwnedBufferOfc_uchar *out, SignalUnwindSafeArgSignalFfiError err);
|
||||
SignalFfiError *signal_error_get_rate_limit_challenge(SignalPairOfPairOfc_charOwnedBufferOfc_uchari64 *out, SignalUnwindSafeArgSignalFfiError err);
|
||||
|
||||
SignalFfiError *signal_error_get_registration_error_not_deliverable(SignalPairOfc_charbool *out, SignalUnwindSafeArgSignalFfiError err);
|
||||
|
||||
|
||||
@@ -72,7 +72,8 @@ class RegistrationServiceConversionTests {
|
||||
let timeoutCase = ("Timeout", { (e: Error) in if case SignalError.requestTimeoutError("the request timed out") = e { true } else { false }})
|
||||
let requestNotValidCase = ("RequestWasNotValid", { (e: Error) in if case RegistrationError.unknown("the request did not pass server validation") = e { true } else { false }})
|
||||
let serverErrorCase = ("ServerSideError", { (e: Error) in if case RegistrationError.unknown("server-side error, retryable with backoff") = e { true } else { false }})
|
||||
let pushChallengeCase = ("PushChallenge", { (e: Error) in if case SignalError.rateLimitChallengeError(token: "token", options: [.pushChallenge], message: "retry after completing a rate limit challenge [PushChallenge]") = e { true } else { false }})
|
||||
let pushChallengeCase = ("PushChallenge", { (e: Error) in if case SignalError.rateLimitChallengeError(token: "token", options: [.pushChallenge], retryAfter: nil, message: "retry after completing a rate limit challenge [PushChallenge]") = e { true } else { false }})
|
||||
let pushChallengeRetryCase = ("PushChallengeRetryAfter42Seconds", { (e: Error) in if case SignalError.rateLimitChallengeError(token: "token42", options: [.pushChallenge], retryAfter: 42, message: "retry after completing a rate limit challenge [PushChallenge] (or retry after 42s)") = e { true } else { false }})
|
||||
|
||||
let cases = [
|
||||
ErrorTest("CreateSession", signal_testing_registration_service_create_session_error_convert, [
|
||||
@@ -83,6 +84,7 @@ class RegistrationServiceConversionTests {
|
||||
requestNotValidCase,
|
||||
serverErrorCase,
|
||||
pushChallengeCase,
|
||||
pushChallengeRetryCase,
|
||||
]),
|
||||
ErrorTest("ResumeSession", signal_testing_registration_service_resume_session_error_convert, [
|
||||
("InvalidSessionId", { if case RegistrationError.invalidSessionId("invalid session ID value") = $0 { true } else { false }}),
|
||||
@@ -92,6 +94,7 @@ class RegistrationServiceConversionTests {
|
||||
requestNotValidCase,
|
||||
serverErrorCase,
|
||||
pushChallengeCase,
|
||||
pushChallengeRetryCase,
|
||||
]),
|
||||
ErrorTest(
|
||||
"UpdateSession",
|
||||
|
||||
Reference in New Issue
Block a user